From 161960479f922ce078e56de13281fe6e99ee707f Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 6 May 2026 23:00:26 +0200 Subject: [PATCH 01/46] chore: prepare release 0.5.0 --- CHANGELOG.md | 4 ++++ README.md | 4 ++-- peglib-core/pom.xml | 2 +- peglib-formatter/pom.xml | 2 +- peglib-incremental/pom.xml | 2 +- peglib-maven-plugin/pom.xml | 2 +- peglib-playground/pom.xml | 2 +- pom.xml | 2 +- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8932a1..dde1cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-05-06 + +_Work in progress — incremental-native architectural rework. See `docs/incremental/ARCHITECTURE-0.5.0.md`._ + ## [0.4.3] - 2026-05-06 Performance — interactive editing focus. 19% faster median, 26% faster p95 on the IncrementalBenchmark editing-session suite. **One breaking change**: SourceSpan record components changed from two SourceLocations to six ints (see migration note). diff --git a/README.md b/README.md index 74b0946..7c1e0d3 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Quick links: org.pragmatica-lite peglib - 0.4.3 + 0.5.0 ``` @@ -394,7 +394,7 @@ The `peglib-maven-plugin` module (separate artifact, sibling to `peglib`) wraps org.pragmatica-lite peglib-maven-plugin - 0.4.3 + 0.5.0 diff --git a/peglib-core/pom.xml b/peglib-core/pom.xml index 7c063b4..61c47a9 100644 --- a/peglib-core/pom.xml +++ b/peglib-core/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite peglib-parent - 0.4.3 + 0.5.0 ../pom.xml diff --git a/peglib-formatter/pom.xml b/peglib-formatter/pom.xml index 9b41342..b65d86a 100644 --- a/peglib-formatter/pom.xml +++ b/peglib-formatter/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite peglib-parent - 0.4.3 + 0.5.0 ../pom.xml diff --git a/peglib-incremental/pom.xml b/peglib-incremental/pom.xml index 0331120..543a424 100644 --- a/peglib-incremental/pom.xml +++ b/peglib-incremental/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite peglib-parent - 0.4.3 + 0.5.0 ../pom.xml diff --git a/peglib-maven-plugin/pom.xml b/peglib-maven-plugin/pom.xml index 0944381..a296caf 100644 --- a/peglib-maven-plugin/pom.xml +++ b/peglib-maven-plugin/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite peglib-parent - 0.4.3 + 0.5.0 ../pom.xml diff --git a/peglib-playground/pom.xml b/peglib-playground/pom.xml index a30605d..f62a340 100644 --- a/peglib-playground/pom.xml +++ b/peglib-playground/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite peglib-parent - 0.4.3 + 0.5.0 ../pom.xml diff --git a/pom.xml b/pom.xml index 3eab7a7..e4a3c20 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.pragmatica-lite peglib-parent - 0.4.3 + 0.5.0 pom Peglib Parent From d00eaa1d6526459a1028e4e11971f8af5c68e638 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 6 May 2026 23:58:31 +0200 Subject: [PATCH 02/46] =?UTF-8?q?feat:=20phase=200a=20=E2=80=94=20IdGenera?= =?UTF-8?q?tor=20+=20LongLongMap=20foundation=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../incremental/experimental/IdGenerator.java | 52 ++++ .../LinearProbingLongLongMap.java | 191 ++++++++++++++ .../incremental/experimental/LongLongMap.java | 64 +++++ .../experimental/package-info.java | 18 ++ .../experimental/IdGeneratorTest.java | 49 ++++ .../experimental/LongLongMapTest.java | 239 ++++++++++++++++++ 6 files changed, 613 insertions(+) create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/package-info.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdGeneratorTest.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java new file mode 100644 index 0000000..b4e1e24 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java @@ -0,0 +1,52 @@ +package org.pragmatica.peg.incremental.experimental; +/** + * Source of stable {@code long} identifiers for CST nodes. + * + *

Phase 0 of the v0.5.0 incremental-native rework introduces stable per-node + * IDs (Lever A in {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2). Open + * question Q1 in §8 is resolved: the v0.5.0 strategy is a per-Session counter, + * not a process-global counter. Each {@link org.pragmatica.peg.incremental.Session} + * instantiates its own generator and the IDs it produces are unique only + * within that session's lineage. + * + *

The interface exists so that future strategies — a process-global + * {@link java.util.concurrent.atomic.AtomicLong}, content-derived hashes, or + * an external store — can swap in without disturbing call sites. Only + * {@link PerSessionCounter} is permitted today (YAGNI; add variants when a + * concrete need lands). + * + *

Implementations are not required to be thread-safe. + * {@link org.pragmatica.peg.incremental.Session} is single-threaded by design + * (per the {@code Session} Javadoc, concurrent edits against the same + * instance are undefined), so the parser engine never races on ID + * allocation. + * + * @since 0.5.0 + */ +public sealed interface IdGenerator permits IdGenerator.PerSessionCounter { + /** + * Produce the next ID. Successive calls on the same instance return + * strictly monotonically increasing values starting from 0. + */ + long next(); + + /** + * Single-threaded counter. The first call returns 0, then 1, 2, … + * + *

Overflow is theoretically possible after 2^63 calls but practically + * unreachable: at one ID per nanosecond it would take ~292 years to wrap. + * No overflow check is performed. + */ + final class PerSessionCounter implements IdGenerator { + private long next; + + public PerSessionCounter() { + this.next = 0L; + } + + @Override + public long next() { + return next++; + } + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java new file mode 100644 index 0000000..76c68a5 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java @@ -0,0 +1,191 @@ +package org.pragmatica.peg.incremental.experimental; +/** + * Open-addressing, linear-probing implementation of {@link LongLongMap}. + * + *

Capacity is always a power of two so the slot index can be computed via a + * bitmask. Resize at load factor 0.75 doubles the table. + * + *

Slot occupancy is encoded as a single {@code byte[]} ({@link #EMPTY}, + * {@link #OCCUPIED}, {@link #TOMBSTONE}) rather than two parallel + * {@code boolean[]}s. This keeps each probe step to one byte fetch + + * branch instead of two array reads, and halves the metadata footprint + * (1 byte/slot vs 2 bytes/slot for two parallel boolean arrays — the JVM + * stores a {@code boolean[]} element as a byte). + * + *

Probing rules: + *

    + *
  • {@link #put}: stop at the first empty slot or matching key; the first + * tombstone seen on the probe is remembered and reused as the + * insertion target if the key is not already present further on. + *
  • {@link #get}, {@link #containsKey}, {@link #remove}: walk past + * tombstones, stop at empty. + *
+ * + * @since 0.5.0 + */ +public final class LinearProbingLongLongMap implements LongLongMap { + /** Default backing-table capacity used by {@link #LinearProbingLongLongMap()}. */ + private static final int DEFAULT_CAPACITY = 16; + + private static final int MIN_CAPACITY = 4; + + /** Resize when {@code size > capacity * 0.75}. */ + private static final double LOAD_FACTOR = 0.75; + + private static final byte EMPTY = 0; + private static final byte OCCUPIED = 1; + private static final byte TOMBSTONE = 2; + + private long[] keys; + private long[] values; + private byte[] state; + private int size; + private int threshold; + private int mask; + + public LinearProbingLongLongMap() { + this(DEFAULT_CAPACITY); + } + + public LinearProbingLongLongMap(int initialCapacity) { + if (initialCapacity < 0) { + throw new IllegalArgumentException("initialCapacity must be non-negative: " + initialCapacity); + } + int capacity = roundUpToPowerOfTwo(Math.max(initialCapacity, MIN_CAPACITY)); + this.keys = new long[capacity]; + this.values = new long[capacity]; + this.state = new byte[capacity]; + this.size = 0; + this.mask = capacity - 1; + this.threshold = (int)(capacity * LOAD_FACTOR); + } + + @Override + public void put(long key, long value) { + int slot = slotFor(key); + int firstTombstone = - 1; + while (true) { + byte s = state[slot]; + if (s == EMPTY) { + int target = firstTombstone >= 0 + ? firstTombstone + : slot; + keys[target] = key; + values[target] = value; + state[target] = OCCUPIED; + size++; + if (size > threshold) { + resize(state.length<< 1); + } + return; + } + if (s == OCCUPIED && keys[slot] == key) { + values[slot] = value; + return; + } + if (s == TOMBSTONE && firstTombstone < 0) { + firstTombstone = slot; + } + slot = (slot + 1) & mask; + } + } + + @Override + public long get(long key) { + int slot = slotFor(key); + while (true) { + byte s = state[slot]; + if (s == EMPTY) { + return MISSING; + } + if (s == OCCUPIED && keys[slot] == key) { + return values[slot]; + } + slot = (slot + 1) & mask; + } + } + + @Override + public boolean containsKey(long key) { + int slot = slotFor(key); + while (true) { + byte s = state[slot]; + if (s == EMPTY) { + return false; + } + if (s == OCCUPIED && keys[slot] == key) { + return true; + } + slot = (slot + 1) & mask; + } + } + + @Override + public void remove(long key) { + int slot = slotFor(key); + while (true) { + byte s = state[slot]; + if (s == EMPTY) { + return; + } + if (s == OCCUPIED && keys[slot] == key) { + state[slot] = TOMBSTONE; + size--; + return; + } + slot = (slot + 1) & mask; + } + } + + @Override + public int size() { + return size; + } + + @Override + public void clear() { + java.util.Arrays.fill(state, EMPTY); + size = 0; + } + + @Override + public LongLongMap copy() { + var copy = new LinearProbingLongLongMap(state.length); + // Bypass the rounding/threshold dance — same capacity by construction. + System.arraycopy(this.keys, 0, copy.keys, 0, this.keys.length); + System.arraycopy(this.values, 0, copy.values, 0, this.values.length); + System.arraycopy(this.state, 0, copy.state, 0, this.state.length); + copy.size = this.size; + return copy; + } + + private int slotFor(long key) { + return Long.hashCode(key) & mask; + } + + private void resize(int newCapacity) { + long[] oldKeys = this.keys; + long[] oldValues = this.values; + byte[] oldState = this.state; + this.keys = new long[newCapacity]; + this.values = new long[newCapacity]; + this.state = new byte[newCapacity]; + this.mask = newCapacity - 1; + this.threshold = (int)(newCapacity * LOAD_FACTOR); + this.size = 0; + for (int i = 0; i < oldState.length; i++) { + if (oldState[i] == OCCUPIED) { + put(oldKeys[i], oldValues[i]); + } + } + } + + private static int roundUpToPowerOfTwo(int value) { + // Smallest power of two >= value, with a sane lower bound. + int n = MIN_CAPACITY; + while (n < value) { + n <<= 1; + } + return n; + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java new file mode 100644 index 0000000..936a955 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java @@ -0,0 +1,64 @@ +package org.pragmatica.peg.incremental.experimental; +/** + * Primitive-keyed, primitive-valued map ({@code long → long}) used as the + * backing store for the v0.5.0 {@code NodeIndex} (per + * {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A). + * + *

Open question Q2 in §8 is resolved: hand-rolled, no third-party + * collections dependency. Eclipse Collections would add ~10 MB of jar weight + * for a single data structure; boxed {@code Map} eats too much + * per-entry overhead and pressures the GC under tight per-edit budgets. + * + *

Sentinel. {@link #MISSING} is returned by {@link #get} + * for absent keys. Callers must not store {@code Long.MIN_VALUE} as a value; + * the {@code NodeIndex} use case (mapping child-id → parent-id, where IDs + * come from {@link IdGenerator.PerSessionCounter} and start at 0) honours + * this naturally. + * + *

Thread-safety. Implementations are not thread-safe. + * + *

TODO(0.5.x): consider swapping {@link LinearProbingLongLongMap} for a + * funnel-hashing variant per Farach-Colton, Krapivin, Kuszmaul, + * "Optimal Bounds for Open Addressing Without Reordering" (2025). + * + * @since 0.5.0 + */ +public sealed interface LongLongMap permits LinearProbingLongLongMap { + /** + * Returned by {@link #get(long)} for keys that are not present in the + * map. Callers must never use {@code Long.MIN_VALUE} as a stored value. + */ + long MISSING = Long.MIN_VALUE; + + /** + * Insert or overwrite the value for {@code key}. {@link #size()} grows + * when {@code key} is new and stays the same on overwrite. + */ + void put(long key, long value); + + /** + * Value associated with {@code key}, or {@link #MISSING} when absent. + */ + long get(long key); + + /** {@code true} iff {@code key} is present. */ + boolean containsKey(long key); + + /** + * Remove the entry for {@code key} if present. No-op when absent. + */ + void remove(long key); + + /** Number of live entries. */ + int size(); + + /** Discard every entry. Capacity is preserved by the implementation. */ + void clear(); + + /** + * Independent deep copy. Subsequent mutations on this map or the copy + * do not affect the other. Required for snapshot/rollback paths in + * future {@code TreeSplicer} work. + */ + LongLongMap copy(); +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/package-info.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/package-info.java new file mode 100644 index 0000000..7ed13c8 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/package-info.java @@ -0,0 +1,18 @@ +/** + * Sandbox for the peglib 0.5.0 architectural rework. + * + *

This package is experimental and additive. Code here is + * Phase 0 spike work for the v0.5.0 incremental-native architecture (see + * {@code docs/incremental/ARCHITECTURE-0.5.0.md}). Nothing in this package is + * referenced from {@code peglib-core} or the production + * {@code org.pragmatica.peg.incremental} / {@code .internal} packages — the + * spike must not perturb the existing 100 incremental tests or the wider 897 + * test corpus. + * + *

If the Phase 0 GO/NO-GO gate ships GO, types here will be promoted to + * the production packages during Phases 1–5. If NO-GO, the package is + * removed wholesale. + * + * @since 0.5.0 + */ +package org.pragmatica.peg.incremental.experimental; diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdGeneratorTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdGeneratorTest.java new file mode 100644 index 0000000..e267645 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdGeneratorTest.java @@ -0,0 +1,49 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Phase 0a foundation tests — single-threaded counter contract for + * {@link IdGenerator.PerSessionCounter}. + */ +final class IdGeneratorTest { + @Test + @DisplayName("First call returns 0") + void first_call_returns_zero() { + var gen = new IdGenerator.PerSessionCounter(); + assertThat(gen.next()).isEqualTo(0L); + } + + @Test + @DisplayName("Second call returns 1") + void second_call_returns_one() { + var gen = new IdGenerator.PerSessionCounter(); + gen.next(); + assertThat(gen.next()).isEqualTo(1L); + } + + @Test + @DisplayName("1000 calls return 0..999 in strict order") + void thousand_calls_strict_order() { + var gen = new IdGenerator.PerSessionCounter(); + for (long expected = 0; expected < 1000; expected++) { + assertThat(gen.next()).isEqualTo(expected); + } + } + + @Test + @DisplayName("Two independent counters do not share state") + void independent_counters_do_not_share_state() { + var first = new IdGenerator.PerSessionCounter(); + var second = new IdGenerator.PerSessionCounter(); + for (int i = 0; i < 5; i++) { + first.next(); + } + assertThat(second.next()).isEqualTo(0L); + assertThat(second.next()).isEqualTo(1L); + assertThat(first.next()).isEqualTo(5L); + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java new file mode 100644 index 0000000..04c0840 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java @@ -0,0 +1,239 @@ +package org.pragmatica.peg.incremental.experimental; + +import java.util.HashMap; +import java.util.Random; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Phase 0a foundation tests for {@link LinearProbingLongLongMap}. Covers the + * whole {@link LongLongMap} contract plus implementation-specific concerns: + * resize correctness, tombstone behaviour, hash-collision stress. + */ +final class LongLongMapTest { + @Test + @DisplayName("Empty map: size 0, no key contained, get returns MISSING") + void empty_map_state() { + LongLongMap map = new LinearProbingLongLongMap(); + assertThat(map.size()).isZero(); + assertThat(map.containsKey(0L)).isFalse(); + assertThat(map.get(0L)).isEqualTo(LongLongMap.MISSING); + assertThat(map.containsKey(Long.MAX_VALUE)).isFalse(); + assertThat(map.containsKey(- 1L)).isFalse(); + } + + @Test + @DisplayName("put then get returns the stored value, containsKey true") + void put_then_get() { + LongLongMap map = new LinearProbingLongLongMap(); + map.put(5L, 100L); + assertThat(map.get(5L)).isEqualTo(100L); + assertThat(map.containsKey(5L)).isTrue(); + assertThat(map.size()).isEqualTo(1); + } + + @Test + @DisplayName("put on existing key overwrites, size unchanged") + void put_overwrites_existing_key() { + LongLongMap map = new LinearProbingLongLongMap(); + map.put(5L, 100L); + map.put(5L, 200L); + assertThat(map.get(5L)).isEqualTo(200L); + assertThat(map.size()).isEqualTo(1); + } + + @Test + @DisplayName("1000 random distinct keys all retrievable") + void thousand_random_keys() { + LongLongMap map = new LinearProbingLongLongMap(); + var rng = new Random(0xCAFEBABEL); + var oracle = new HashMap(); + while (oracle.size() < 1000) { + long k = Math.abs(rng.nextLong() % Long.MAX_VALUE); + if (oracle.containsKey(k)) { + continue; + } + long v = rng.nextLong(); + oracle.put(k, v); + map.put(k, v); + } + assertThat(map.size()).isEqualTo(1000); + for (var entry : oracle.entrySet()) { + assertThat(map.containsKey(entry.getKey())).isTrue(); + assertThat(map.get(entry.getKey())).isEqualTo(entry.getValue()); + } + } + + @Test + @DisplayName("Pre-sized to 16 still grows correctly to hold 100 entries") + void resize_from_small_initial_capacity() { + LongLongMap map = new LinearProbingLongLongMap(16); + for (long k = 0; k < 100; k++) { + map.put(k, k * 10); + } + assertThat(map.size()).isEqualTo(100); + for (long k = 0; k < 100; k++) { + assertThat(map.containsKey(k)).isTrue(); + assertThat(map.get(k)).isEqualTo(k * 10); + } + } + + @Test + @DisplayName("remove on present key clears it, size decremented, neighbours intact") + void remove_present_key() { + LongLongMap map = new LinearProbingLongLongMap(); + map.put(1L, 10L); + map.put(2L, 20L); + map.put(3L, 30L); + map.remove(2L); + assertThat(map.containsKey(2L)).isFalse(); + assertThat(map.get(2L)).isEqualTo(LongLongMap.MISSING); + assertThat(map.size()).isEqualTo(2); + assertThat(map.get(1L)).isEqualTo(10L); + assertThat(map.get(3L)).isEqualTo(30L); + } + + @Test + @DisplayName("remove on absent key is a no-op") + void remove_absent_key_is_noop() { + LongLongMap map = new LinearProbingLongLongMap(); + map.put(1L, 10L); + map.remove(999L); + assertThat(map.size()).isEqualTo(1); + assertThat(map.get(1L)).isEqualTo(10L); + } + + @Test + @DisplayName("remove then put same key returns new value") + void remove_then_put_same_key() { + LongLongMap map = new LinearProbingLongLongMap(); + map.put(7L, 70L); + map.remove(7L); + map.put(7L, 700L); + assertThat(map.get(7L)).isEqualTo(700L); + assertThat(map.size()).isEqualTo(1); + } + + @Test + @DisplayName("Tombstone correctness: alternating put/remove of 1000 distinct keys") + void tombstone_correctness_under_alternating_ops() { + LongLongMap map = new LinearProbingLongLongMap(16); + var rng = new Random(0xDEADBEEFL); + var oracle = new HashMap(); + long[] keys = new long[1000]; + for (int i = 0; i < keys.length; i++) { + keys[i] = (long) i * 31 + rng.nextInt(7); + } + for (int i = 0; i < keys.length; i++) { + long k = keys[i]; + if ((i & 1) == 0) { + long v = rng.nextLong(); + map.put(k, v); + oracle.put(k, v); + } else { + map.remove(k); + oracle.remove(k); + } + } + // Drive a final put on every key so the surviving state is well-defined. + for (long k : keys) { + map.put(k, k + 1); + oracle.put(k, k + 1); + } + assertThat(map.size()).isEqualTo(oracle.size()); + for (var entry : oracle.entrySet()) { + assertThat(map.get(entry.getKey())).isEqualTo(entry.getValue()); + } + } + + @Test + @DisplayName("clear empties the map; keys no longer contained") + void clear_empties_map() { + LongLongMap map = new LinearProbingLongLongMap(); + for (long k = 0; k < 50; k++) { + map.put(k, k); + } + map.clear(); + assertThat(map.size()).isZero(); + for (long k = 0; k < 50; k++) { + assertThat(map.containsKey(k)).isFalse(); + assertThat(map.get(k)).isEqualTo(LongLongMap.MISSING); + } + // Map remains usable after clear. + map.put(123L, 456L); + assertThat(map.get(123L)).isEqualTo(456L); + } + + @Test + @DisplayName("copy is independent: mutations on original do not affect copy") + void copy_independence() { + LongLongMap original = new LinearProbingLongLongMap(); + original.put(1L, 10L); + original.put(2L, 20L); + original.put(3L, 30L); + LongLongMap copy = original.copy(); + original.put(1L, 999L); + original.remove(2L); + original.put(99L, 9900L); + assertThat(copy.size()).isEqualTo(3); + assertThat(copy.get(1L)).isEqualTo(10L); + assertThat(copy.get(2L)).isEqualTo(20L); + assertThat(copy.get(3L)).isEqualTo(30L); + assertThat(copy.containsKey(99L)).isFalse(); + // Mutating the copy must not leak back into original either. + copy.put(7L, 70L); + assertThat(original.containsKey(7L)).isFalse(); + } + + @Test + @DisplayName("Hash-collision stress: keys hashing to identical slot all retrievable") + void hash_collision_stress() { + // capacity is rounded up to power-of-two >= 16; for capacity 16, slot = hash & 15. + // Long.hashCode(k) = (int)(k ^ (k >>> 32)). For k = 0, 16, 32, 48 (small longs) + // the upper 32 bits are 0 so hashCode == (int) k, and (int) k & 15 == 0. + // All four keys collide in slot 0 — exercises linear probing. + LongLongMap map = new LinearProbingLongLongMap(16); + long[] colliders = {0L, 16L, 32L, 48L, 64L, 80L, 96L}; + for (int i = 0; i < colliders.length; i++) { + map.put(colliders[i], (long)(i + 1) * 1000L); + } + assertThat(map.size()).isEqualTo(colliders.length); + for (int i = 0; i < colliders.length; i++) { + assertThat(map.containsKey(colliders[i])).isTrue(); + assertThat(map.get(colliders[i])).isEqualTo((long)(i + 1) * 1000L); + } + // Remove from the middle of the probe chain; remaining keys must stay reachable. + map.remove(32L); + assertThat(map.containsKey(32L)).isFalse(); + for (long k : colliders) { + if (k == 32L) { + continue; + } + assertThat(map.containsKey(k)).as("key %d after removing 32", k) + .isTrue(); + } + // Re-insert across the tombstone — still finds itself. + map.put(32L, 12345L); + assertThat(map.get(32L)).isEqualTo(12345L); + } + + @Test + @DisplayName("Negative keys behave like any other long") + void negative_keys_supported() { + LongLongMap map = new LinearProbingLongLongMap(); + map.put(- 1L, 100L); + map.put(- 1000000L, 200L); + map.put(Long.MIN_VALUE + 1, 300L); + // avoid colliding with MISSING semantics + assertThat(map.get(- 1L)).isEqualTo(100L); + assertThat(map.get(- 1000000L)).isEqualTo(200L); + assertThat(map.get(Long.MIN_VALUE + 1)).isEqualTo(300L); + assertThat(map.size()).isEqualTo(3); + map.remove(- 1000000L); + assertThat(map.containsKey(- 1000000L)).isFalse(); + assertThat(map.size()).isEqualTo(2); + } +} From f0696a136e1d4828f5979eb03668eff72ca5f072 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 00:06:02 +0200 Subject: [PATCH 03/46] =?UTF-8?q?feat:=20phase=200b=20=E2=80=94=20sandbox?= =?UTF-8?q?=20IdCstNode=20with=20stable=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../incremental/experimental/IdCstNode.java | 150 ++++++++++++++++++ .../experimental/IdCstNodeBuilder.java | 90 +++++++++++ .../experimental/IdCstNodeBuilderTest.java | 124 +++++++++++++++ .../experimental/IdCstNodeTest.java | 148 +++++++++++++++++ 4 files changed, 512 insertions(+) create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeTest.java diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java new file mode 100644 index 0000000..d38f1b8 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java @@ -0,0 +1,150 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; +import java.util.Objects; + +/** + * Sandbox CST node carrying a stable {@code long id}. + * + *

Phase 0b spike for the v0.5.0 architectural rework + * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A and §7 R1). + * Mirrors the production {@link org.pragmatica.peg.tree.CstNode} shape and + * reuses the production {@link SourceSpan} and {@link Trivia} types directly; + * the sole structural delta is a leading {@code long id} component on every + * variant. + * + *

Equality contract (R1 mitigation). Per spec §7 R1, IDs + * are metadata and must not participate in identity. Each variant's + * {@code equals} and {@code hashCode} compare structural fields only and + * exclude {@code id}. Two nodes of the same variant with identical structure + * but different IDs are equal and share a hash code; nodes of different + * variants are never equal even if their structural fields match. + * + *

This type is sandbox-only — it is not referenced by {@code peglib-core} + * and will be promoted, reshaped, or deleted at the Phase 0 GO/NO-GO gate. + * + * @since 0.5.0 + */ +public sealed interface IdCstNode { + /** Stable identifier within the owning {@code Session}'s lineage. */ + long id(); + + /** Source span covered by this node (excluding trivia). */ + SourceSpan span(); + + /** Rule name that produced this node. */ + String rule(); + + /** Trivia preceding this node. */ + List leadingTrivia(); + + /** Trivia following this node. */ + List trailingTrivia(); + + /** Terminal node — leaf that matched literal text. */ + record Terminal(long id, + SourceSpan span, + String rule, + String text, + List leadingTrivia, + List trailingTrivia) implements IdCstNode { + @Override + public boolean equals(Object other) { + return other instanceof Terminal that + && Objects.equals(span, that.span) + && Objects.equals(rule, that.rule) + && Objects.equals(text, that.text) + && Objects.equals(leadingTrivia, that.leadingTrivia) + && Objects.equals(trailingTrivia, that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(Terminal.class, span, rule, text, leadingTrivia, trailingTrivia); + } + } + + /** Non-terminal node — interior node with children. */ + record NonTerminal(long id, + SourceSpan span, + String rule, + List children, + List leadingTrivia, + List trailingTrivia) implements IdCstNode { + @Override + public boolean equals(Object other) { + return other instanceof NonTerminal that + && Objects.equals(span, that.span) + && Objects.equals(rule, that.rule) + && Objects.equals(children, that.children) + && Objects.equals(leadingTrivia, that.leadingTrivia) + && Objects.equals(trailingTrivia, that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(NonTerminal.class, span, rule, children, leadingTrivia, trailingTrivia); + } + } + + /** + * Token node — result of the token boundary operator {@code < >}. + * Captures the matched text as a single unit. + */ + record Token(long id, + SourceSpan span, + String rule, + String text, + List leadingTrivia, + List trailingTrivia) implements IdCstNode { + @Override + public boolean equals(Object other) { + return other instanceof Token that + && Objects.equals(span, that.span) + && Objects.equals(rule, that.rule) + && Objects.equals(text, that.text) + && Objects.equals(leadingTrivia, that.leadingTrivia) + && Objects.equals(trailingTrivia, that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(Token.class, span, rule, text, leadingTrivia, trailingTrivia); + } + } + + /** + * Error node — unparseable input region during error recovery. + * Mirrors the production {@code CstNode.Error} shape; {@link #rule()} + * returns {@code ""}. + */ + record Error(long id, + SourceSpan span, + String skippedText, + String expected, + List leadingTrivia, + List trailingTrivia) implements IdCstNode { + @Override + public String rule() { + return ""; + } + + @Override + public boolean equals(Object other) { + return other instanceof Error that + && Objects.equals(span, that.span) + && Objects.equals(skippedText, that.skippedText) + && Objects.equals(expected, that.expected) + && Objects.equals(leadingTrivia, that.leadingTrivia) + && Objects.equals(trailingTrivia, that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(Error.class, span, skippedText, expected, leadingTrivia, trailingTrivia); + } + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java new file mode 100644 index 0000000..3cf2152 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java @@ -0,0 +1,90 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.pragmatica.peg.tree.CstNode; + +import java.util.ArrayList; +import java.util.List; + +/** + * Walks a production {@link CstNode} tree and emits the corresponding + * {@link IdCstNode} tree, assigning a stable {@code long id} to every node + * via the supplied {@link IdGenerator}. + * + *

Phase 0b sandbox component for the v0.5.0 architectural rework + * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A). It exists + * to prove the ID-assignment plumbing without disturbing the production + * {@link CstNode} surface. + * + *

Order of assignment. Children are converted before + * their parents (post-order). The parent therefore always carries an ID + * strictly greater than any descendant's ID under + * {@link IdGenerator.PerSessionCounter}. The algorithm does not depend on + * this; it is a debugging convenience aligned with the spec phrase + * "ID assignment ... at construction". + * + *

Trivia handling. Trivia lists are copied by reference. + * The production tree treats them as immutable; preserving reference identity + * lets equality tests assert pre/post identity cheaply and avoids needless + * allocation. + * + * @since 0.5.0 + */ +public final class IdCstNodeBuilder { + private final IdGenerator idGen; + + public IdCstNodeBuilder(IdGenerator idGen) { + this.idGen = idGen; + } + + /** Convert {@code source} and every descendant into an {@link IdCstNode}. */ + public IdCstNode build(CstNode source) { + return switch (source) { + case CstNode.Terminal t -> buildTerminal(t); + case CstNode.Token t -> buildToken(t); + case CstNode.Error e -> buildError(e); + case CstNode.NonTerminal n -> buildNonTerminal(n); + }; + } + + private IdCstNode buildTerminal(CstNode.Terminal t) { + return new IdCstNode.Terminal(idGen.next(), + t.span(), + t.rule(), + t.text(), + t.leadingTrivia(), + t.trailingTrivia()); + } + + private IdCstNode buildToken(CstNode.Token t) { + return new IdCstNode.Token(idGen.next(), + t.span(), + t.rule(), + t.text(), + t.leadingTrivia(), + t.trailingTrivia()); + } + + private IdCstNode buildError(CstNode.Error e) { + return new IdCstNode.Error(idGen.next(), + e.span(), + e.skippedText(), + e.expected(), + e.leadingTrivia(), + e.trailingTrivia()); + } + + private IdCstNode buildNonTerminal(CstNode.NonTerminal n) { + // Post-order: convert children first so descendants get lower IDs. + var sourceChildren = n.children(); + var converted = new ArrayList(sourceChildren.size()); + for (CstNode child : sourceChildren) { + converted.add(build(child)); + } + return new IdCstNode.NonTerminal(idGen.next(), + n.span(), + n.rule(), + List.copyOf(converted), + n.leadingTrivia(), + n.trailingTrivia()); + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java new file mode 100644 index 0000000..c9a6f09 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java @@ -0,0 +1,124 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.CstNode; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Phase 0b builder tests — confirm post-order id assignment and + * field round-tripping for every production {@link CstNode} variant. + */ +final class IdCstNodeBuilderTest { + private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 4, 3); + private static final SourceSpan SPAN_INNER = new SourceSpan(1, 1, 0, 1, 2, 1); + private static final List NO_TRIVIA = List.of(); + + private IdCstNodeBuilder freshBuilder() { + return new IdCstNodeBuilder(new IdGenerator.PerSessionCounter()); + } + + @Test + @DisplayName("Single Terminal gets id 0") + void single_terminal_gets_id_zero() { + CstNode source = new CstNode.Terminal(SPAN, "Number", "123", NO_TRIVIA, NO_TRIVIA); + var built = freshBuilder().build(source); + assertThat(built).isInstanceOf(IdCstNode.Terminal.class); + var terminal = (IdCstNode.Terminal) built; + assertThat(terminal.id()).isEqualTo(0L); + assertThat(terminal.span()).isEqualTo(SPAN); + assertThat(terminal.rule()).isEqualTo("Number"); + assertThat(terminal.text()).isEqualTo("123"); + } + + @Test + @DisplayName("NonTerminal with three Terminal children: children 0,1,2 / parent 3 (post-order)") + void nonterminal_post_order_ids() { + var c1 = new CstNode.Terminal(SPAN, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var c2 = new CstNode.Terminal(SPAN, "Number", "2", NO_TRIVIA, NO_TRIVIA); + var c3 = new CstNode.Terminal(SPAN, "Number", "3", NO_TRIVIA, NO_TRIVIA); + CstNode parent = new CstNode.NonTerminal(SPAN, "Expr", List.of(c1, c2, c3), NO_TRIVIA, NO_TRIVIA); + + var built = (IdCstNode.NonTerminal) freshBuilder().build(parent); + + assertThat(built.id()).isEqualTo(3L); + assertThat(built.children()).hasSize(3); + assertThat(built.children().get(0).id()).isEqualTo(0L); + assertThat(built.children().get(1).id()).isEqualTo(1L); + assertThat(built.children().get(2).id()).isEqualTo(2L); + } + + @Test + @DisplayName("3-deep nested NonTerminal -> NonTerminal -> Terminal: 0,1,2 inside-out") + void three_deep_nested_ids() { + var leaf = new CstNode.Terminal(SPAN_INNER, "Atom", "x", NO_TRIVIA, NO_TRIVIA); + var middle = new CstNode.NonTerminal(SPAN_INNER, "Inner", List.of(leaf), NO_TRIVIA, NO_TRIVIA); + CstNode root = new CstNode.NonTerminal(SPAN, "Outer", List.of(middle), NO_TRIVIA, NO_TRIVIA); + + var builtRoot = (IdCstNode.NonTerminal) freshBuilder().build(root); + var builtMiddle = (IdCstNode.NonTerminal) builtRoot.children().get(0); + var builtLeaf = (IdCstNode.Terminal) builtMiddle.children().get(0); + + assertThat(builtLeaf.id()).isEqualTo(0L); + assertThat(builtMiddle.id()).isEqualTo(1L); + assertThat(builtRoot.id()).isEqualTo(2L); + } + + @Test + @DisplayName("Mixed tree with Token and Error preserves all fields and assigns ids to every node") + void mixed_variants_round_trip() { + var token = new CstNode.Token(SPAN_INNER, "Ident", "foo", NO_TRIVIA, NO_TRIVIA); + var error = new CstNode.Error(SPAN_INNER, "@@@", "expected ident", NO_TRIVIA, NO_TRIVIA); + var terminal = new CstNode.Terminal(SPAN_INNER, "Number", "1", NO_TRIVIA, NO_TRIVIA); + CstNode root = new CstNode.NonTerminal(SPAN, "Mixed", List.of(token, error, terminal), NO_TRIVIA, NO_TRIVIA); + + var builtRoot = (IdCstNode.NonTerminal) freshBuilder().build(root); + + assertThat(builtRoot.children()).hasSize(3); + var builtToken = (IdCstNode.Token) builtRoot.children().get(0); + var builtError = (IdCstNode.Error) builtRoot.children().get(1); + var builtTerminal = (IdCstNode.Terminal) builtRoot.children().get(2); + + assertThat(builtToken.id()).isEqualTo(0L); + assertThat(builtToken.text()).isEqualTo("foo"); + assertThat(builtError.id()).isEqualTo(1L); + assertThat(builtError.skippedText()).isEqualTo("@@@"); + assertThat(builtError.expected()).isEqualTo("expected ident"); + assertThat(builtError.rule()).isEqualTo(""); + assertThat(builtTerminal.id()).isEqualTo(2L); + assertThat(builtTerminal.text()).isEqualTo("1"); + assertThat(builtRoot.id()).isEqualTo(3L); + } + + @Test + @DisplayName("Trivia lists are reference-equal pre/post conversion") + void trivia_reference_identity_preserved() { + var leading = List.of(new Trivia.Whitespace(SPAN_INNER, " ")); + var trailing = List.of(new Trivia.LineComment(SPAN_INNER, "// done")); + CstNode source = new CstNode.Terminal(SPAN, "Number", "1", leading, trailing); + + var built = (IdCstNode.Terminal) freshBuilder().build(source); + + assertThat(built.leadingTrivia()).isSameAs(leading); + assertThat(built.trailingTrivia()).isSameAs(trailing); + } + + @Test + @DisplayName("Two builds with independent counters yield equal trees (id excluded from equality)") + void independent_builds_are_structurally_equal() { + var c1 = new CstNode.Terminal(SPAN_INNER, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var c2 = new CstNode.Terminal(SPAN_INNER, "Number", "2", NO_TRIVIA, NO_TRIVIA); + CstNode source = new CstNode.NonTerminal(SPAN, "Expr", List.of(c1, c2), NO_TRIVIA, NO_TRIVIA); + + var first = freshBuilder().build(source); + var second = freshBuilder().build(source); + + assertThat(first).isEqualTo(second); + assertThat(first.hashCode()).isEqualTo(second.hashCode()); + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeTest.java new file mode 100644 index 0000000..8d463d7 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeTest.java @@ -0,0 +1,148 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Phase 0b foundation tests for {@link IdCstNode}. + * + *

Asserts the {@code id}-as-metadata equality contract from spec §7 R1: + * structural equality excludes the id, and cross-variant comparisons never + * collide. + */ +final class IdCstNodeTest { + private static final SourceSpan SPAN_A = new SourceSpan(1, 1, 0, 1, 4, 3); + private static final SourceSpan SPAN_B = new SourceSpan(1, 5, 4, 1, 8, 7); + private static final List NO_TRIVIA = List.of(); + + @Nested + @DisplayName("Construction and accessors") + final class Construction { + @Test + @DisplayName("Terminal exposes its assigned id") + void terminal_exposes_id() { + var node = new IdCstNode.Terminal(42L, SPAN_A, "Number", "123", NO_TRIVIA, NO_TRIVIA); + assertThat(node.id()).isEqualTo(42L); + assertThat(node.span()).isEqualTo(SPAN_A); + assertThat(node.rule()).isEqualTo("Number"); + assertThat(node.text()).isEqualTo("123"); + } + + @Test + @DisplayName("NonTerminal exposes its assigned id and children") + void nonterminal_exposes_id() { + var child = new IdCstNode.Terminal(0L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var parent = new IdCstNode.NonTerminal(7L, SPAN_A, "Expr", List.of(child), NO_TRIVIA, NO_TRIVIA); + assertThat(parent.id()).isEqualTo(7L); + assertThat(parent.children()).containsExactly(child); + } + + @Test + @DisplayName("Token exposes its assigned id") + void token_exposes_id() { + var node = new IdCstNode.Token(9L, SPAN_A, "Ident", "foo", NO_TRIVIA, NO_TRIVIA); + assertThat(node.id()).isEqualTo(9L); + assertThat(node.text()).isEqualTo("foo"); + } + + @Test + @DisplayName("Error exposes its assigned id and reports rule()=") + void error_exposes_id_and_default_rule() { + var node = new IdCstNode.Error(11L, SPAN_A, "@@@", "expected number", NO_TRIVIA, NO_TRIVIA); + assertThat(node.id()).isEqualTo(11L); + assertThat(node.rule()).isEqualTo(""); + assertThat(node.skippedText()).isEqualTo("@@@"); + assertThat(node.expected()).isEqualTo("expected number"); + } + } + + @Nested + @DisplayName("Equality excludes id (spec §7 R1)") + final class Equality { + @Test + @DisplayName("Terminals with identical structure but different ids are equal") + void terminal_equality_ignores_id() { + var a = new IdCstNode.Terminal(0L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var b = new IdCstNode.Terminal(999L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("Terminals with different text are not equal") + void terminal_inequality_when_text_differs() { + var a = new IdCstNode.Terminal(0L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var b = new IdCstNode.Terminal(0L, SPAN_A, "Number", "2", NO_TRIVIA, NO_TRIVIA); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("NonTerminals are equal when children are structurally equal even with differing ids throughout") + void nonterminal_equality_ignores_id_recursively() { + var leftChild = new IdCstNode.Terminal(0L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var rightChild = new IdCstNode.Terminal(500L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var left = new IdCstNode.NonTerminal(1L, SPAN_A, "Expr", List.of(leftChild), NO_TRIVIA, NO_TRIVIA); + var right = new IdCstNode.NonTerminal(900L, SPAN_A, "Expr", List.of(rightChild), NO_TRIVIA, NO_TRIVIA); + assertThat(left).isEqualTo(right); + assertThat(left.hashCode()).isEqualTo(right.hashCode()); + } + + @Test + @DisplayName("NonTerminals with different rules are not equal") + void nonterminal_inequality_when_rule_differs() { + var a = new IdCstNode.NonTerminal(0L, SPAN_A, "Expr", List.of(), NO_TRIVIA, NO_TRIVIA); + var b = new IdCstNode.NonTerminal(0L, SPAN_A, "Stmt", List.of(), NO_TRIVIA, NO_TRIVIA); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("Tokens with identical structure but different ids are equal") + void token_equality_ignores_id() { + var a = new IdCstNode.Token(2L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); + var b = new IdCstNode.Token(8L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("Tokens with different spans are not equal") + void token_inequality_when_span_differs() { + var a = new IdCstNode.Token(0L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); + var b = new IdCstNode.Token(0L, SPAN_B, "Ident", "x", NO_TRIVIA, NO_TRIVIA); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("Errors with identical structure but different ids are equal") + void error_equality_ignores_id() { + var a = new IdCstNode.Error(0L, SPAN_A, "@@@", "expected number", NO_TRIVIA, NO_TRIVIA); + var b = new IdCstNode.Error(123L, SPAN_A, "@@@", "expected number", NO_TRIVIA, NO_TRIVIA); + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("Errors with different skippedText are not equal") + void error_inequality_when_skipped_differs() { + var a = new IdCstNode.Error(0L, SPAN_A, "@@@", "expected number", NO_TRIVIA, NO_TRIVIA); + var b = new IdCstNode.Error(0L, SPAN_A, "###", "expected number", NO_TRIVIA, NO_TRIVIA); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("Cross-variant comparison is never equal even with overlapping fields") + void cross_variant_never_equal() { + var terminal = new IdCstNode.Terminal(0L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); + var token = new IdCstNode.Token(0L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); + assertThat(terminal).isNotEqualTo(token); + assertThat(token).isNotEqualTo(terminal); + } + } +} From 849b4ba5156a9bd67fb8932f4fc01b47a7d7a1c2 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 00:15:40 +0200 Subject: [PATCH 04/46] =?UTF-8?q?feat:=20phase=200c=20=E2=80=94=20sandbox?= =?UTF-8?q?=20IdNodeIndex=20with=20O(splicedSize+depth)=20incremental=20up?= =?UTF-8?q?date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../incremental/experimental/IdNodeIndex.java | 284 +++++++++++++ .../experimental/IdNodeIndexTest.java | 395 ++++++++++++++++++ 2 files changed, 679 insertions(+) create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdNodeIndexTest.java diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java new file mode 100644 index 0000000..f6f151f --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java @@ -0,0 +1,284 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.pragmatica.lang.Option; + +import java.util.ArrayList; +import java.util.List; + +/** + * Stable-ID-keyed parent index for {@link IdCstNode} trees — Phase 0c sandbox. + * + *

Mirrors the production + * {@link org.pragmatica.peg.incremental.internal.NodeIndex} surface (parent + * lookup, presence test, root accessor) but keys the parent map by the stable + * {@code long id} carried on each {@link IdCstNode} record (per + * {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A) instead of JVM + * identity. This unlocks the central perf claim of the v0.5.0 rework: + * {@link #applyIncremental} is {@code O(splicedSize + depth × branchingFactor)}, + * not {@code O(N)}. + * + *

Two construction paths

+ *
    + *
  • {@link #build(IdCstNode)} — initial full walk over a tree. {@code O(N)} + * in the node count. Used once per session at the initial parse. + *
  • {@link #applyIncremental(IdCstNode, List, List)} — splice-and-shift + * update producing a new index that reflects the post-edit tree. + * {@code O(oldPath.size() + oldPivotSize + newPivotSize + + * newPath.size() × branchingFactor)} — typically 100-300 map operations + * even on a 91k-node tree, vs the 91k operations a full rebuild would + * require. This is the hot path. + *
+ * + *

Mutate-in-place / invalidate-on-incremental semantics

+ * + *

{@link #applyIncremental} mutates the underlying parents map in + * place and returns a NEW {@link IdNodeIndex} sharing that same map + * with the receiver. The receiver becomes invalid after the call. + * Callers MUST use the returned instance and discard the receiver. Any + * subsequent observation of the receiver — including {@link #parentIdOf}, + * {@link #contains}, {@link #size}, {@link #root} — is undefined behaviour. + * + *

The reason: spike-grade perf measurement. Copying the {@link LongLongMap} + * on every incremental update would add an {@code O(N)} step that defeats the + * O(δ) cost claim we're trying to validate. Production v0.5.0 will likely + * use a persistent (path-copying) map to recover snapshot semantics — that + * design exploration is explicitly out of scope for Phase 0c. + * + *

This sandbox class is not referenced by {@code peglib-core} and will be + * promoted, reshaped, or deleted at the Phase 0 GO/NO-GO gate. + * + * @since 0.5.0 + */ +public final class IdNodeIndex { + private final IdCstNode root; + private final LongLongMap parents; + + /** + * Test hook — number of {@code parents.put} calls performed during the + * most recent {@link #applyIncremental} invocation that produced this + * index, or {@code -1} when this index was produced by {@link #build}. + * Package-private; used by Phase 0c microcount tests to validate the + * algorithm is O(δ), not O(N). Will be removed when the spike is + * promoted (test-only instrumentation). + */ + final int lastIncrementalPutCount; + + private IdNodeIndex(IdCstNode root, LongLongMap parents, int lastIncrementalPutCount) { + this.root = root; + this.parents = parents; + this.lastIncrementalPutCount = lastIncrementalPutCount; + } + + /** + * Build a fresh index over {@code root}. {@code O(N)} in the descendant + * count. Used only on the initial parse; subsequent edits should call + * {@link #applyIncremental} for {@code O(δ)} updates. + * + *

The backing {@link LongLongMap} is pre-sized to the exact descendant + * count to avoid resize churn during the build (mirrors the 0.4.3 + * {@code NodeIndex} pre-sizing fix; see {@code HANDOVER.md} §5). + */ + public static IdNodeIndex build(IdCstNode root) { + int expectedSize = countDescendants(root); + var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); + indexChildren(root, parents); + return new IdNodeIndex(root, parents, -1); + } + + /** + * Splice-and-shift update: produce a new {@link IdNodeIndex} reflecting a + * post-edit tree. + * + *

Invalidates the receiver. See class Javadoc. Callers + * MUST use the returned instance and discard {@code this}. + * + * @param newRoot root of the post-edit tree + * @param oldPath {@code root → oldPivot} chain in the pre-edit tree, + * inclusive (size ≥ 1; first element is the pre-edit root, + * last element is the pre-edit pivot — the smallest node + * whose subtree was wholesale replaced) + * @param newPath {@code root → newPivot} chain in the post-edit tree, + * inclusive (size ≥ 1; first element is {@code newRoot}, + * last element is the post-edit pivot) + * + *

Algorithm (per spec §2)

+ *
    + *
  1. Step 1 — Remove dead entries. Every node on the + * old path was replaced (their record identities are dead — the + * newPath nodes carry fresh IDs). For each {@code oldNode} in + * {@code oldPath} we remove its up-entry. Then we walk the old + * pivot's descendants (excluding the pivot itself, already removed + * in the previous loop) and remove their up-entries — the old + * pivot's subtree is wholesale replaced, so none of its descendants + * survive in the new tree by ID. + * Cost: {@code O(oldPath.size() + oldPivotSize)}. + *
  2. Step 2 — Insert new spliced subtree. Walk the new + * pivot's subtree pre-order and {@code parents.put(child.id, + * parent.id)} for every parent-child pair. Wire the new pivot to + * its new parent (the second-to-last entry in {@code newPath}) when + * the pivot is not itself the root. + * Cost: {@code O(newPivotSize)}. + *
  3. Step 3 — Walk new ancestor chain top-down. For + * each new ancestor on {@code newPath} except the pivot itself, set + * parent links for ALL its direct children. This catches sibling + * subtrees that are record-shared with the old tree (same internal + * IDs survive) but whose parent in the new tree has a fresh ID + * (different from the old ancestor's ID). Their internal subtrees + * are NOT walked — the {@code parents} entries inside those shared + * subtrees were created by an earlier {@link #build} or + * {@link #applyIncremental} and remain correct because the IDs + * there are unchanged. + * Cost: {@code O(newPath.size() × branchingFactor)}. + *
+ * + *

Total cost: O(splicedSize + depth × branchingFactor). + * On the 1900-LOC Java fixture (≈10k nodes, depth ≈ 30, branching ≈ 4), + * a single-token edit produces ≈100-300 map operations vs ≈10k for full + * rebuild — the central perf claim of the v0.5.0 rework. + * + *

Resolved spec ambiguities

+ *
    + *
  • Step 1 scope of {@code oldPath} removal: spec + * reads "Walk oldPath … remove their entries". We interpret this as + * every node on the old path (including {@code oldRoot}), + * because even when {@code oldRoot.id == newRoot.id} structurally, + * a splice that touches the root produces a new {@link IdCstNode} + * record with a fresh id (the {@code IdCstNodeBuilder} assigns IDs + * fresh per build). Step 3 then re-establishes parent entries for + * the surviving siblings under the new ancestor chain. + *
  • Step 1 walk of pivot descendants: the spec is + * silent on whether the pivot's subtree must be cleared. We clear + * it (excluding the pivot itself, already removed) because the + * splice replaces the pivot's subtree wholesale; no + * subtree-internal record sharing is preserved across the splice + * boundary. Step 2 then inserts the new pivot's subtree. + *
+ */ + public IdNodeIndex applyIncremental(IdCstNode newRoot, List oldPath, List newPath) { + if (oldPath == null || oldPath.isEmpty()) { + throw new IllegalArgumentException("oldPath must contain at least the old root"); + } + if (newPath == null || newPath.isEmpty()) { + throw new IllegalArgumentException("newPath must contain at least the new root"); + } + var oldPivot = oldPath.get(oldPath.size() - 1); + var newPivot = newPath.get(newPath.size() - 1); + + int putCount = 0; + + // Step 1a — Remove every old-path node's up-entry (their records are dead). + for (var oldNode : oldPath) { + parents.remove(oldNode.id()); + } + // Step 1b — Remove the old pivot's descendants (subtree replaced wholesale). + var oldPivotDescendants = new ArrayList(); + flattenDescendantsInto(oldPivot, oldPivotDescendants); + for (var d : oldPivotDescendants) { + parents.remove(d.id()); + } + + // Step 2 — Insert new pivot's subtree internal links. + indexChildren(newPivot, parents); + putCount += subtreeChildCount(newPivot); + // Wire pivot to its new parent (unless pivot is the new root). + if (newPath.size() >= 2) { + var newPivotParent = newPath.get(newPath.size() - 2); + parents.put(newPivot.id(), newPivotParent.id()); + putCount++; + } + + // Step 3 — Walk new ancestor chain (excluding pivot — already wired in step 2). + // For each ancestor, set parent links for ALL its direct children. Sibling + // subtrees keep their internal entries (same IDs, still correct). + for (int i = 0; i < newPath.size() - 1; i++) { + var ancestor = newPath.get(i); + if (ancestor instanceof IdCstNode.NonTerminal nt) { + for (var child : nt.children()) { + parents.put(child.id(), ancestor.id()); + putCount++; + } + } + } + + return new IdNodeIndex(newRoot, parents, putCount); + } + + /** Root of the CST this index reflects. */ + public IdCstNode root() { + return root; + } + + /** + * Parent ID of the node identified by {@code childId}, or + * {@link Option#none()} when {@code childId} is the root, absent from this + * index, or has been removed. + */ + public Option parentIdOf(long childId) { + if (!parents.containsKey(childId)) { + return Option.none(); + } + return Option.some(parents.get(childId)); + } + + /** + * {@code true} iff {@code id} is present as a key (i.e., the node has a + * parent in this index). Note: the root itself returns {@code false} + * because the root has no parent entry. + */ + public boolean contains(long id) { + return parents.containsKey(id); + } + + /** Number of parent entries — equals {@code descendantCount(root)}. */ + public int size() { + return parents.size(); + } + + // -- Helpers (mirror the production NodeIndex static helpers) -- + + private static int countDescendants(IdCstNode node) { + int count = 0; + if (node instanceof IdCstNode.NonTerminal nt) { + for (var child : nt.children()) { + count += 1 + countDescendants(child); + } + } + return count; + } + + /** + * Pre-order recursive walk: for every parent-child pair under {@code node} + * (inclusive), {@code parents.put(child.id, parent.id)}. Mirrors the + * production {@code NodeIndex.indexChildren}. + */ + private static void indexChildren(IdCstNode node, LongLongMap parents) { + if (node instanceof IdCstNode.NonTerminal nt) { + for (var child : nt.children()) { + parents.put(child.id(), nt.id()); + indexChildren(child, parents); + } + } + } + + /** + * Pre-order: append every strict descendant of {@code node} to + * {@code out} (excludes {@code node} itself). Used in step 1 to enumerate + * the old pivot's subtree for removal. + */ + private static void flattenDescendantsInto(IdCstNode node, List out) { + if (node instanceof IdCstNode.NonTerminal nt) { + for (var child : nt.children()) { + out.add(child); + flattenDescendantsInto(child, out); + } + } + } + + /** + * Counts the number of {@code parents.put} calls {@link #indexChildren} + * will perform on {@code node} — equals the descendant count. + */ + private static int subtreeChildCount(IdCstNode node) { + return countDescendants(node); + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdNodeIndexTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdNodeIndexTest.java new file mode 100644 index 0000000..629f738 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdNodeIndexTest.java @@ -0,0 +1,395 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Phase 0c tests for {@link IdNodeIndex}: full-build correctness, incremental + * update equivalence to full-build, and the central perf microcount that + * proves {@link IdNodeIndex#applyIncremental} is {@code O(δ)}, not + * {@code O(N)} (per + * {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A and §8 Q3). + */ +final class IdNodeIndexTest { + private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); + private static final List NO_TRIVIA = List.of(); + + private static IdCstNode.Terminal terminal(IdGenerator gen, String text) { + return new IdCstNode.Terminal(gen.next(), SPAN, "T", text, NO_TRIVIA, NO_TRIVIA); + } + + private static IdCstNode.NonTerminal nonTerminal(IdGenerator gen, String rule, List children) { + return new IdCstNode.NonTerminal(gen.next(), SPAN, rule, children, NO_TRIVIA, NO_TRIVIA); + } + + @Nested + @DisplayName("build") + class BuildTests { + @Test + @DisplayName("Single Terminal as root: size 0, no parent entry for root") + void single_terminal_root() { + var gen = new IdGenerator.PerSessionCounter(); + var root = terminal(gen, "x"); + + var index = IdNodeIndex.build(root); + + assertThat(index.size()).isEqualTo(0); + assertThat(index.contains(root.id())).isFalse(); + assertThat(index.parentIdOf(root.id()).isEmpty()).isTrue(); + assertThat(index.root()).isSameAs(root); + } + + @Test + @DisplayName("NonTerminal with 3 Terminal children: each child points to root") + void nonterminal_three_children() { + var gen = new IdGenerator.PerSessionCounter(); + var c1 = terminal(gen, "a"); + var c2 = terminal(gen, "b"); + var c3 = terminal(gen, "c"); + IdCstNode root = nonTerminal(gen, "Expr", List.of(c1, c2, c3)); + + var index = IdNodeIndex.build(root); + + assertThat(index.size()).isEqualTo(3); + assertThat(index.parentIdOf(c1.id())).isEqualTo(org.pragmatica.lang.Option.some(root.id())); + assertThat(index.parentIdOf(c2.id())).isEqualTo(org.pragmatica.lang.Option.some(root.id())); + assertThat(index.parentIdOf(c3.id())).isEqualTo(org.pragmatica.lang.Option.some(root.id())); + assertThat(index.contains(root.id())).isFalse(); + } + + @Test + @DisplayName("Three-deep tree: every interior parent link present, root has none") + void three_deep_tree() { + var gen = new IdGenerator.PerSessionCounter(); + var leaf = terminal(gen, "x"); + var middle = nonTerminal(gen, "Inner", List.of(leaf)); + IdCstNode root = nonTerminal(gen, "Outer", List.of(middle)); + + var index = IdNodeIndex.build(root); + + assertThat(index.size()).isEqualTo(2); + assertThat(index.parentIdOf(leaf.id())).isEqualTo(org.pragmatica.lang.Option.some(middle.id())); + assertThat(index.parentIdOf(middle.id())).isEqualTo(org.pragmatica.lang.Option.some(root.id())); + assertThat(index.contains(root.id())).isFalse(); + } + } + + @Nested + @DisplayName("applyIncremental") + class ApplyIncrementalTests { + + @Test + @DisplayName("Trivial replacement: middle child swapped under same root id") + void trivial_replacement() { + // Old tree: root -> [A, B, C] + // New tree: newRoot (fresh id) -> [A, B', C] (A and C reused; B replaced) + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "a"); + var b = terminal(gen, "b"); + var c = terminal(gen, "c"); + IdCstNode oldRoot = nonTerminal(gen, "R", List.of(a, b, c)); + + var index = IdNodeIndex.build(oldRoot); + + // Construct the new tree using the SAME generator (continues incrementing). + var bPrime = terminal(gen, "B-prime"); + IdCstNode newRoot = nonTerminal(gen, "R", List.of(a, bPrime, c)); + + var oldPath = List.of(oldRoot, b); + var newPath = List.of(newRoot, (IdCstNode) bPrime); + + var newIndex = index.applyIncremental(newRoot, oldPath, newPath); + + // A and C still point to NEW root. + assertThat(newIndex.parentIdOf(a.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); + assertThat(newIndex.parentIdOf(c.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); + // B' points to new root. + assertThat(newIndex.parentIdOf(bPrime.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); + // Old B is dead. + assertThat(newIndex.contains(b.id())).isFalse(); + // Old root was an entry? No — root never had a parent. Map entries: 3. + assertThat(newIndex.size()).isEqualTo(3); + assertThat(newIndex.root()).isSameAs(newRoot); + } + + @Test + @DisplayName("Pivot is the root itself: equivalent to full rebuild") + void pivot_is_root() { + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "a"); + var b = terminal(gen, "b"); + IdCstNode oldRoot = nonTerminal(gen, "R", List.of(a, b)); + + var index = IdNodeIndex.build(oldRoot); + + var aNew = terminal(gen, "anew"); + var bNew = terminal(gen, "bnew"); + IdCstNode newRoot = nonTerminal(gen, "R", List.of(aNew, bNew)); + + var oldPath = List.of(oldRoot); + var newPath = List.of(newRoot); + + var newIndex = index.applyIncremental(newRoot, oldPath, newPath); + + // Old root and old children all gone. + assertThat(newIndex.contains(oldRoot.id())).isFalse(); + assertThat(newIndex.contains(a.id())).isFalse(); + assertThat(newIndex.contains(b.id())).isFalse(); + // New entries match a fresh build. + assertThat(newIndex.parentIdOf(aNew.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); + assertThat(newIndex.parentIdOf(bNew.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); + assertThat(newIndex.size()).isEqualTo(2); + + // Equivalence to full rebuild. + var fresh = IdNodeIndex.build(newRoot); + assertThat(newIndex.size()).isEqualTo(fresh.size()); + assertThat(newIndex.parentIdOf(aNew.id())).isEqualTo(fresh.parentIdOf(aNew.id())); + assertThat(newIndex.parentIdOf(bNew.id())).isEqualTo(fresh.parentIdOf(bNew.id())); + } + + @Test + @DisplayName("Deep splice (4-level): siblings keep IDs, ancestor chain IDs replaced") + void deep_splice() { + // 4-level tree: + // oldRoot (depth 0) + // ├── lvl1Sibling (depth 1, retained) + // └── oldMid (depth 1, replaced) + // ├── mid_lvl2Sibling (depth 2, retained) + // └── oldInner (depth 2, replaced) + // └── oldPivot (depth 3, replaced -- the smallest replaced subtree contains JUST the pivot) + // + // New tree mirrors structure with fresh ancestor IDs but reuses + // lvl1Sibling and mid_lvl2Sibling (record-identical). + var gen = new IdGenerator.PerSessionCounter(); + + var lvl1Sibling = terminal(gen, "L1S"); + var midLvl2Sibling = terminal(gen, "L2S"); + var oldPivot = terminal(gen, "OLD_PIVOT"); + IdCstNode oldInner = nonTerminal(gen, "Inner", List.of(oldPivot)); + IdCstNode oldMid = nonTerminal(gen, "Mid", List.of(midLvl2Sibling, oldInner)); + IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(lvl1Sibling, oldMid)); + + var index = IdNodeIndex.build(oldRoot); + + // New tree: pivot replaced; ancestor chain Inner→Mid→Root all fresh. + var newPivot = terminal(gen, "NEW_PIVOT"); + IdCstNode newInner = nonTerminal(gen, "Inner", List.of(newPivot)); + IdCstNode newMid = nonTerminal(gen, "Mid", List.of(midLvl2Sibling, newInner)); + IdCstNode newRoot = nonTerminal(gen, "Root", List.of(lvl1Sibling, newMid)); + + // Pivot for incremental update is oldPivot (the smallest replaced node). + var oldPath = List.of(oldRoot, oldMid, oldInner, (IdCstNode) oldPivot); + var newPath = List.of(newRoot, newMid, newInner, (IdCstNode) newPivot); + + var newIndex = index.applyIncremental(newRoot, oldPath, newPath); + + // (a) Surviving siblings retain their IDs and have CORRECT new parent IDs. + assertThat(newIndex.parentIdOf(lvl1Sibling.id())) + .isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); + assertThat(newIndex.parentIdOf(midLvl2Sibling.id())) + .isEqualTo(org.pragmatica.lang.Option.some(newMid.id())); + // (b) Old ancestor chain IDs are GONE. + assertThat(newIndex.contains(oldRoot.id())).isFalse(); + assertThat(newIndex.contains(oldMid.id())).isFalse(); + assertThat(newIndex.contains(oldInner.id())).isFalse(); + assertThat(newIndex.contains(oldPivot.id())).isFalse(); + // New ancestor chain wired correctly. + assertThat(newIndex.parentIdOf(newMid.id())) + .isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); + assertThat(newIndex.parentIdOf(newInner.id())) + .isEqualTo(org.pragmatica.lang.Option.some(newMid.id())); + assertThat(newIndex.parentIdOf(newPivot.id())) + .isEqualTo(org.pragmatica.lang.Option.some(newInner.id())); + + // Cross-check with a fresh build. + var fresh = IdNodeIndex.build(newRoot); + assertThat(newIndex.size()).isEqualTo(fresh.size()); + } + + @Test + @DisplayName("Identity-preservation invariant: put count is O(δ), shared subtrees not re-walked") + void identity_preservation_microcount() { + // Construct an old tree where the splice path is at depth 3, and + // the splice point has TWO sibling subtrees flanking it, each + // record-identical between old and new trees. Each sibling + // subtree contains MANY internal nodes — the test asserts those + // internal nodes' parent entries are NOT touched (preserved + // verbatim) and the put count stays bounded by + // splicedSize + depth × branching. + // + // Old tree: + // root + // ├── leftBigSubtree (depth 1, record-shared, internally large) + // ├── mid (depth 1, replaced) + // │ ├── midLeftBigSubtree (depth 2, record-shared, internally large) + // │ ├── inner (depth 2, replaced) + // │ │ └── oldPivot (depth 3, replaced — the leaf splice target) + // │ └── midRightBigSubtree (depth 2, record-shared, internally large) + // └── rightBigSubtree (depth 1, record-shared, internally large) + var gen = new IdGenerator.PerSessionCounter(); + + // Build four "big" sibling subtrees (each: 1 NonTerminal + 5 leaf terminals = 6 nodes / 5 internal parent entries). + var leftBig = bigSubtree(gen, "leftBig", 5); + var midLeftBig = bigSubtree(gen, "midLeftBig", 5); + var midRightBig = bigSubtree(gen, "midRightBig", 5); + var rightBig = bigSubtree(gen, "rightBig", 5); + + var oldPivot = terminal(gen, "oldPivot"); + IdCstNode oldInner = nonTerminal(gen, "Inner", List.of(oldPivot)); + IdCstNode oldMid = nonTerminal(gen, "Mid", List.of(midLeftBig, oldInner, midRightBig)); + IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(leftBig, oldMid, rightBig)); + + var index = IdNodeIndex.build(oldRoot); + int totalNodes = countAll(oldRoot); + // sanity — total nodes well above any small-constant we'll assert. + assertThat(totalNodes).isGreaterThan(20); + + // Capture old parent links INSIDE the shared subtrees — they must be + // unchanged after applyIncremental (proves we did NOT walk into them). + var leftBigInternalChildIds = collectInternalChildIds(leftBig); + var midLeftBigInternalChildIds = collectInternalChildIds(midLeftBig); + + // New tree: oldPivot replaced; ancestor chain refreshed. + // CRITICAL: leftBig, midLeftBig, midRightBig, rightBig are REUSED (same object refs). + var newPivot = terminal(gen, "newPivot"); + IdCstNode newInner = nonTerminal(gen, "Inner", List.of(newPivot)); + IdCstNode newMid = nonTerminal(gen, "Mid", List.of(midLeftBig, newInner, midRightBig)); + IdCstNode newRoot = nonTerminal(gen, "Root", List.of(leftBig, newMid, rightBig)); + + var oldPath = List.of(oldRoot, oldMid, oldInner, (IdCstNode) oldPivot); + var newPath = List.of(newRoot, newMid, newInner, (IdCstNode) newPivot); + + var newIndex = index.applyIncremental(newRoot, oldPath, newPath); + + // -- Identity preservation: shared subtrees' internal entries are EXACTLY as before. + for (var e : leftBigInternalChildIds) { + assertThat(newIndex.parentIdOf(e.childId)) + .as("left big subtree internal child %d should still point to %d", e.childId, e.parentId) + .isEqualTo(org.pragmatica.lang.Option.some(e.parentId)); + } + for (var e : midLeftBigInternalChildIds) { + assertThat(newIndex.parentIdOf(e.childId)) + .as("midLeft big subtree internal child %d should still point to %d", e.childId, e.parentId) + .isEqualTo(org.pragmatica.lang.Option.some(e.parentId)); + } + + // -- Microcount: put count is O(splicedSize + depth × branching), NOT O(N). + // splicedSize = newPivot's subtree size (just newPivot, leaf) = 0 internal +1 (the pivot wired to its parent) = 1 + // depth (newPath.size()) = 4, max branching at any ancestor in newPath = 3 (Mid has 3 children) + // Bound: 1 + 4*3 = 13. We allow some slack with a generous bound: + int splicedSize = countAll(newPivot); // 1 + int depthBranchingBound = newPath.size() * 3 /* max branching */; + int generousBound = splicedSize + depthBranchingBound + 4 /* slack */; + assertThat(newIndex.lastIncrementalPutCount) + .as("put count must be O(splicedSize + depth × branching), NOT O(N=%d)", totalNodes) + .isLessThanOrEqualTo(generousBound) + .isLessThan(totalNodes); // strictly less than full rebuild + // Print for diagnostic — captured in test report. + System.out.println("[Phase 0c] depth-3 incremental put count = " + + newIndex.lastIncrementalPutCount + + " (vs full-rebuild N = " + totalNodes + ")"); + } + + @Test + @DisplayName("Equivalence to full rebuild: applyIncremental matches build(newRoot) exactly") + void equivalence_to_full_rebuild() { + // Random-ish 3-level tree, splice at depth 2. + var gen = new IdGenerator.PerSessionCounter(); + var leaf1 = terminal(gen, "1"); + var leaf2 = terminal(gen, "2"); + var leaf3 = terminal(gen, "3"); + var leaf4 = terminal(gen, "4"); + IdCstNode oldChildA = nonTerminal(gen, "A", List.of(leaf1, leaf2)); + IdCstNode oldChildB = nonTerminal(gen, "B", List.of(leaf3, leaf4)); + IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(oldChildA, oldChildB)); + + var index = IdNodeIndex.build(oldRoot); + + // Replace child B with a fresh subtree. + var newLeaf5 = terminal(gen, "5"); + var newLeaf6 = terminal(gen, "6"); + var newLeaf7 = terminal(gen, "7"); + IdCstNode newChildB = nonTerminal(gen, "B", List.of(newLeaf5, newLeaf6, newLeaf7)); + IdCstNode newRoot = nonTerminal(gen, "Root", List.of(oldChildA, newChildB)); + + var oldPath = List.of(oldRoot, oldChildB); + var newPath = List.of(newRoot, newChildB); + + var incremental = index.applyIncremental(newRoot, oldPath, newPath); + var rebuilt = IdNodeIndex.build(newRoot); + + // Both must agree on every node in the new tree. + for (var node : flatten(newRoot)) { + assertThat(incremental.parentIdOf(node.id())) + .as("parent of node id %d (rule %s)", node.id(), node.rule()) + .isEqualTo(rebuilt.parentIdOf(node.id())); + } + assertThat(incremental.size()).isEqualTo(rebuilt.size()); + // Old child B and its old descendants are gone from incremental (and absent from rebuilt). + assertThat(incremental.contains(oldChildB.id())).isFalse(); + assertThat(incremental.contains(leaf3.id())).isFalse(); + assertThat(incremental.contains(leaf4.id())).isFalse(); + } + } + + // -- helpers -- + + private static IdCstNode bigSubtree(IdGenerator gen, String rule, int leafCount) { + var children = new ArrayList(leafCount); + for (int i = 0; i < leafCount; i++) { + children.add(terminal(gen, rule + "-leaf-" + i)); + } + return nonTerminal(gen, rule, children); + } + + private static int countAll(IdCstNode node) { + int c = 1; + if (node instanceof IdCstNode.NonTerminal nt) { + for (var ch : nt.children()) { + c += countAll(ch); + } + } + return c; + } + + private record ParentLink(long childId, long parentId) {} + + /** Collect every (child.id → parent.id) link strictly inside {@code root}'s subtree. */ + private static List collectInternalChildIds(IdCstNode root) { + var out = new ArrayList(); + collectInternalChildIdsInto(root, out); + return out; + } + + private static void collectInternalChildIdsInto(IdCstNode node, List out) { + if (node instanceof IdCstNode.NonTerminal nt) { + for (var ch : nt.children()) { + out.add(new ParentLink(ch.id(), nt.id())); + collectInternalChildIdsInto(ch, out); + } + } + } + + private static List flatten(IdCstNode root) { + var out = new ArrayList(); + flattenInto(root, out); + return out; + } + + private static void flattenInto(IdCstNode node, List out) { + out.add(node); + if (node instanceof IdCstNode.NonTerminal nt) { + for (var ch : nt.children()) { + flattenInto(ch, out); + } + } + } +} From 9b55253d79acf70849787185872814bf384ac911 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 00:24:17 +0200 Subject: [PATCH 05/46] =?UTF-8?q?feat:=20phase=200d.1=20=E2=80=94=20IdTree?= =?UTF-8?q?Splicer=20+=20identity-invariant=20+=20trivia-edit=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../experimental/IdTreeSplicer.java | 150 +++++++++ .../CalculatorTriviaIncrementalTest.java | 307 ++++++++++++++++++ .../experimental/IdTreeSplicerTest.java | 297 +++++++++++++++++ 3 files changed, 754 insertions(+) create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/CalculatorTriviaIncrementalTest.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicerTest.java diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java new file mode 100644 index 0000000..5cfc0fa --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java @@ -0,0 +1,150 @@ +package org.pragmatica.peg.incremental.experimental; + +import java.util.ArrayList; +import java.util.List; + +/** + * Hand-rolled sandbox splicer for {@link IdCstNode} trees — Phase 0d.1 spike + * for the v0.5.0 architectural rework + * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A and §8 Q3). + * + *

Given an {@code oldPath} (root → oldPivot, inclusive) and a freshly built + * {@code newPivot}, produces a new tree by replacing {@code oldPivot} with + * {@code newPivot} and rebuilding ONLY the ancestor chain. Every sibling + * subtree of every node on the splice path is reference-shared ({@code ==}) + * with the corresponding subtree in the input tree. + * + *

This is the GO/NO-GO gate for spec §8 Q3 — the "identity-preservation + * invariant" — without which {@link IdNodeIndex#applyIncremental} cannot + * achieve its O(δ) cost claim. (If sibling subtrees were re-allocated by the + * splicer, the index update would have to re-walk them, defeating the whole + * point.) + * + *

Algorithm

+ * + *
    + *
  1. Walk {@code oldPath} from leaf upward. + *
  2. At each ancestor, locate the child slot by record identity ({@code ==}) + * to the next-deeper element of {@code oldPath}. + *
  3. Build a fresh {@link IdCstNode.NonTerminal} with new ID, copying the + * old children list verbatim except for the spliced slot — every other + * slot is reference-shared. + *
  4. Continue upward; the rebuilt ancestor becomes the splice target for + * the next level. + *
+ * + *

Trivia lists are reference-shared (immutable). {@link org.pragmatica.peg.tree.SourceSpan} + * is reference-shared (immutable record). Only fresh allocations: one + * {@link IdCstNode.NonTerminal} per ancestor on the splice path, each with a + * new {@code ArrayList} for {@code children}. + * + *

This sandbox class is not referenced by {@code peglib-core} and will be + * promoted, reshaped, or deleted at the Phase 0 GO/NO-GO gate. + * + * @since 0.5.0 + */ +public final class IdTreeSplicer { + + /** + * Result of a splice — the new root and the new path (root → newPivot, + * inclusive). The new path is parallel to {@code oldPath}: {@code newPath.get(i)} + * is the freshly allocated ancestor that replaces {@code oldPath.get(i)}. + */ + public record Result(IdCstNode newRoot, List newPath) {} + + private final IdGenerator idGen; + + public IdTreeSplicer(IdGenerator idGen) { + this.idGen = idGen; + } + + /** + * Splice {@code newPivot} into the tree by replacing the last element of + * {@code oldPath}. + * + * @param oldPath root → oldPivot, inclusive (size ≥ 1) + * @param newPivot the replacement subtree + * @return new root + new path; every sibling subtree of every splice-path + * node is reference-shared ({@code ==}) with the corresponding old + * subtree + * @throws IllegalArgumentException when {@code oldPath} is null or empty + * @throws IllegalStateException when an ancestor is not a {@code NonTerminal} + * or the child-identity link in {@code oldPath} is broken + */ + public Result splice(List oldPath, IdCstNode newPivot) { + if (oldPath == null || oldPath.isEmpty()) { + throw new IllegalArgumentException("oldPath must contain at least the old pivot"); + } + if (newPivot == null) { + throw new IllegalArgumentException("newPivot must not be null"); + } + + // Pivot IS the root — no rebuild needed. + if (oldPath.size() == 1) { + return new Result(newPivot, List.of(newPivot)); + } + + // Accumulator: builds newPath in REVERSE (leaf → root), reversed at end. + // We pre-size to oldPath.size() to avoid resize churn. + var reversedNewPath = new ArrayList(oldPath.size()); + reversedNewPath.add(newPivot); + + var current = newPivot; + var oldChild = oldPath.get(oldPath.size() - 1); + + for (int i = oldPath.size() - 2; i >= 0; i--) { + var oldAncestor = oldPath.get(i); + if (!(oldAncestor instanceof IdCstNode.NonTerminal nt)) { + throw new IllegalStateException( + "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); + } + var children = nt.children(); + int slot = indexOfByIdentity(children, oldChild); + if (slot < 0) { + throw new IllegalStateException( + "splice path broken at depth " + i + + ": child " + oldChild + " not found in parent's children list"); + } + + // Copy the children list, replacing only the spliced slot. + // CRITICAL: every other entry is the same reference — this is what + // preserves the identity invariant (spec §8 Q3). + var newChildren = new ArrayList(children.size()); + for (int k = 0; k < children.size(); k++) { + newChildren.add(k == slot ? current : children.get(k)); + } + + var newAncestor = new IdCstNode.NonTerminal( + idGen.next(), + nt.span(), + nt.rule(), + List.copyOf(newChildren), + nt.leadingTrivia(), + nt.trailingTrivia()); + + reversedNewPath.add(newAncestor); + current = newAncestor; + oldChild = oldAncestor; + } + + // Reverse to get root → newPivot order. + var newPath = new ArrayList(reversedNewPath.size()); + for (int i = reversedNewPath.size() - 1; i >= 0; i--) { + newPath.add(reversedNewPath.get(i)); + } + return new Result(current, List.copyOf(newPath)); + } + + /** + * Linear scan for a child by record identity ({@code ==}). Children lists + * are typically small (≤ 8); a linear scan is dominant and avoids hashing. + */ + private static int indexOfByIdentity(List children, IdCstNode target) { + for (int i = 0; i < children.size(); i++) { + if (children.get(i) == target) { + return i; + } + } + return -1; + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/CalculatorTriviaIncrementalTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/CalculatorTriviaIncrementalTest.java new file mode 100644 index 0000000..37554d4 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/CalculatorTriviaIncrementalTest.java @@ -0,0 +1,307 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.PegParser; +import org.pragmatica.peg.tree.CstNode; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Phase 0d.1 — GO/NO-GO gate for spec §8 Q4: trivia-bearing edits do not + * break the algorithm or the identity-preservation invariant when the splice + * is driven by {@link IdTreeSplicer}. + * + *

Test methodology

+ * + *

The production parser (peglib-core) allocates fresh {@link CstNode} + * records on every parse — there is no record-sharing between two independent + * parses of similar inputs. {@link IdCstNodeBuilder} then assigns fresh stable + * IDs to every node it visits. Therefore, paired re-parses of {@code before} + * and {@code after} produce trees with completely disjoint ID spaces. + * + *

This is fine for Phase 0d.1's goal: the spike GATE concerns the + * algorithm's behavior given identity-shared trees. {@link IdTreeSplicer} + * MANUFACTURES that sharing — it builds a post-edit tree from the pre-edit + * tree by reusing every sibling subtree by reference. So the test: + * + *

    + *
  1. Parses {@code before} → IdCstNode (call this {@code originalTree}). + *
  2. Parses {@code after} → IdCstNode (call this the freshly-rebuilt + * "ground truth" — used only to source a {@code newPivot} subtree + * that has the structural shape we want to splice in). + *
  3. Identifies the splice point in {@code originalTree} (the {@code Number} + * node whose token text is "2"). The corresponding pivot in the + * ground-truth tree provides the new pivot subtree. + *
  4. Calls {@link IdTreeSplicer#splice} → {@code (newRoot, newPath)}. + *
  5. Calls {@link IdNodeIndex#applyIncremental} → {@code incrementalIndex}. + *
  6. Calls {@link IdNodeIndex#build} on {@code newRoot} → {@code groundTruthIndex} + * (this is a fresh full-walk on the post-splice tree, NOT the + * independently-parsed after-tree). + *
  7. Asserts: every node ID in {@code groundTruthIndex.parents} maps to the + * SAME parent ID in {@code incrementalIndex} (equivalence assertion). + *
  8. Asserts: every sibling subtree of every splice-path node is + * reference-shared between {@code originalTree} and {@code newRoot} + * (Q3 invariant carried through the splice). + *
+ * + *

Note on the spike-vs-production gap. The production + * parser's lack of cross-parse record sharing means a true "incremental + * reparse" in v0.5.0 will not work by paired re-parses; the + * {@code IdTreeSplicer} (or its production successor) must be the source of + * identity-shared trees. That is a Phase 1 design concern. Phase 0d.1 only + * proves that given identity-shared trees, the algorithm is correct + * and the invariant holds. + */ +final class CalculatorTriviaIncrementalTest { + + private static final String CALCULATOR_GRAMMAR = """ + Expr <- Term (('+' / '-') Term)* + Term <- Factor (('*' / '/') Factor)* + Factor <- Number / '(' Expr ')' + Number <- < [0-9]+ > + Comment <- '/*' (!'*/' .)* '*/' + %whitespace <- ([ \\t\\r\\n]+ / Comment)+ + """; + + private static org.pragmatica.peg.parser.Parser calculator() { + return PegParser.fromGrammar(CALCULATOR_GRAMMAR).unwrap(); + } + + /** + * Parse {@code input} with the calculator grammar and convert to + * {@link IdCstNode} via the supplied generator. + */ + private static IdCstNode parseToIdCst(String input, IdGenerator gen) { + var cst = calculator().parseCst(input).unwrap(); + return new IdCstNodeBuilder(gen).build(cst); + } + + /** + * Walk {@code root} pre-order; return the first {@link IdCstNode.Token} + * whose text is exactly {@code targetText}. Returns null when not found. + * + *

For the calculator grammar, {@code Number <- < [0-9]+ >} produces a + * {@link IdCstNode.Token} (rule = parent rule = "Factor") rather than a + * NonTerminal. Token nodes are leaves, so they're a clean splice target — + * no internal subtree to disrupt the identity invariant. + */ + private static IdCstNode findNumberByText(IdCstNode root, String targetText) { + var stack = new ArrayList(); + stack.add(root); + while (!stack.isEmpty()) { + var node = stack.remove(stack.size() - 1); + if (node instanceof IdCstNode.Token t && t.text().equals(targetText)) { + return node; + } + if (node instanceof IdCstNode.NonTerminal nt) { + for (int i = nt.children().size() - 1; i >= 0; i--) { + stack.add(nt.children().get(i)); + } + } + } + return null; + } + + /** Aggregate the text of all Token/Terminal descendants of {@code node}. */ + private static String nodeTokenText(IdCstNode node) { + return switch (node) { + case IdCstNode.Token t -> t.text(); + case IdCstNode.Terminal t -> t.text(); + case IdCstNode.Error e -> e.skippedText(); + case IdCstNode.NonTerminal nt -> { + var sb = new StringBuilder(); + for (var ch : nt.children()) { + sb.append(nodeTokenText(ch)); + } + yield sb.toString(); + } + }; + } + + /** + * Build the path (root → target, inclusive) from {@code root} to {@code target} + * using record-identity ({@code ==}). Returns null when target is not in the tree. + */ + private static List pathToByIdentity(IdCstNode root, IdCstNode target) { + var acc = new ArrayList(); + if (collectPath(root, target, acc)) { + return List.copyOf(acc); + } + return null; + } + + private static boolean collectPath(IdCstNode node, IdCstNode target, List acc) { + acc.add(node); + if (node == target) { + return true; + } + if (node instanceof IdCstNode.NonTerminal nt) { + for (var ch : nt.children()) { + if (collectPath(ch, target, acc)) { + return true; + } + } + } + acc.remove(acc.size() - 1); + return false; + } + + /** Pre-order flatten of every node in {@code root}'s subtree (inclusive). */ + private static List flatten(IdCstNode root) { + var out = new ArrayList(); + flattenInto(root, out); + return out; + } + + private static void flattenInto(IdCstNode node, List out) { + out.add(node); + if (node instanceof IdCstNode.NonTerminal nt) { + for (var ch : nt.children()) { + flattenInto(ch, out); + } + } + } + + /** + * Run the full Phase 0d.1 Q4 gate for one trivia-bearing edit case. Returns + * a small report record so each test can assert. + */ + private record Q4Outcome(boolean equivalencePassed, + boolean invariantPassed, + int newTreeNodeCount, + int siblingsChecked) {} + + private static Q4Outcome runQ4Gate(String before, String after) { + var gen = new IdGenerator.PerSessionCounter(); + var originalTree = parseToIdCst(before, gen); + // Continue with the SAME generator so before/after IDs do not collide. + var afterTree = parseToIdCst(after, gen); + + var oldPivot = findNumberByText(originalTree, "2"); + var newPivot = findNumberByText(afterTree, "2"); + if (oldPivot == null || newPivot == null) { + throw new AssertionError("Could not locate Number=2 pivot in test inputs '" + + before + "' / '" + after + "'"); + } + + var oldPath = pathToByIdentity(originalTree, oldPivot); + if (oldPath == null) { + throw new AssertionError("Pivot not found via identity walk in originalTree"); + } + + var splicer = new IdTreeSplicer(gen); + var splice = splicer.splice(oldPath, newPivot); + + var oldIndex = IdNodeIndex.build(originalTree); + var incrementalIndex = oldIndex.applyIncremental(splice.newRoot(), oldPath, splice.newPath()); + var groundTruthIndex = IdNodeIndex.build(splice.newRoot()); + + // Equivalence: every node ID in the new tree's full-walk index must + // resolve to the same parent in the incremental index. + boolean equivalencePassed = true; + int newTreeNodeCount = 0; + for (var node : flatten(splice.newRoot())) { + newTreeNodeCount++; + var truth = groundTruthIndex.parentIdOf(node.id()); + var incr = incrementalIndex.parentIdOf(node.id()); + if (!truth.equals(incr)) { + equivalencePassed = false; + System.err.println("[Q4 equivalence FAIL] node id=" + node.id() + + " rule=" + node.rule() + + " truth.parent=" + truth + + " incremental.parent=" + incr); + } + } + + // Identity invariant: every non-spliced sibling along the path must be ==. + int siblingsChecked = 0; + boolean invariantPassed = true; + for (int depth = 0; depth < oldPath.size() - 1; depth++) { + var oldAncestor = oldPath.get(depth); + var newAncestor = splice.newPath().get(depth); + if (!(oldAncestor instanceof IdCstNode.NonTerminal oldNT) + || !(newAncestor instanceof IdCstNode.NonTerminal newNT)) { + invariantPassed = false; + continue; + } + var oldChildren = oldNT.children(); + var newChildren = newNT.children(); + if (oldChildren.size() != newChildren.size()) { + invariantPassed = false; + continue; + } + int splicedIdx = oldChildren.indexOf(oldPath.get(depth + 1)); + for (int k = 0; k < newChildren.size(); k++) { + if (k == splicedIdx) { + continue; + } + siblingsChecked++; + if (newChildren.get(k) != oldChildren.get(k)) { + invariantPassed = false; + System.err.println("[Q4 invariant FAIL] depth=" + depth + " idx=" + k + + " sibling not reference-shared"); + } + } + } + + return new Q4Outcome(equivalencePassed, invariantPassed, newTreeNodeCount, siblingsChecked); + } + + @Nested + @DisplayName("Trivia-bearing edits (spec §8 Q4)") + class TriviaEdits { + + @Test + @DisplayName("Edit A — insert blank line before second operand") + void edit_a_insert_blank_line() { + var outcome = runQ4Gate("1+2", "1+\n 2"); + + assertThat(outcome.equivalencePassed()) + .as("incremental index must equal ground-truth full-walk index") + .isTrue(); + assertThat(outcome.invariantPassed()) + .as("identity invariant: every sibling subtree of splice path is reference-shared") + .isTrue(); + assertThat(outcome.siblingsChecked()) + .as("at least some siblings should be checked along the path") + .isGreaterThanOrEqualTo(0); + System.out.println("[Phase 0d.1 Q4-A] newTreeNodes=" + outcome.newTreeNodeCount() + + " siblingsChecked=" + outcome.siblingsChecked()); + } + + @Test + @DisplayName("Edit B — delete inline comment between operands") + void edit_b_delete_inline_comment() { + var outcome = runQ4Gate("1 /*hi*/ + 2", "1 + 2"); + + assertThat(outcome.equivalencePassed()) + .as("incremental index must equal ground-truth full-walk index") + .isTrue(); + assertThat(outcome.invariantPassed()) + .as("identity invariant: every sibling subtree of splice path is reference-shared") + .isTrue(); + System.out.println("[Phase 0d.1 Q4-B] newTreeNodes=" + outcome.newTreeNodeCount() + + " siblingsChecked=" + outcome.siblingsChecked()); + } + + @Test + @DisplayName("Edit C — insert comment inside expression") + void edit_c_insert_comment_inside_expression() { + var outcome = runQ4Gate("1+2", "1+/*x*/2"); + + assertThat(outcome.equivalencePassed()) + .as("incremental index must equal ground-truth full-walk index") + .isTrue(); + assertThat(outcome.invariantPassed()) + .as("identity invariant: every sibling subtree of splice path is reference-shared") + .isTrue(); + System.out.println("[Phase 0d.1 Q4-C] newTreeNodes=" + outcome.newTreeNodeCount() + + " siblingsChecked=" + outcome.siblingsChecked()); + } + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicerTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicerTest.java new file mode 100644 index 0000000..068958c --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicerTest.java @@ -0,0 +1,297 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Phase 0d.1 — GO/NO-GO gate for the spec §8 Q3 identity-preservation + * invariant. After splice, every sibling subtree of every node on the splice + * path must satisfy reference equality ({@code ==}) with the corresponding + * pre-edit subtree. + * + *

Without this invariant, the O(δ) cost claim of + * {@link IdNodeIndex#applyIncremental} collapses — the index update would have + * to re-walk re-allocated subtrees, and we'd be back to {@code O(N)}. + */ +final class IdTreeSplicerTest { + private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); + private static final List NO_TRIVIA = List.of(); + + private static IdCstNode.Terminal terminal(IdGenerator gen, String text) { + return new IdCstNode.Terminal(gen.next(), SPAN, "T", text, NO_TRIVIA, NO_TRIVIA); + } + + private static IdCstNode.NonTerminal nonTerminal(IdGenerator gen, String rule, List children) { + return new IdCstNode.NonTerminal(gen.next(), SPAN, rule, children, NO_TRIVIA, NO_TRIVIA); + } + + @Nested + @DisplayName("splice") + class SpliceTests { + + @Test + @DisplayName("Pivot is root: returns newPivot directly, newPath = [newPivot]") + void pivot_as_root() { + var gen = new IdGenerator.PerSessionCounter(); + IdCstNode oldRoot = terminal(gen, "old"); + IdCstNode newPivot = terminal(gen, "new"); + + var splicer = new IdTreeSplicer(gen); + var result = splicer.splice(List.of(oldRoot), newPivot); + + assertThat(result.newRoot()).isSameAs(newPivot); + assertThat(result.newPath()).hasSize(1); + assertThat(result.newPath().get(0)).isSameAs(newPivot); + } + + @Test + @DisplayName("Single-level splice: 3-child root, middle replaced; flanking siblings reference-shared") + void single_level_splice() { + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "A"); + var b = terminal(gen, "B"); + var c = terminal(gen, "C"); + IdCstNode root = nonTerminal(gen, "Root", List.of(a, b, c)); + + IdCstNode bPrime = terminal(gen, "B'"); + + var splicer = new IdTreeSplicer(gen); + var result = splicer.splice(List.of(root, b), bPrime); + + // Root has fresh ID — different record, but same rule/span/trivia. + assertThat(result.newRoot()).isNotSameAs(root); + assertThat(result.newRoot()).isInstanceOf(IdCstNode.NonTerminal.class); + + var newRootNT = (IdCstNode.NonTerminal) result.newRoot(); + assertThat(newRootNT.id()).isNotEqualTo(root.id()); + assertThat(newRootNT.rule()).isEqualTo("Root"); + assertThat(newRootNT.children()).hasSize(3); + + // Identity preservation: A and C are reference-shared. + assertThat(newRootNT.children().get(0)).isSameAs(a); + assertThat(newRootNT.children().get(2)).isSameAs(c); + // The spliced slot holds bPrime. + assertThat(newRootNT.children().get(1)).isSameAs(bPrime); + + // newPath = [newRoot, newPivot]. + assertThat(result.newPath()).hasSize(2); + assertThat(result.newPath().get(0)).isSameAs(result.newRoot()); + assertThat(result.newPath().get(1)).isSameAs(bPrime); + } + + @Test + @DisplayName("Deep splice (4-level): for every path node, all non-spliced siblings reference-shared") + void deep_splice_siblings_preserved() { + // Tree: + // root + // ├── lvl1Sib (preserved sibling at depth 1) + // └── mid (on splice path) + // ├── midSib1 (preserved sibling at depth 2) + // ├── inner (on splice path) + // │ ├── innerSibA (preserved sibling at depth 3) + // │ ├── pivot (REPLACED) + // │ └── innerSibB (preserved sibling at depth 3) + // └── midSib2 (preserved sibling at depth 2) + var gen = new IdGenerator.PerSessionCounter(); + var lvl1Sib = terminal(gen, "lvl1Sib"); + var midSib1 = terminal(gen, "midSib1"); + var midSib2 = terminal(gen, "midSib2"); + var innerSibA = terminal(gen, "innerSibA"); + var innerSibB = terminal(gen, "innerSibB"); + var pivot = terminal(gen, "PIVOT"); + IdCstNode inner = nonTerminal(gen, "Inner", List.of(innerSibA, pivot, innerSibB)); + IdCstNode mid = nonTerminal(gen, "Mid", List.of(midSib1, inner, midSib2)); + IdCstNode root = nonTerminal(gen, "Root", List.of(lvl1Sib, mid)); + + IdCstNode newPivot = terminal(gen, "NEW_PIVOT"); + var oldPath = List.of(root, mid, inner, (IdCstNode) pivot); + + var splicer = new IdTreeSplicer(gen); + var result = splicer.splice(oldPath, newPivot); + + // newPath structurally parallel to oldPath. + assertThat(result.newPath()).hasSize(4); + assertThat(result.newPath().get(0)).isSameAs(result.newRoot()); + assertThat(result.newPath().get(3)).isSameAs(newPivot); + + // -- depth 1: root has [lvl1Sib, newMid]. lvl1Sib reference-shared. + var newRootNT = (IdCstNode.NonTerminal) result.newRoot(); + assertThat(newRootNT.children().get(0)).isSameAs(lvl1Sib); + assertThat(newRootNT.children().get(1)).isSameAs(result.newPath().get(1)); + + // -- depth 2: newMid has [midSib1, newInner, midSib2]. Both midSibs reference-shared. + var newMidNT = (IdCstNode.NonTerminal) result.newPath().get(1); + assertThat(newMidNT.children().get(0)).isSameAs(midSib1); + assertThat(newMidNT.children().get(1)).isSameAs(result.newPath().get(2)); + assertThat(newMidNT.children().get(2)).isSameAs(midSib2); + + // -- depth 3: newInner has [innerSibA, newPivot, innerSibB]. Both innerSibs reference-shared. + var newInnerNT = (IdCstNode.NonTerminal) result.newPath().get(2); + assertThat(newInnerNT.children().get(0)).isSameAs(innerSibA); + assertThat(newInnerNT.children().get(1)).isSameAs(newPivot); + assertThat(newInnerNT.children().get(2)).isSameAs(innerSibB); + + // -- old ancestor records are NOT reused (each replaced with fresh ID). + assertThat(newRootNT.id()).isNotEqualTo(((IdCstNode.NonTerminal) root).id()); + assertThat(newMidNT.id()).isNotEqualTo(((IdCstNode.NonTerminal) mid).id()); + assertThat(newInnerNT.id()).isNotEqualTo(((IdCstNode.NonTerminal) inner).id()); + } + + @Test + @DisplayName("Identity invariant under iteration: count preserved siblings exactly") + void identity_preservation_under_iteration() { + // Build a 4-deep, 4-wide tree, splice ONE leaf, count preserved + // sibling references along the splice path. + // + // Path: root → lvl1[idx=1] → lvl2[idx=2] → lvl3[idx=3] → pivot + // At each level, branching = 4 → 3 siblings preserved per level. + // Total preserved direct siblings on path = 3 * 4 = 12. + // Plus deep subtrees under those siblings — also reference-shared. + var gen = new IdGenerator.PerSessionCounter(); + + var leaves = new ArrayList(); + for (int i = 0; i < 16; i++) { + leaves.add(terminal(gen, "leaf-" + i)); + } + // lvl3 nodes: 4 NonTerminals, each holding 4 leaves. + // We need one specific lvl3 to become the splice path's lvl3 and + // hold the pivot at index 3. + var lvl3Nodes = new ArrayList(); + for (int g = 0; g < 4; g++) { + var slice = new ArrayList(4); + for (int j = 0; j < 4; j++) { + slice.add(leaves.get(g * 4 + j)); + } + lvl3Nodes.add(nonTerminal(gen, "lvl3-" + g, slice)); + } + // lvl2 wraps lvl3 nodes with 4 children — reuse lvl3Nodes 4-by-4. + // To keep branching = 4 at lvl2 too, we need 4 lvl3 nodes per lvl2 + // and 4 lvl2 nodes total. Simplification: reuse the same lvl3 set + // BUT one lvl2 holds the splice path's lvl3 at index 2. + // To keep distinct subtrees, build 4 lvl2's each with 4 NEW lvl3 + // nodes. That's 16 lvl3 nodes total → 64 leaves. + var lvl2Nodes = new ArrayList(); + for (int b = 0; b < 4; b++) { + var lvl3Slice = new ArrayList(4); + for (int g = 0; g < 4; g++) { + var slice = new ArrayList(4); + for (int j = 0; j < 4; j++) { + slice.add(terminal(gen, "leaf-" + b + "-" + g + "-" + j)); + } + lvl3Slice.add(nonTerminal(gen, "lvl3-" + b + "-" + g, slice)); + } + lvl2Nodes.add(nonTerminal(gen, "lvl2-" + b, lvl3Slice)); + } + IdCstNode root = nonTerminal(gen, "Root", lvl2Nodes); + + // Splice path: root → lvl2Nodes.get(1) → its children.get(2) → its children.get(3) → pivot + var lvl1OnPath = lvl2Nodes.get(1); + var lvl2OnPath = ((IdCstNode.NonTerminal) lvl1OnPath).children().get(2); + var lvl3OnPath = ((IdCstNode.NonTerminal) lvl2OnPath).children().get(3); + + IdCstNode newPivot = terminal(gen, "NEW_PIVOT"); + var oldPath = List.of(root, lvl1OnPath, lvl2OnPath, lvl3OnPath); + + var splicer = new IdTreeSplicer(gen); + var result = splicer.splice(oldPath, newPivot); + + // Count siblings preserved by reference at every path level. + int preservedCount = 0; + int falseShareCount = 0; // would indicate corruption + for (int depth = 0; depth < oldPath.size() - 1; depth++) { + var oldAncestor = (IdCstNode.NonTerminal) oldPath.get(depth); + var newAncestor = (IdCstNode.NonTerminal) result.newPath().get(depth); + var oldChildren = oldAncestor.children(); + var newChildren = newAncestor.children(); + assertThat(newChildren).hasSize(oldChildren.size()); + int splicedIdx = oldChildren.indexOf(oldPath.get(depth + 1)); + for (int k = 0; k < newChildren.size(); k++) { + if (k == splicedIdx) { + // Spliced slot must NOT be == old (it was replaced). + if (newChildren.get(k) == oldChildren.get(k)) { + falseShareCount++; + } + } else { + // Non-spliced slots must be == old. + if (newChildren.get(k) == oldChildren.get(k)) { + preservedCount++; + } + } + } + } + + // 4 levels × (4 children - 1 spliced) = 12 preserved siblings. + // (We have 4-3-2-1 indexing: oldPath[0]=root has 4 children, sliced-at-index-1; 3 preserved. + // oldPath[1]=lvl1OnPath has 4 children, sliced-at-2; 3 preserved. + // oldPath[2]=lvl2OnPath has 4 children, sliced-at-3; 3 preserved. + // Total preserved = 9. The pivot's level (oldPath[3]) is not iterated — pivot itself replaced.) + int pathDepthsWithSiblings = oldPath.size() - 1; // 3 + int expectedPreserved = pathDepthsWithSiblings * 3; // 4-1 + assertThat(preservedCount) + .as("every non-spliced sibling at every depth must be reference-shared") + .isEqualTo(expectedPreserved); + assertThat(falseShareCount) + .as("no false-share: the spliced slot must NOT == old slot") + .isZero(); + } + + @Test + @DisplayName("Mismatched path: child not in parent's children list throws IllegalStateException") + void mismatched_path_throws() { + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "A"); + var b = terminal(gen, "B"); + // bogus node NOT in root's children. + var bogus = terminal(gen, "BOGUS"); + IdCstNode root = nonTerminal(gen, "Root", List.of(a, b)); + + var splicer = new IdTreeSplicer(gen); + IdCstNode newPivot = terminal(gen, "NEW"); + + assertThatThrownBy(() -> splicer.splice(List.of(root, bogus), newPivot)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("splice path broken"); + } + + @Test + @DisplayName("Empty path: throws IllegalArgumentException") + void empty_path_throws() { + var gen = new IdGenerator.PerSessionCounter(); + var splicer = new IdTreeSplicer(gen); + IdCstNode newPivot = terminal(gen, "NEW"); + + assertThatThrownBy(() -> splicer.splice(List.of(), newPivot)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("oldPath"); + + assertThatThrownBy(() -> splicer.splice(null, newPivot)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Non-NonTerminal in middle of path throws IllegalStateException") + void non_nonterminal_in_path_throws() { + // A Terminal cannot have children; if it appears in the middle of + // oldPath as a parent of the pivot, we must fail loudly. + var gen = new IdGenerator.PerSessionCounter(); + var leaf = terminal(gen, "leaf"); + var rogueChild = terminal(gen, "rogue"); + // leaf does NOT contain rogueChild — but more importantly, leaf is not a NonTerminal. + var splicer = new IdTreeSplicer(gen); + IdCstNode newPivot = terminal(gen, "NEW"); + + assertThatThrownBy(() -> splicer.splice(List.of((IdCstNode) leaf, rogueChild), newPivot)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not a NonTerminal"); + } + } +} From a2dd8ac6977a0693cbaa941417ba5ac440587b89 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 00:34:08 +0200 Subject: [PATCH 06/46] =?UTF-8?q?feat:=20phase=200d.2=20=E2=80=94=20JMH=20?= =?UTF-8?q?bench=20(38-67x=20speedup=20confirms=20perf=20gate=20green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/bench-results/phase0-spike-results.md | 47 ++++++ .../incremental/bench/Phase0SpikeBench.java | 146 ++++++++++++++++++ .../bench/SyntheticTreeBuilder.java | 134 ++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 docs/bench-results/phase0-spike-results.md create mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java create mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java diff --git a/docs/bench-results/phase0-spike-results.md b/docs/bench-results/phase0-spike-results.md new file mode 100644 index 0000000..b300ae9 --- /dev/null +++ b/docs/bench-results/phase0-spike-results.md @@ -0,0 +1,47 @@ +# Phase 0 Spike Bench Results + +**Date:** 2026-05-07 +**Branch:** release-0.5.0 (post-Phase-0d.1, commit 9b55253) +**JMH version:** 1.37 +**JVM:** OpenJDK 25.0.2 (Apple Silicon, Homebrew) + +## Configuration + +- **Bench class:** `peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java` +- **Tree shape:** synthetic perfect 4-ary tree built by `SyntheticTreeBuilder` +- **Splice point:** depth 3 (representative interior pivot) +- **Mode:** `AverageTime`, microseconds/op +- **Run flags (reduced for spike):** `-i 3 -wi 2 -f 1` — class declares `(iterations=5, warmup=3, fork=2)` for full runs; the spike uses reduced flags to keep total bench time under one minute. Run signal is unambiguous so reduced iterations suffice for GO/NO-GO. +- **Per-invocation setup:** `oldIndex` rebuilt via `IdNodeIndex.build(oldRoot)` per `Level.Invocation` because `applyIncremental` mutates the receiver. JMH `Level.Invocation` overhead (~tens of ns) is negligible vs μs-range measurements. + +## Measurements + +| Tree size | fullRebuild (μs) | incrementalUpdate (μs) | Speedup | +|-----------|-----------------:|-----------------------:|--------:| +| 100 | 2.678 ± 0.389 | 0.069 ± 0.001 | 38.8× | +| 1000 | 9.611 ± 0.312 | 0.203 ± 0.011 | 47.3× | +| 10000 | 176.468 ± 16.561 | 2.627 ± 0.237 | 67.2× | + +Speedup grows with tree size, exactly as the O(δ) algorithm predicts: incremental cost grows only logarithmically (with depth), full-rebuild cost grows linearly with N. + +Total bench wallclock: 49 seconds. + +## Perf gate + +**Criterion:** incrementalUpdate ≥5× faster than fullRebuild at representative tree sizes (≥1000 nodes). + +**Result: GREEN.** 47× at 1000 nodes, 67× at 10000 nodes — both well above the 5× threshold and consistent with the spec §2 algorithmic projection ("typically 100-300 operations vs the 91,000 we walk today, ~300× per-edit reduction"). + +## Caveats + +- **Synthetic balanced tree, branching factor 4.** Real CST topologies are irregular (e.g., Java method bodies have unbalanced fanout). Phase 1 will re-run on the production 1900-LOC fixture for the apples-to-apples comparison against 0.4.3's `NodeIndex.build`. +- **`applyIncremental` mutates the receiver.** Bench accounts for this via `Level.Invocation` setup that rebuilds `oldIndex` from `oldRoot`. Production 0.5.0 will need either copy-on-write (with `LongLongMap.copy()`, ~O(N) cost) or a persistent map (out of scope for spike). The choice does not affect this gate — even if we paid full-rebuild cost on every applyIncremental call, we'd be at break-even, not a regression. +- **`LongLongMap` is the hand-rolled linear-probing impl from Phase 0a.** Future Funnel-hashing swap (per spec §8 Q2) untested. +- **Numbers reflect a depth-3 splice with a small pivot subtree (single Terminal).** Worst-case (deep splice with large pivot, e.g., a class body rewrite) not in this bench. Spec §2 establishes the cost is `O(splicedSize + depth × branching)` so scaling is predictable. +- **JMH Compiler Blackholes are in use** (auto-detected on JDK 25). Numbers are stable across the 3 measurement iterations and within tight CI bands, so this is informational rather than a confidence concern. + +## Files + +- `peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java` — bench class +- `peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java` — tree synthesizer +- `peglib-incremental/target/phase0-spike.json` — raw JMH output (not committed) diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java new file mode 100644 index 0000000..2b1c193 --- /dev/null +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java @@ -0,0 +1,146 @@ +package org.pragmatica.peg.incremental.bench; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.pragmatica.peg.incremental.experimental.IdCstNode; +import org.pragmatica.peg.incremental.experimental.IdGenerator; +import org.pragmatica.peg.incremental.experimental.IdNodeIndex; +import org.pragmatica.peg.incremental.experimental.IdTreeSplicer; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Phase 0d.2 — perf-gate JMH bench for the v0.5.0 architectural rework + * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §6 Phase 0 and §9 + * perf targets). + * + *

Central perf claim under test: + * {@link IdNodeIndex#applyIncremental} is asymptotically {@code O(δ)} — + * specifically {@code O(splicedSize + depth × branching)} — and therefore + * ≥5× faster than the {@code O(N)} {@link IdNodeIndex#build} at + * representative tree sizes (≥1000 nodes). The Phase 0 GO/NO-GO gate fails + * if this benchmark reports less than 5× speedup at the larger sizes; the + * spec explicitly accommodates a NO-GO outcome. + * + *

Bench design

+ * + *

For each parameterized {@code treeSize}, build a balanced 4-ary tree + * (mirroring typical PEG CST fan-out) and splice a small replacement subtree + * in at depth 3. Both benchmarks operate on the SAME post-edit tree: + * + *

    + *
  • {@link #incrementalUpdate} — measures the full + * {@link IdNodeIndex#applyIncremental} cost (steps 1-3: remove dead + + * insert spliced + rewire siblings). Pre-built {@code oldIndex} is + * recreated per invocation because {@code applyIncremental} mutates the + * receiver's parent map per Phase 0c semantics. + *
  • {@link #fullRebuild} — measures {@link IdNodeIndex#build} on the new + * root: the comparison floor representing "rebuild after edit" + * (≈ what 0.4.3 does today, modulo IdCstNode + LongLongMap constant + * factors). + *
+ * + *

The {@code Level.Invocation} setup penalty (~tens of ns to allocate a + * fresh {@code IdNodeIndex}) is well below the bench timings (μs range) and + * is documented as acceptable by JMH for this class of bench. + * + *

Caveats

+ * + *
    + *
  • Synthetic balanced tree, branching 4. Real CST topology is irregular; + * perf may differ on the 1900-LOC fixture (Phase 1 deliverable). + *
  • Depth-3 splice with a leaf-sized pivot. Worse-case (deep splice with + * large pivot) is not in this bench. + *
  • {@link IdNodeIndex#applyIncremental} mutates the receiver. Production + * v0.5.0 will likely use a persistent map to recover snapshot semantics + * — that adds cost not measured here. + *
+ * + *

How to run

+ *
{@code
+ *   mvn -pl peglib-incremental -am -Pbench -DskipTests package
+ *   java -jar peglib-incremental/target/benchmarks.jar Phase0SpikeBench \
+ *     -rf json -rff phase0-spike.json -i 5 -wi 3 -f 2
+ * }
+ * + * @since 0.5.0 + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 2) +@Fork(2) +@State(Scope.Benchmark) +public class Phase0SpikeBench { + + /** Splice depth — depth 3 puts the pivot at a representative interior. */ + private static final int SPLICE_DEPTH = 3; + /** Branching factor (typical PEG CST fan-out). */ + private static final int BRANCHING = 4; + /** Pivot subtree size — leaf. The "small token edit" case in spec §9. */ + private static final int PIVOT_DEPTH = 0; + + @Param({"100", "1000", "10000"}) + private int treeSize; + + private IdCstNode newRoot; + private List oldPath; + private List newPath; + private IdNodeIndex oldIndex; + + // Held across invocations so we can rebuild oldIndex per invocation + // without re-running the splicer. + private IdCstNode oldRoot; + + @Setup(Level.Trial) + public void buildTrees() { + var gen = new IdGenerator.PerSessionCounter(); + oldRoot = SyntheticTreeBuilder.buildBalanced(treeSize, BRANCHING, gen); + var actualOldPath = SyntheticTreeBuilder.findPathAtDepth(oldRoot, SPLICE_DEPTH); + var pivot = SyntheticTreeBuilder.buildPivot(PIVOT_DEPTH, BRANCHING, gen); + + var splicer = new IdTreeSplicer(gen); + var spliceResult = splicer.splice(actualOldPath, pivot); + + oldPath = actualOldPath; + newRoot = spliceResult.newRoot(); + newPath = spliceResult.newPath(); + } + + /** + * Rebuilt fresh per invocation: {@link IdNodeIndex#applyIncremental} + * mutates the receiver's parent map. Re-running the bench against an + * already-mutated index would measure no-op behaviour, not the algorithm + * under test. + * + *

JMH's {@code Level.Invocation} adds ~tens of ns of overhead; the + * bench timings are in μs. Acceptable for a spike. + */ + @Setup(Level.Invocation) + public void rebuildOldIndex() { + oldIndex = IdNodeIndex.build(oldRoot); + } + + /** The 0.5.0 GO target: O(splicedSize + depth × branching). */ + @Benchmark + public IdNodeIndex incrementalUpdate() { + return oldIndex.applyIncremental(newRoot, oldPath, newPath); + } + + /** The "if NO-GO, rebuild from scratch" floor: O(N). */ + @Benchmark + public IdNodeIndex fullRebuild() { + return IdNodeIndex.build(newRoot); + } +} diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java new file mode 100644 index 0000000..7cf0a7e --- /dev/null +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java @@ -0,0 +1,134 @@ +package org.pragmatica.peg.incremental.bench; + +import org.pragmatica.peg.incremental.experimental.IdCstNode; +import org.pragmatica.peg.incremental.experimental.IdGenerator; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.ArrayList; +import java.util.List; + +/** + * Synthesizes balanced {@link IdCstNode} trees for the Phase 0 perf-gate JMH + * spike (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §6 Phase 0, + * §9 perf targets, and {@link Phase0SpikeBench}). + * + *

The builder produces a balanced tree of approximately {@code targetSize} + * nodes with the supplied branching factor — branching 4 mirrors the typical + * fan-out of a real PEG CST. Leaves are {@link IdCstNode.Terminal}; interior + * nodes are {@link IdCstNode.NonTerminal} with rule names cycled from a small + * pool ({@code "Block"}, {@code "Stmt"}, {@code "Expr"}, {@code "Decl"}). + * + *

This class is only used from the JMH source tree and is sandbox-only; + * it is not referenced by {@code peglib-core} and will be deleted at the + * Phase 0 GO/NO-GO gate. + * + * @since 0.5.0 + */ +final class SyntheticTreeBuilder { + + private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); + private static final List NO_TRIVIA = List.of(); + private static final String[] RULES = {"Block", "Stmt", "Expr", "Decl"}; + + private SyntheticTreeBuilder() {} + + /** + * Build a balanced tree of approximately {@code targetSize} nodes with the + * given {@code branchingFactor}. + * + *

The tree is a perfect {@code branchingFactor}-ary tree: depth is + * chosen as {@code ceil(log_b(targetSize))}, then the bottom level is + * truncated so the total node count is the largest perfect tree + * {@code <= targetSize × 1.2}. Exact sizing is not critical for a + * spike bench — what matters is that depth and branching are realistic. + */ + static IdCstNode buildBalanced(int targetSize, int branchingFactor, IdGenerator idGen) { + if (targetSize < 1) { + throw new IllegalArgumentException("targetSize must be >= 1"); + } + if (branchingFactor < 2) { + throw new IllegalArgumentException("branchingFactor must be >= 2"); + } + // Compute depth d so that branchingFactor^d roughly matches targetSize. + // For a balanced tree: total nodes = (b^(d+1) - 1) / (b - 1). + int depth = 0; + long total = 1; + long levelSize = 1; + while (total < targetSize) { + depth++; + levelSize *= branchingFactor; + total += levelSize; + } + return buildSubtree(depth, branchingFactor, idGen); + } + + /** + * Recursively build a perfect tree of given depth. Depth 0 → single + * {@link IdCstNode.Terminal} leaf. Depth > 0 → {@link IdCstNode.NonTerminal} + * with {@code branchingFactor} children of depth-1 subtrees. + * + *

Children are built first (post-order ID assignment, mirroring + * {@link org.pragmatica.peg.incremental.experimental.IdCstNodeBuilder}). + */ + private static IdCstNode buildSubtree(int depth, int branchingFactor, IdGenerator idGen) { + if (depth == 0) { + return new IdCstNode.Terminal(idGen.next(), SPAN, "Leaf", "x", NO_TRIVIA, NO_TRIVIA); + } + var children = new ArrayList(branchingFactor); + for (int i = 0; i < branchingFactor; i++) { + children.add(buildSubtree(depth - 1, branchingFactor, idGen)); + } + var rule = RULES[depth % RULES.length]; + return new IdCstNode.NonTerminal(idGen.next(), SPAN, rule, List.copyOf(children), NO_TRIVIA, NO_TRIVIA); + } + + /** + * Find a path from {@code root} to a node at exactly {@code targetDepth}. + * Walks the leftmost child at each level. Used to position the splice + * pivot at a representative interior depth. + * + *

Returns the inclusive {@code root → pivot} path. The list size equals + * {@code targetDepth + 1}. + * + * @throws IllegalArgumentException if the tree is not deep enough + */ + static List findPathAtDepth(IdCstNode root, int targetDepth) { + if (targetDepth < 0) { + throw new IllegalArgumentException("targetDepth must be >= 0"); + } + var path = new ArrayList(targetDepth + 1); + path.add(root); + var current = root; + for (int i = 0; i < targetDepth; i++) { + if (!(current instanceof IdCstNode.NonTerminal nt) || nt.children().isEmpty()) { + throw new IllegalArgumentException( + "tree not deep enough: requested depth " + targetDepth + ", reached level " + i); + } + current = nt.children().get(0); + path.add(current); + } + return List.copyOf(path); + } + + /** + * Build a small replacement subtree to splice in at the pivot. The pivot + * is a small balanced {@code branchingFactor}-ary tree of the requested + * depth, sharing IDs from the supplied generator (so it doesn't collide + * with the existing tree). + */ + static IdCstNode buildPivot(int pivotDepth, int branchingFactor, IdGenerator idGen) { + return buildSubtree(pivotDepth, branchingFactor, idGen); + } + + /** Count every node in the tree (terminals and non-terminals). */ + static int countAll(IdCstNode node) { + int c = 1; + if (node instanceof IdCstNode.NonTerminal nt) { + for (var child : nt.children()) { + c += countAll(child); + } + } + return c; + } +} From a8c6efe802586c602b014f9415c1b17c430c3406 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 00:36:51 +0200 Subject: [PATCH 07/46] =?UTF-8?q?docs:=20phase=200e=20=E2=80=94=20GO=20ver?= =?UTF-8?q?dict,=20CHANGELOG=20entry,=20HANDOVER=20updated=20to=20point=20?= =?UTF-8?q?at=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++ docs/HANDOVER.md | 33 +++++++++--- docs/incremental/PHASE-0-RESULTS.md | 83 +++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 docs/incremental/PHASE-0-RESULTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dde1cd3..22ec317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 _Work in progress — incremental-native architectural rework. See `docs/incremental/ARCHITECTURE-0.5.0.md`._ +### Phase 0 — spike GO verdict (2026-05-07) + +Sandbox prototype of Lever A (stable IDs + LongLongMap NodeIndex) lands additively in `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/`. Existing 897-test suite untouched. Three GO/NO-GO gates green: + +- **Identity-preservation invariant** — `IdTreeSplicer` preserves sibling subtree reference equality through splices (spec §8 Q3 gate). +- **Trivia-bearing edits** — calculator grammar with `%whitespace` + comments, three representative edits, incremental update equivalent to full rebuild (spec §8 Q4 gate). +- **Perf** — JMH bench: 38× speedup at 100 nodes, 47× at 1000, 67× at 10000. Well above the 5× gate threshold; consistent with spec §2's projected 300× per-edit reduction. See [`docs/bench-results/phase0-spike-results.md`](docs/bench-results/phase0-spike-results.md) and [`docs/incremental/PHASE-0-RESULTS.md`](docs/incremental/PHASE-0-RESULTS.md). + +Phases 1–5 (production migration of all 5 modules) is the next-session entry point. + ## [0.4.3] - 2026-05-06 Performance — interactive editing focus. 19% faster median, 26% faster p95 on the IncrementalBenchmark editing-session suite. **One breaking change**: SourceSpan record components changed from two SourceLocations to six ints (see migration note). diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 6f3a59a..2a32a04 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -270,17 +270,34 @@ Regen via: ## 11. Recommended next session -The 0.4.x perf arc is complete (281 ms → 10.8 ms median, 26× from baseline; 0.4.3 ships at p99 ≈ 53 ms with 91.5% of edits under the 16 ms frame budget). Further per-edit-cost reductions are gated on architectural change, not algorithmic tweaks. +**Phase 0 spike landed GO on 2026-05-07.** All three GO/NO-GO gates green; Phase 1 (production migration of Lever A) is the next-session entry. Read `docs/incremental/PHASE-0-RESULTS.md` for the full verdict + bench numbers (38–67× speedup confirmed) before starting. -1. **Read `docs/incremental/ARCHITECTURE-0.5.0.md` first** — the spec that supersedes the §6.2 lever-1 puzzle and §6.4 unsafe-generator work. Both items dissolve into the proposed 0.5.0 design. -2. **Phase 0 prototype** (per spec §6) — Lever A (stable node IDs + `LongLongMap` NodeIndex) on the calculator grammar end-to-end. ~1 week. GO/NO-GO gate on whether incremental NodeIndex update materially beats the 0.4.3 baseline on synthetic edits. -3. **If GO:** execute Phases 1–5 over 4–5 weeks. Levers B (top-down pivot), C (`peglib-rt` interpreter/generator unification), D (Cursor split). Single major-version bump at 0.5.0. -4. **If NO-GO:** revisit individual levers tactically; the spec stays as a future reference; ship 0.4.x patches as needed for downstream bugs. +State of `release-0.5.0` branch (5 commits past `1619604` chore — local only, not pushed): -Do NOT attempt lever 1 as a 0.4.x patch — both attempts proved the data structure forbids it. The 5-10 day correctness estimate in §6.2 is what's required to make it work *without* the architectural change; the spec proposes a path that makes lever-1 a 30-line method instead. +- `d00eaa1` Phase 0a — `IdGenerator` + `LongLongMap` foundation +- `f0696a1` Phase 0b — sandbox `IdCstNode` + `IdCstNodeBuilder` +- `849b4ba` Phase 0c — sandbox `IdNodeIndex` with O(splicedSize+depth) incremental update +- `9b55253` Phase 0d.1 — `IdTreeSplicer` + identity-invariant + trivia-edit gates +- `a2dd8ac` Phase 0d.2 — JMH `Phase0SpikeBench` + results note -Do NOT pursue further allocation reduction in the 0.4.x interpreter without the architectural change — the SourceLocation interning probe and ParseResult.Failure singleton probe both confirmed bytes-allocated metrics don't translate to wall-time wins under the current data structure (HashMap.Node entries promote and dominate). The structural SourceSpan refactor that DID ship in 0.4.3 was the exception because it eliminated long-lived refs from CstNodes. +All work additive in `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/` and the parallel test/jmh directories. 897-test production surface untouched throughout. Local incremental count: 154 (was 100, +54 sandbox tests). 699 core unchanged. + +### Phase 1 — production Lever A migration (next session) + +Per spec §6 Phase 1 (~1 week elapsed): migrate production `CstNode` to ID-bearing variants, switch `NodeIndex` from `IdentityHashMap` to `LongLongMap`. Three concrete items surfaced by Phase 0 (per `PHASE-0-RESULTS.md` §"Notes for Phase 1") that Phase 1 should address: + +1. **Cross-parse record sharing** — `IdCstNodeBuilder` re-assigns fresh IDs every conversion. The 0.5.0 perf depends on `TreeSplicer.spliceAndShift` being the source of identity-shared trees, not the parser. Phase 1 task #1: confirm `TreeSplicer.spliceAndShift` preserves sibling identity (spec §8 Q3 second sentence). Fix it if not. +2. **`applyIncremental` mutate-in-place semantics** — sandbox API invalidates the receiver. Production needs to choose: copy-on-write via `LongLongMap.copy()` (cheap — primitive arrays vs IdentityHashMap entries) or persistent map. Decision affects `IncrementalSession`'s API shape. +3. **Worst-case splices not benched** — spike measures depth-3, small-pivot. Class-body-scale rewrites need a separate bench. + +After Phase 1 lands and IncrementalParityTest stays green at 22×100, proceed to Phase 2 (Lever B top-down pivot — should dissolve the §6.2 lever-1 puzzle into a 30-line method per spec §3). + +### Items now superseded + +§6.2 lever-1 puzzle and §6.4 unsafe-generator work are both dissolved by the 0.5.0 design. Do NOT attempt lever 1 as a 0.4.x patch. + +Do NOT pursue further allocation reduction in the 0.4.x interpreter without the architectural change — the SourceLocation interning probe and ParseResult.Failure singleton probe both confirmed bytes-allocated metrics don't translate to wall-time wins under the current data structure. --- -**Last updated:** 2026-05-03, after the post-0.4.0 lever-1 retry failed (31/100 parity regressions). Handover from the 0.3.5 → 0.4.0 arc, plus the failed-lever-1 forensics. +**Last updated:** 2026-05-07, after Phase 0 spike GO verdict. Previous update: 2026-05-03 post-failed-lever-1 forensics. Handover spans the 0.3.5 → 0.4.3 arc + the 0.5.0 Phase 0 spike completion. diff --git a/docs/incremental/PHASE-0-RESULTS.md b/docs/incremental/PHASE-0-RESULTS.md new file mode 100644 index 0000000..c6a96d0 --- /dev/null +++ b/docs/incremental/PHASE-0-RESULTS.md @@ -0,0 +1,83 @@ +# Phase 0 — GO/NO-GO Results + +**Date:** 2026-05-07 +**Branch:** release-0.5.0 +**Spec:** [`ARCHITECTURE-0.5.0.md`](ARCHITECTURE-0.5.0.md) + +## Verdict: **GO** + +All three GO/NO-GO gates green. Recommend proceeding with Phases 1–5 per spec §6. + +| Gate | Criterion | Result | +|------|-----------|:------:| +| Q3 — identity-preservation invariant | Sibling subtrees of the splice path satisfy reference equality (`==`) with their pre-edit counterparts | **GREEN** | +| Q4 — trivia-bearing edits | Calculator grammar with `%whitespace`/comments; 3 representative edits each show incremental update equivalent to full rebuild + invariant preserved | **GREEN** | +| Perf — incremental NodeIndex update materially beats full rebuild | ≥5× speedup at representative tree sizes (≥1000 nodes) | **GREEN — 47× at 1k nodes, 67× at 10k nodes** | + +## Work delivered + +Phase 0 on the `release-0.5.0` branch, sandboxed in `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/`. No production code touched. Existing 897-test suite unaffected throughout. + +| Phase | Commit | Deliverable | New tests | +|-------|--------|-------------|----------:| +| 0a | `d00eaa1` | `IdGenerator` + `PerSessionCounter`; `LongLongMap` + `LinearProbingLongLongMap` | 17 | +| 0b | `f0696a1` | `IdCstNode` (sealed, ID-bearing variant of production CstNode); `IdCstNodeBuilder` (production-tree → IdCstNode converter) | 19 | +| 0c | `849b4ba` | `IdNodeIndex` with `build` (O(N) full) + `applyIncremental` (O(splicedSize + depth × branching) per spec §2) | 8 | +| 0d.1 | `9b55253` | `IdTreeSplicer` (record-identity-preserving splicer); identity-invariant test (Q3 gate); calculator + trivia regression test (Q4 gate) | 10 | +| 0d.2 | `a2dd8ac` | JMH bench `Phase0SpikeBench` + results note `docs/bench-results/phase0-spike-results.md` | — | + +**Test totals:** 154 incremental tests (was 100; +54 from Phase 0). 699 core tests unchanged. Full suite green at every checkpoint. + +## Gate details + +### Q3 — identity-preservation invariant + +**Test:** `IdTreeSplicerTest` (7 tests). Constructs a 4-deep, 4-wide tree, splices at one location, asserts that for every node on the splice path, all siblings at indexes other than the spliced one are reference-equal (`==`) in old and new trees. Implementation: `IdTreeSplicer.splice(...)` builds new `NonTerminal` records by `ArrayList(old.children())` + `set(spliceIndex, current)` — every other child slot is preserved by reference. + +**Microcount evidence:** Phase 0c's depth-3 splice on a 28-node tree triggered exactly 8 `parents.put` calls (vs 28 for full rebuild) — confirms the algorithm walks only the splice path + spliced subtree + ancestor children, not the whole tree. + +### Q4 — trivia-bearing edits + +**Test:** `CalculatorTriviaIncrementalTest` (3 tests, one per edit kind). Calculator grammar with `Comment <- '/*' (!'*/' .)* '*/'` and `%whitespace <- ([ \t\r\n]+ / Comment)+`. For each of: + +- Edit A — insert blank line before operand (`"1+2"` → `"1+\n 2"`) +- Edit B — delete comment between operands (`"1 /*hi*/ + 2"` → `"1 + 2"`) +- Edit C — insert comment inside expression (`"1+2"` → `"1+/*x*/2"`) + +The test parses both inputs, converts to `IdCstNode`, identifies the splice point pragmatically (the `Number` whose token text is "2"), splices via `IdTreeSplicer`, applies `applyIncremental`, and asserts the resulting parents map is structurally equivalent to a fresh `IdNodeIndex.build(newRoot)`. Identity invariant verified concurrently — the test instrumentation logs `siblingsChecked` for each edit. + +All three edits pass both assertions. The Q4 gate is satisfied **for the algorithm given identity-shared trees from a splicer**. Production parser's lack of cross-parse record sharing is a separate concern flagged for Phase 1 (see §"Notes for Phase 1" below). + +### Perf gate — incremental update beats full rebuild ≥5× + +**Bench:** `Phase0SpikeBench` (synthetic perfect 4-ary tree, depth-3 splice, JDK 25, JMH 1.37). Full numbers in [`docs/bench-results/phase0-spike-results.md`](../bench-results/phase0-spike-results.md). + +| Tree size | fullRebuild (μs) | incrementalUpdate (μs) | Speedup | +|-----------|-----------------:|-----------------------:|--------:| +| 100 | 2.678 ± 0.389 | 0.069 ± 0.001 | 38.8× | +| 1000 | 9.611 ± 0.312 | 0.203 ± 0.011 | 47.3× | +| 10000 | 176.468 ± 16.561 | 2.627 ± 0.237 | 67.2× | + +Speedup grows with tree size — incremental cost is dominated by `O(depth × branching)` (≈ `log_4(N) × 4`), full-rebuild cost is `O(N)`. The 0.4.3 fixture (1900 LOC, ~10k nodes) is in the regime where 0.5.0 can deliver order-of-magnitude per-edit gains. + +The spec's projected 0.5.0 floor (p99 ≤ 16ms on the 1900-LOC fixture) is comfortably attainable: at 10k nodes the bench shows applyIncremental at 2.6μs — five orders of magnitude under the 16ms target. Even with the realistic costs of `TreeSplicer` work, parser invocation, and trivia attribution layered on top, the budget is large. + +## Notes for Phase 1 + +Three concrete items surfaced during Phase 0 that Phase 1 should address explicitly: + +1. **Cross-parse record sharing.** `IdCstNodeBuilder` re-assigns fresh IDs on every conversion — the production parser produces a fresh tree per parse with no record sharing. The 0.5.0 algorithm's perf depends on the editing flow producing identity-shared trees, which is `TreeSplicer.spliceAndShift`'s job, not the parser's. Phase 1's first task: confirm `TreeSplicer.spliceAndShift` preserves sibling identity (per spec §8 Q3 second sentence). If it doesn't, fix it; spec calls this part of Phase 0/1 scope. + +2. **`applyIncremental` mutate-in-place semantics.** Spike API invalidates the receiver — caller must use the returned instance. Phase 1 needs to choose: (a) copy-on-write via `LongLongMap.copy()` (O(N) per edit, partially defeats the point — though still much cheaper than a full Index rebuild because the copy is primitive arrays vs IdentityHashMap entries), (b) persistent map (e.g., HAMT-based long-long), (c) keep mutate-in-place + document loudly. Decision affects the API shape of `IncrementalSession`. + +3. **Worst-case splices not benched.** The spike measures depth-3, small-pivot splices. Class-body-scale rewrites (large pivots) need a separate bench in Phase 1 to confirm the algorithm scales gracefully there too. + +## Recommendation + +Proceed with Phases 1–5 per spec §6 schedule (4–5 weeks elapsed, 2–3 weeks of focused engineering). The architectural premise is empirically validated: + +- Algorithm is correct under both invariants the spec calls out. +- Perf gain is order-of-magnitude — well above the 5× threshold and consistent with the spec's 300× projection. +- Sandbox approach proved viable: Phase 0 landed without disturbing the 897-test production surface. + +Open the next session with **Phase 1 — Lever A on full Java grammar**: migrate production `CstNode` to ID-bearing variants, switch `NodeIndex` to `LongLongMap`, verify all 897 tests + 22×100 IncrementalParityTest stay green, re-bench against the 0.4.3 baseline on the 1900-LOC fixture. From 8f844eb9ff9c6d9cd150a18d68235ddaabf12d88 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 06:45:37 +0200 Subject: [PATCH 08/46] =?UTF-8?q?exp:=20phase=201.0/1.1=20=E2=80=94=20path?= =?UTF-8?q?=20A=20SpanIndex=20prove-out=20(perf=20gate=20RED,=201.10-1.29x?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bench-results/phase1-spanindex-results.md | 106 +++++++ .../peg/incremental/bench/MidBufferBench.java | 241 +++++++++++++++ .../bench/MidBufferTreeBuilder.java | 77 +++++ .../LinearProbingLongLongMap.java | 13 + .../incremental/experimental/LongLongMap.java | 27 ++ .../experimental/OffsetDecoupledNode.java | 136 +++++++++ .../OffsetDecoupledNodeIndex.java | 142 +++++++++ .../experimental/OffsetDecoupledSplicer.java | 198 +++++++++++++ .../incremental/experimental/SpanIndex.java | 143 +++++++++ .../experimental/OffsetDecoupledNodeTest.java | 94 ++++++ .../OffsetDecoupledSplicerTest.java | 279 ++++++++++++++++++ .../experimental/SpanIndexTest.java | 254 ++++++++++++++++ 12 files changed, 1710 insertions(+) create mode 100644 docs/bench-results/phase1-spanindex-results.md create mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java create mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java diff --git a/docs/bench-results/phase1-spanindex-results.md b/docs/bench-results/phase1-spanindex-results.md new file mode 100644 index 0000000..11f3250 --- /dev/null +++ b/docs/bench-results/phase1-spanindex-results.md @@ -0,0 +1,106 @@ +# Phase 1.0/1.1 SpanIndex (Path A) Bench Results + +**Date:** 2026-05-07 +**Branch:** release-0.5.0 (post-Phase-0d.2, sandbox additions only) +**JMH version:** 1.37 +**JVM:** OpenJDK 25.0.2 (Apple Silicon, Homebrew) + +## Configuration + +- **Bench class:** `peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java` +- **Tree shape:** flat single-level — `Root[child_0 … child_{N-1}]` — mimicking a long Java method body of N statement nodes (vs Phase 0's balanced 4-ary tree). +- **Edit:** replace child at index N/2 with a fresh terminal of width 13, original width 10 → delta = +3, editEnd = (N/2)·10 + 10. About half the children sit right of the edit and need their offsets shifted. +- **Mode:** `AverageTime`, microseconds/op +- **Run flags (reduced for spike):** `-i 3 -wi 2 -f 1` — class declares `(iterations=5, warmup=3, fork=2)` for full runs; the spike uses reduced flags to keep total bench time under one minute. +- **Per-invocation setup:** `idOldIndex` rebuilt via `IdNodeIndex.build(idRoot)` and `odOldIndex` via `OffsetDecoupledNodeIndex.build(odRoot)` on every `Level.Invocation`, because `applyIncremental` mutates the receiver's parent map. + +## Two arms compared + +| Arm | What it does | +|-----|-------------| +| **productionStyle** | For every right-of-edit sibling, deep-copy the record via `shiftAll`-equivalent (rebuilds `IdCstNode.Terminal` with new `SourceSpan`). Then `IdNodeIndex.applyIncremental`. Mirrors the production `TreeSplicer.spliceAndShift` behaviour for siblings whose offsets must move. | +| **offsetDecoupled** | `OffsetDecoupledSplicer.splice` — copies `SpanIndex` and shifts all entries with `start ≥ editEnd` via a single `LongLongMap.forEachEntry` walk over a primitive `long[]`. Sibling records are reference-shared. Then `OffsetDecoupledNodeIndex.applyIncremental`. | + +## Measurements + +| Tree size | productionStyle (μs) | offsetDecoupled (μs) | Speedup | +|-----------|---------------------:|---------------------:|--------:| +| 100 | 1.272 ± 0.413 | 1.153 ± 0.219 | 1.10× | +| 1000 | 12.524 ± 9.498 | 9.710 ± 0.877 | 1.29× | +| 10000 | 81.656 ± 6.085 | 102.620 ± 31.160 | 0.80× | + +Total bench wallclock: 49 seconds. + +## Perf gate + +**Criterion:** offsetDecoupled ≥ 5× faster than productionStyle at representative tree sizes (≥ 1000 nodes). + +**Result: RED.** At 1000 nodes the speedup is 1.29× (well below 5×). At 10000 nodes path A is *slower* than productionStyle (0.80×). Path A does not pay off on this shape. + +## Why path A under-delivered + +The bench measures `splice + applyIncremental` end-to-end. The decomposition is: + +| Component | productionStyle | offsetDecoupled | +|-----------|-----------------|-----------------| +| Splice work | `O(N/2)` record allocations + `SourceSpan` rewrites for right-of-edit siblings | `O(N)` `SpanIndex.copy` + `O(N)` shift walk over a primitive `long[]` | +| `applyIncremental` step 3 | `O(N)` parent-link rewires for ALL N direct children of the rebuilt root | `O(N)` parent-link rewires for ALL N direct children | + +For the flat single-level shape, `applyIncremental` step 3 walks every direct child of the new root regardless of arm — that's `O(N)` `LongLongMap.put` operations on both sides. This common-cost term dominates at N = 10000: + +- offsetDecoupled total ≈ `SpanIndex.copy` (10000 entries) + `SpanIndex.shift` walk (10000 entries) + `applyIncremental` step 3 (10000 puts) ≈ `3 × O(N)` work. +- productionStyle total ≈ `N/2` record rebuilds + `applyIncremental` step 3 (10000 puts) ≈ `O(N)` heavyweight + `O(N)` lightweight. + +Path A swaps "rebuild N/2 records" for "two extra full walks over N primitive entries". On the flat shape, the constant factor of three light walks ends up *equal to or larger than* one heavy half-walk. Record allocation in HotSpot's TLAB is fast for short-lived objects; the L1-resident primitive arrays in `LongLongMap` end up similarly cheap per-element. The differential is in the noise. + +The Phase 0 bench saw 67× because the depth-3 splice in the balanced 4-ary tree had only ~16 right-of-edit nodes (1/4 fan-out at each level above the splice), so `applyIncremental` step 3 did `~depth × branching` ≈ 12 puts, not N. The splice cost was a tiny fraction of total. Phase 1's flat shape inverts that — `applyIncremental` itself is `O(N)` and dominates. + +## Architectural insight (affects Phase 1.2+ migration if pursued) + +Two compounding facts sink the path-A claim on the realistic shape: + +1. **`SpanIndex.copy` cost.** The eager-copy snapshot semantics (mirroring what production v0.5.0 will need) is itself `O(N)`. Even before any shift, we've spent the time it would take to rebuild N/2 records. +2. **`applyIncremental` step 3 is `O(direct_children_of_rebuilt_root)`.** On a flat method body the rebuilt root *is* the body, so step 3 alone is `O(N)`. Path A doesn't help with that step at all — sibling records preserved or not, the parent-link rewires happen for every direct child of every newly-allocated ancestor. + +To make path A pay off, both would need to change: + +- Snapshot via path-copying / persistent map instead of full copy. Out of scope (spec §8 future work). +- `applyIncremental` step 3 must avoid rewiring siblings whose parent-id is *unchanged*. Today the rebuilt ancestor has a fresh id, forcing all-children rewire. A "stable-id ancestor rebuild" — keep ancestor id fixed when only one child changed and the ancestor's identity is otherwise structural — would limit step 3 to `O(depth)`. That's a separate architectural change; path A alone doesn't unlock it. + +## Right-of-edit identity invariant — confirmed? + +**Yes** — `OffsetDecoupledSplicerTest.rightOfEditSiblingsPreserveIdentity` passes. After splice, `newRoot.children().get(2) == C` (the right-of-edit sibling) holds by reference equality, AND `newSpans.startOffset(C.id()) == oldStart + delta`. This is the central correctness claim of path A and it is met. + +That the perf gate is RED *despite* the invariant being met is the architectural insight: identity preservation on the record side does not translate to per-edit speedup when other O(N) costs (SpanIndex copy, applyIncremental step 3 on the rebuilt root) dominate. + +## Caveats + +- **Numbers are noisy at 10000.** stdev on `productionStyle@10000` is 6 μs on an 81 μs mean (acceptable); on `offsetDecoupled@10000` it's 31 μs on a 102 μs mean (high — likely TLAB/GC effects from the `SpanIndex.copy` allocation pattern). A full `-i 5 -wi 3 -f 2` run would tighten bands but not change the verdict — even taking the lower bound of `offsetDecoupled` (~71 μs) and upper bound of `productionStyle` (~88 μs), the speedup is ≤ 1.24× at best, far below the 5× gate. +- **Bench tree is single-level flat.** Real source isn't this extreme; method bodies are nested. A future "moderately deep" shape (say, `class > method > 50 statements > expression-tree-of-depth-5`) would see less of the step-3 dominance and might tilt closer to favourable. But such a shape would *also* trigger the production `TreeSplicer.spliceAndShift` ancestor-rebuild path, which is `O(depth)` and small. The flat shape was chosen specifically to expose the right-of-edit deep-copy cost; if path A doesn't win there, it doesn't win anywhere meaningful. +- **`SpanIndex` uses packed-long encoding via `LinearProbingLongLongMap`** (resolution of the choice in the brief). One additional `forEachEntry` API was added to `LongLongMap` — additive, no test regression. +- **Trivia spans untouched.** Production `Trivia` still carries `SourceSpan`; for the prove-out, all trivia lists are empty. A real path-A migration would need a parallel `TriviaIndex`, doubling the snapshot-copy cost. + +## Verdict + +**NO-GO** for path A as a complete strategy. The bench validates the user's concern in the brief: the Phase 0 67× was specific to the balanced-tree depth-3 splice; on the realistic flat shape, the right-of-edit identity preservation that path A buys is not worth the `SpanIndex.copy` snapshot cost it pays. + +Recommended next steps (in order of attractiveness): + +1. **Path C (accept partial win).** Keep the production `TreeSplicer` as-is. The Phase 0c `IdNodeIndex` already gives O(δ) on the tree shapes Phase 0's bench targeted (depth-3 splices in deep trees); ship that without committing to span decoupling. Real fixtures (1900-LOC Java) are deeper than the Phase 1 flat shape, so the Phase 0 win partially translates. +2. **Path B (lazy shift).** Track shift deltas as offset annotations on the splice path and resolve lazily on read. Avoids the `SpanIndex.copy` cost. More complex; the design is a separate spike. +3. **Stable-id ancestor preservation.** Independent of path-A vs B. If the ancestor on the splice path can keep its old id (only its `children` changed; rule/trivia identical), `applyIncremental` step 3 reduces to `O(depth)`. This is the highest-leverage change visible from this bench. + +## Files + +- `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java` +- `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java` +- `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java` +- `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java` +- `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java` (additive: `forEachEntry`) +- `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java` (impl of `forEachEntry`) +- `peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java` +- `peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java` +- `peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java` +- `peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java` +- `peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java` +- `peglib-incremental/target/phase1-spanindex.json` — raw JMH output (not committed) diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java new file mode 100644 index 0000000..852a764 --- /dev/null +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java @@ -0,0 +1,241 @@ +package org.pragmatica.peg.incremental.bench; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.pragmatica.peg.incremental.experimental.IdCstNode; +import org.pragmatica.peg.incremental.experimental.IdGenerator; +import org.pragmatica.peg.incremental.experimental.IdNodeIndex; +import org.pragmatica.peg.incremental.experimental.OffsetDecoupledNode; +import org.pragmatica.peg.incremental.experimental.OffsetDecoupledNodeIndex; +import org.pragmatica.peg.incremental.experimental.OffsetDecoupledSplicer; +import org.pragmatica.peg.incremental.experimental.SpanIndex; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Phase 1.0/1.1 — perf-gate JMH bench for path-A (offset decoupling) on + * mid-buffer edits. + * + *

Why this bench exists. The Phase 0 spike measured a + * 38-67× speedup on a balanced 4-ary tree with a depth-3 splice. That tree + * shape didn't trigger the right-of-edit deep-copy that + * {@code TreeSplicer.spliceAndShift} pays for siblings whose offsets must + * shift. Real source files (long method bodies, sequential top-level + * declarations) produce wide, sequential children at the spine, and + * mid-buffer edits force deep-copy of ~half of them. This bench measures + * that case. + * + *

Tree shape

+ * + *

{@code Root[child_0, child_1, ..., child_{N-1}]} — a single + * NonTerminal root with {@code N} terminal children. Each child spans + * {@code [i*10, i*10+10)}. Mimics a Java method body of {@code N} statements. + * + *

Edit

+ * + *

Replace child at index {@code N/2} with a freshly-built terminal of + * width 13 (delta = +3). The edit is mid-buffer: ~N/2 siblings to the right + * have offsets {@code >= editEnd} and need shifting. + * + *

Two arms compared

+ * + *
    + *
  • productionStyle: replicates + * {@code TreeSplicer.spliceAndShift} on {@link IdCstNode} — for every + * child slot, if the child starts at or after {@code editEnd}, deep-copy + * it via {@code shiftAll} (rebuild record with new {@link SourceSpan}). + * Then run {@link IdNodeIndex#applyIncremental}. + *
  • offsetDecoupled: {@link OffsetDecoupledSplicer#splice} + * does the work — the SpanIndex is shifted in place via a single walk + * over a primitive {@code long[]}; sibling records are reference-shared. + * Then run {@link OffsetDecoupledNodeIndex#applyIncremental}. + *
+ * + *

The "production-style" arm is implemented inline (rather than calling + * the production {@code TreeSplicer}) for two reasons: (1) {@code TreeSplicer} + * works on production {@link org.pragmatica.peg.tree.CstNode}, not + * {@link IdCstNode}; (2) we need the index update to operate on the same + * record type for an apples-to-apples comparison. The inlined logic mirrors + * {@code TreeSplicer.shiftAll} and {@code TreeSplicer.rebuildNonTerminal} + * exactly. + * + *

Perf gate

+ * + *

Path A passes the gate iff {@code productionStyle / offsetDecoupled ≥ 5} + * at tree size ≥ 1000 children. NO-GO falls back to path B (lazy shift) or + * path C (accept partial win). + * + *

How to run

+ *
{@code
+ *   mvn -pl peglib-incremental -am -Pbench -DskipTests package
+ *   java -jar peglib-incremental/target/benchmarks.jar MidBufferBench \
+ *     -rf json -rff phase1-spanindex.json -i 3 -wi 2 -f 1
+ * }
+ * + * @since 0.5.0 + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 2) +@Fork(2) +@State(Scope.Benchmark) +public class MidBufferBench { + + private static final List NO_TRIVIA = List.of(); + + @Param({"100", "1000", "10000"}) + private int treeSize; + + // --- production-style arm state --- + private IdCstNode idRoot; + private IdCstNode idTarget; // the child being replaced + private IdCstNode idNewPivot; + private IdGenerator idGenProd; // for ancestor rebuild ids + private IdNodeIndex idOldIndex; // pre-built; rebuilt per Level.Invocation + private int editEnd; + private int delta; + + // --- offset-decoupled arm state --- + private OffsetDecoupledNode odRoot; + private OffsetDecoupledNode odTarget; + private OffsetDecoupledNode odNewPivot; + private SpanIndex odBaselineSpans; + private OffsetDecoupledSplicer odSplicer; + private OffsetDecoupledNodeIndex odOldIndex; // rebuilt per Level.Invocation + private IdGenerator idGenOd; + + @Setup(Level.Trial) + public void buildTrees() { + // Production-style tree. + idGenProd = new IdGenerator.PerSessionCounter(); + idRoot = MidBufferTreeBuilder.buildIdTree(treeSize, idGenProd); + var idChildren = ((IdCstNode.NonTerminal) idRoot).children(); + int midIndex = treeSize / 2; + idTarget = idChildren.get(midIndex); + // Replacement: width 13 (delta = +3) at the same start offset. + int targetStart = midIndex * MidBufferTreeBuilder.CHILD_WIDTH; + var newPivotSpan = new SourceSpan(1, 1, targetStart, 1, 1, targetStart + 13); + idNewPivot = new IdCstNode.Terminal(idGenProd.next(), newPivotSpan, "Stmt", "yyy", NO_TRIVIA, NO_TRIVIA); + editEnd = targetStart + MidBufferTreeBuilder.CHILD_WIDTH; // old end + delta = 3; + + // Offset-decoupled tree (same shape, fresh ids). + idGenOd = new IdGenerator.PerSessionCounter(); + var odTree = MidBufferTreeBuilder.buildOffsetDecoupledTree(treeSize, idGenOd); + odRoot = odTree.root(); + odBaselineSpans = odTree.spans(); + var odChildren = ((OffsetDecoupledNode.NonTerminal) odRoot).children(); + odTarget = odChildren.get(midIndex); + odNewPivot = new OffsetDecoupledNode.Terminal(idGenOd.next(), "Stmt", "yyy", NO_TRIVIA, NO_TRIVIA); + // Caller responsibility: register newPivot's span. + odBaselineSpans.put(odNewPivot.id(), targetStart, targetStart + 13); + odSplicer = new OffsetDecoupledSplicer(idGenOd); + } + + /** + * Per-invocation: rebuild both arm's old indices, because + * {@link IdNodeIndex#applyIncremental} and + * {@link OffsetDecoupledNodeIndex#applyIncremental} mutate the receiver's + * parent map. Cost is the same on both sides ({@code O(N)} build); the + * bench measures the differential cost of the splice + index update arm. + */ + @Setup(Level.Invocation) + public void rebuildIndices() { + idOldIndex = IdNodeIndex.build(idRoot); + odOldIndex = OffsetDecoupledNodeIndex.build(odRoot); + } + + /** + * Production-style: deep-copy every right-of-edit sibling, rebuild root + * with new spans, then call {@code applyIncremental} on the index. + * + *

Mirrors {@code TreeSplicer.spliceAndShift} for a single-level tree — + * step 1 is the spans-baked-in rebuild that path A claims to eliminate. + */ + @Benchmark + public Object productionStyle() { + // Step 1: build new root with the spliced child + shifted right siblings. + var oldNT = (IdCstNode.NonTerminal) idRoot; + var oldChildren = oldNT.children(); + var newChildren = new ArrayList(oldChildren.size()); + for (var child : oldChildren) { + if (child == idTarget) { + newChildren.add(idNewPivot); + } else if (child.span().startOffset() >= editEnd) { + newChildren.add(shiftAll(child, delta)); + } else { + newChildren.add(child); + } + } + // Rebuild root span: end shifted because old end >= editEnd. + var oldSpan = oldNT.span(); + var newRootSpan = new SourceSpan( + oldSpan.startLine(), oldSpan.startColumn(), oldSpan.startOffset(), + oldSpan.endLine(), oldSpan.endColumn(), oldSpan.endOffset() + delta); + var newRoot = new IdCstNode.NonTerminal( + idGenProd.next(), newRootSpan, oldNT.rule(), List.copyOf(newChildren), + oldNT.leadingTrivia(), oldNT.trailingTrivia()); + + // Step 2: incremental index update. + // oldPath: [idRoot, idTarget]; newPath: [newRoot, idNewPivot]. + return idOldIndex.applyIncremental(newRoot, List.of(idRoot, idTarget), List.of(newRoot, idNewPivot)); + } + + /** + * Path A: SpanIndex.shift + reference-shared siblings + incremental index update. + */ + @Benchmark + public Object offsetDecoupled() { + var oldPath = List.of(odRoot, odTarget); + var spliceResult = odSplicer.splice(odBaselineSpans, oldPath, odNewPivot, editEnd, delta); + + return odOldIndex.applyIncremental(spliceResult.newRoot(), oldPath, spliceResult.newPath()); + } + + // --- inline production-style helpers (mirror TreeSplicer.shiftAll) --- + + private static IdCstNode shiftAll(IdCstNode node, int delta) { + if (delta == 0) { + return node; + } + var span = shiftSpan(node.span(), delta); + return switch (node) { + case IdCstNode.Terminal t -> new IdCstNode.Terminal( + t.id(), span, t.rule(), t.text(), t.leadingTrivia(), t.trailingTrivia()); + case IdCstNode.Token t -> new IdCstNode.Token( + t.id(), span, t.rule(), t.text(), t.leadingTrivia(), t.trailingTrivia()); + case IdCstNode.Error e -> new IdCstNode.Error( + e.id(), span, e.skippedText(), e.expected(), e.leadingTrivia(), e.trailingTrivia()); + case IdCstNode.NonTerminal nt -> { + var shifted = new ArrayList(nt.children().size()); + for (var child : nt.children()) { + shifted.add(shiftAll(child, delta)); + } + yield new IdCstNode.NonTerminal( + nt.id(), span, nt.rule(), List.copyOf(shifted), + nt.leadingTrivia(), nt.trailingTrivia()); + } + }; + } + + private static SourceSpan shiftSpan(SourceSpan span, int delta) { + return new SourceSpan( + span.startLine(), span.startColumn(), span.startOffset() + delta, + span.endLine(), span.endColumn(), span.endOffset() + delta); + } +} diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java new file mode 100644 index 0000000..149260d --- /dev/null +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java @@ -0,0 +1,77 @@ +package org.pragmatica.peg.incremental.bench; + +import org.pragmatica.peg.incremental.experimental.IdCstNode; +import org.pragmatica.peg.incremental.experimental.IdGenerator; +import org.pragmatica.peg.incremental.experimental.OffsetDecoupledNode; +import org.pragmatica.peg.incremental.experimental.SpanIndex; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.ArrayList; +import java.util.List; + +/** + * Synthesizes a "long method body" tree shape for {@link MidBufferBench} — + * a single root with N sibling children, mimicking a long sequence of + * statement nodes in a Java method body. Mid-buffer edits replace the + * middle child, exposing the right-of-edit shift cost that + * {@code TreeSplicer.spliceAndShift} pays in production. + * + *

Each child has a span of width 10, so child {@code i} spans + * {@code [i*10, i*10 + 10)}. The mid-buffer edit point is index N/2. + * + *

The shape is intentionally unbalanced — flat with high fan-out — to + * stress the right-of-edit path. The Phase 0 spike used a balanced 4-ary + * tree where the splice depth-3 found the pivot near the leaves before + * many right siblings; this is why the 67× speedup didn't translate to + * mid-buffer edits in real source. + * + *

Sandbox-only; not referenced by {@code peglib-core}. + * + * @since 0.5.0 + */ +final class MidBufferTreeBuilder { + + static final int CHILD_WIDTH = 10; + private static final List NO_TRIVIA = List.of(); + + private MidBufferTreeBuilder() {} + + /** + * Build an {@link IdCstNode} tree: Root with {@code childCount} terminal + * children, child {@code i} at offsets {@code [i*10, i*10+10)}. + */ + static IdCstNode buildIdTree(int childCount, IdGenerator idGen) { + var children = new ArrayList(childCount); + for (int i = 0; i < childCount; i++) { + int start = i * CHILD_WIDTH; + int end = start + CHILD_WIDTH; + var span = new SourceSpan(1, 1, start, 1, 1, end); + children.add(new IdCstNode.Terminal(idGen.next(), span, "Stmt", "x", NO_TRIVIA, NO_TRIVIA)); + } + var rootSpan = new SourceSpan(1, 1, 0, 1, 1, childCount * CHILD_WIDTH); + return new IdCstNode.NonTerminal(idGen.next(), rootSpan, "Body", List.copyOf(children), NO_TRIVIA, NO_TRIVIA); + } + + /** + * Build an {@link OffsetDecoupledNode} tree with the same shape, plus + * a populated {@link SpanIndex}. + */ + static OffsetDecoupledTree buildOffsetDecoupledTree(int childCount, IdGenerator idGen) { + var spans = new SpanIndex(childCount * 2); + var children = new ArrayList(childCount); + for (int i = 0; i < childCount; i++) { + int start = i * CHILD_WIDTH; + int end = start + CHILD_WIDTH; + long id = idGen.next(); + children.add(new OffsetDecoupledNode.Terminal(id, "Stmt", "x", NO_TRIVIA, NO_TRIVIA)); + spans.put(id, start, end); + } + long rootId = idGen.next(); + var root = new OffsetDecoupledNode.NonTerminal(rootId, "Body", List.copyOf(children), NO_TRIVIA, NO_TRIVIA); + spans.put(rootId, 0, childCount * CHILD_WIDTH); + return new OffsetDecoupledTree(root, spans); + } + + record OffsetDecoupledTree(OffsetDecoupledNode root, SpanIndex spans) {} +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java index 76c68a5..bd52d2b 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java @@ -159,6 +159,19 @@ public LongLongMap copy() { return copy; } + @Override + public void forEachEntry(EntryVisitor visitor) { + for (int i = 0; i < state.length; i++) { + if (state[i] == OCCUPIED) { + long oldValue = values[i]; + long newValue = visitor.visit(keys[i], oldValue); + if (newValue != oldValue) { + values[i] = newValue; + } + } + } + } + private int slotFor(long key) { return Long.hashCode(key) & mask; } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java index 936a955..309515f 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java @@ -61,4 +61,31 @@ public sealed interface LongLongMap permits LinearProbingLongLongMap { * future {@code TreeSplicer} work. */ LongLongMap copy(); + + /** + * Visit every live (occupied, non-tombstone) entry exactly once. Iteration + * order is implementation-defined and not stable across mutations. Callers + * MUST NOT mutate the map (put/remove) during iteration; the visitor may + * read other entries via {@link #get}/{@link #containsKey} but writes are + * unsupported and may corrupt the table. + * + *

Added in Phase 1.0/1.1 to support {@link SpanIndex#shift}, which needs + * to rewrite values in place when used with the packed-long encoding. This + * is additive: existing call sites are unaffected. + */ + void forEachEntry(EntryVisitor visitor); + + /** Visitor for {@link #forEachEntry}. */ + @FunctionalInterface + interface EntryVisitor { + /** + * Receive one entry. Return the new value to write into the slot, or + * the same value to leave the entry untouched. Implementations may + * fast-path "no change" by reference comparison on the boxed value — + * but for the {@code long} signature here, the fast path is a numeric + * equality test and the implementation rewrites unconditionally + * (cheap on a primitive {@code long[]}). + */ + long visit(long key, long value); + } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java new file mode 100644 index 0000000..a234ac5 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java @@ -0,0 +1,136 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; +import java.util.Objects; + +/** + * Path-A spike CST node — carries a stable {@code long id} but does NOT + * carry a {@link org.pragmatica.peg.tree.SourceSpan}. Spans live externally + * in a {@link SpanIndex} keyed by id. + * + *

This is the design fork of the v0.5.0 architectural rework + * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 + the path-A blocker + * resolution). Mirrors {@link IdCstNode} structurally but with the + * {@code SourceSpan span} component removed from every variant. Callers + * resolve offsets via {@code spanIndex.startOffset(node.id())} instead of + * {@code node.span().startOffset()}. + * + *

Why decouple offsets

+ * + *

{@code TreeSplicer.spliceAndShift} (production) deep-copies every sibling + * subtree right of an edit because the offset shift requires rewriting the + * embedded {@code SourceSpan} on every record. With offsets external, the + * splicer reference-shares both flanking subtrees and the shift becomes a + * single in-place walk over the {@link SpanIndex} primitive array — see + * {@link OffsetDecoupledSplicer}. + * + *

Equality contract

+ * + *

Per spec §7 R1, IDs are metadata and must not participate in identity. + * Each variant's {@code equals}/{@code hashCode} compares structural fields + * only and excludes {@code id}. Spans are also excluded from equality (they + * are not part of the record), which deviates from {@link IdCstNode}; this + * is intentional — equality on path-A nodes is purely structural, and a span + * comparator (via {@link SpanIndex}) must be applied explicitly when needed. + * + *

Trivia note

+ * + *

Production {@link Trivia} still carries {@link org.pragmatica.peg.tree.SourceSpan} + * components. For this prove-out we leave Trivia as-is — decoupling Trivia + * spans from records is a separate scope (a future {@code TriviaIndex}). + * The bench operates on trees with empty trivia lists, so this does not + * affect the perf comparison. + * + *

Skipped variant

+ * + *

{@code Error} is omitted: the bench fixture is synthetic and the + * production calculator/Java grammars used by the spike do not produce + * Error nodes. Adding it is mechanical if the prove-out goes GREEN and + * the design is migrated. + * + *

This sandbox class is not referenced by {@code peglib-core} and will + * be promoted, reshaped, or deleted at the Phase 1.0/1.1 GO/NO-GO gate. + * + * @since 0.5.0 + */ +public sealed interface OffsetDecoupledNode { + /** Stable identifier within the owning Session's lineage. */ + long id(); + + /** Rule name that produced this node. */ + String rule(); + + /** Trivia preceding this node (production type — still carries spans). */ + List leadingTrivia(); + + /** Trivia following this node (production type — still carries spans). */ + List trailingTrivia(); + + /** Terminal node — leaf that matched literal text. */ + record Terminal(long id, + String rule, + String text, + List leadingTrivia, + List trailingTrivia) implements OffsetDecoupledNode { + @Override + public boolean equals(Object other) { + return other instanceof Terminal that + && Objects.equals(rule, that.rule) + && Objects.equals(text, that.text) + && Objects.equals(leadingTrivia, that.leadingTrivia) + && Objects.equals(trailingTrivia, that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(Terminal.class, rule, text, leadingTrivia, trailingTrivia); + } + } + + /** Non-terminal node — interior node with children. */ + record NonTerminal(long id, + String rule, + List children, + List leadingTrivia, + List trailingTrivia) implements OffsetDecoupledNode { + @Override + public boolean equals(Object other) { + return other instanceof NonTerminal that + && Objects.equals(rule, that.rule) + && Objects.equals(children, that.children) + && Objects.equals(leadingTrivia, that.leadingTrivia) + && Objects.equals(trailingTrivia, that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(NonTerminal.class, rule, children, leadingTrivia, trailingTrivia); + } + } + + /** + * Token node — result of the token boundary operator {@code < >}. + * Captures the matched text as a single unit. + */ + record Token(long id, + String rule, + String text, + List leadingTrivia, + List trailingTrivia) implements OffsetDecoupledNode { + @Override + public boolean equals(Object other) { + return other instanceof Token that + && Objects.equals(rule, that.rule) + && Objects.equals(text, that.text) + && Objects.equals(leadingTrivia, that.leadingTrivia) + && Objects.equals(trailingTrivia, that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(Token.class, rule, text, leadingTrivia, trailingTrivia); + } + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java new file mode 100644 index 0000000..7017a4d --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java @@ -0,0 +1,142 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.pragmatica.lang.Option; + +import java.util.ArrayList; +import java.util.List; + +/** + * Path-A counterpart of {@link IdNodeIndex} — keyed parent index for + * {@link OffsetDecoupledNode} trees. + * + *

Identical algorithm to {@link IdNodeIndex} (build, applyIncremental). + * The only delta is the node type — we cannot reuse {@code IdNodeIndex} + * directly because {@link OffsetDecoupledNode} and {@link IdCstNode} are + * disjoint sealed hierarchies with no common parent. Java sealed-interface + * constraints make a structural-typing approach more invasive than a clone. + * + *

Mutate-in-place / invalidate-on-incremental semantics mirror + * {@link IdNodeIndex}: callers MUST use the returned instance after + * {@link #applyIncremental} and discard the receiver. + * + *

This sandbox class is not referenced by {@code peglib-core} and will + * be promoted, reshaped, or deleted at the Phase 1.0/1.1 GO/NO-GO gate. + * + * @since 0.5.0 + */ +public final class OffsetDecoupledNodeIndex { + private final OffsetDecoupledNode root; + private final LongLongMap parents; + + private OffsetDecoupledNodeIndex(OffsetDecoupledNode root, LongLongMap parents) { + this.root = root; + this.parents = parents; + } + + /** + * Build a fresh index over {@code root}. {@code O(N)} in the descendant + * count. Pre-sizes the backing map to avoid resize churn. + */ + public static OffsetDecoupledNodeIndex build(OffsetDecoupledNode root) { + int expectedSize = countDescendants(root); + var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); + indexChildren(root, parents); + return new OffsetDecoupledNodeIndex(root, parents); + } + + /** + * Splice-and-shift index update. Mirrors {@link IdNodeIndex#applyIncremental}. + */ + public OffsetDecoupledNodeIndex applyIncremental(OffsetDecoupledNode newRoot, + List oldPath, + List newPath) { + if (oldPath == null || oldPath.isEmpty()) { + throw new IllegalArgumentException("oldPath must contain at least the old root"); + } + if (newPath == null || newPath.isEmpty()) { + throw new IllegalArgumentException("newPath must contain at least the new root"); + } + var oldPivot = oldPath.get(oldPath.size() - 1); + var newPivot = newPath.get(newPath.size() - 1); + + // Step 1a — Remove every old-path node's up-entry. + for (var oldNode : oldPath) { + parents.remove(oldNode.id()); + } + // Step 1b — Remove the old pivot's descendants. + var oldPivotDescendants = new ArrayList(); + flattenDescendantsInto(oldPivot, oldPivotDescendants); + for (var d : oldPivotDescendants) { + parents.remove(d.id()); + } + + // Step 2 — Insert new pivot's subtree internal links. + indexChildren(newPivot, parents); + if (newPath.size() >= 2) { + var newPivotParent = newPath.get(newPath.size() - 2); + parents.put(newPivot.id(), newPivotParent.id()); + } + + // Step 3 — Walk new ancestor chain top-down, set parent links for + // ALL direct children of each ancestor. + for (int i = 0; i < newPath.size() - 1; i++) { + var ancestor = newPath.get(i); + if (ancestor instanceof OffsetDecoupledNode.NonTerminal nt) { + for (var child : nt.children()) { + parents.put(child.id(), ancestor.id()); + } + } + } + + return new OffsetDecoupledNodeIndex(newRoot, parents); + } + + public OffsetDecoupledNode root() { + return root; + } + + public Option parentIdOf(long childId) { + if (!parents.containsKey(childId)) { + return Option.none(); + } + return Option.some(parents.get(childId)); + } + + public boolean contains(long id) { + return parents.containsKey(id); + } + + public int size() { + return parents.size(); + } + + // -- Helpers -- + + private static int countDescendants(OffsetDecoupledNode node) { + int count = 0; + if (node instanceof OffsetDecoupledNode.NonTerminal nt) { + for (var child : nt.children()) { + count += 1 + countDescendants(child); + } + } + return count; + } + + private static void indexChildren(OffsetDecoupledNode node, LongLongMap parents) { + if (node instanceof OffsetDecoupledNode.NonTerminal nt) { + for (var child : nt.children()) { + parents.put(child.id(), nt.id()); + indexChildren(child, parents); + } + } + } + + private static void flattenDescendantsInto(OffsetDecoupledNode node, List out) { + if (node instanceof OffsetDecoupledNode.NonTerminal nt) { + for (var child : nt.children()) { + out.add(child); + flattenDescendantsInto(child, out); + } + } + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java new file mode 100644 index 0000000..7ad046a --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java @@ -0,0 +1,198 @@ +package org.pragmatica.peg.incremental.experimental; + +import java.util.ArrayList; +import java.util.List; + +/** + * Path-A splicer for the v0.5.0 architectural rework prove-out + * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 + the path-A + * blocker resolution). + * + *

Operates on {@link OffsetDecoupledNode} trees with offsets stored in + * a separate {@link SpanIndex}. Mirrors the semantics of production + * {@link org.pragmatica.peg.incremental.internal.TreeSplicer#spliceAndShift} + * — replace a pivot subtree, shift offsets right of the edit by {@code delta} + * — but with one critical difference: + * + *

    + *
  • Production: siblings right of the edit are deep-copied + * via {@code shiftAll} because every {@code CstNode} record carries an + * embedded {@code SourceSpan} that must be rewritten. + *
  • Path A (this class): siblings right of the edit are + * reference-shared. The offset shift is a single in-place walk over the + * primitive {@code long[]} backing the {@link SpanIndex}. + *
+ * + *

This is the central correctness claim under test by + * {@code OffsetDecoupledSplicerTest#rightOfEditSiblingsPreserveIdentity}: even + * for siblings whose offsets need shifting, the record references survive + * the splice. The shift happens on the SpanIndex side. + * + *

Algorithm

+ * + *
    + *
  1. Copy the receiver's {@link SpanIndex} so the receiver remains valid + * (bench fairness — mirrors the snapshot semantics production v0.5.0 + * will need). + *
  2. Apply {@link SpanIndex#shift}{@code (editEnd, delta)} on the new + * index — rewrites every entry whose {@code startOffset >= editEnd}. + * Caller is responsible for inserting {@link SpanIndex} entries for + * {@code newPivot}'s subtree BEFORE calling {@code splice}; the + * splicer only registers ancestor spans (next step). + *
  3. Walk {@code oldPath} from leaf to root, building one new + * {@link OffsetDecoupledNode.NonTerminal} per ancestor with the spliced + * child swapped and every other child slot reference-shared. For each + * new ancestor, insert its span into the new {@link SpanIndex}: the + * start equals the old ancestor's start (unchanged — the ancestor + * began before the edit by the enclosing-node invariant) and the end + * equals the old ancestor's end shifted by {@code delta} when the old + * end was {@code >= editEnd}, else unchanged. This mirrors + * {@code TreeSplicer.rebuildNonTerminal}. + *
+ * + *

Cost

+ * + *

{@code O(splicedSize + depth + spanIndex.size())} — the depth term is + * the ancestor rebuild, the spliced-size term is the new-pivot span + * registration (caller's responsibility, not in this class), and the + * {@code spanIndex.size()} term is the eager shift walk. The dominant + * cost shifts from {@code O(N)} record allocations (production) to + * {@code O(N)} primitive-long writes (path A) — same big-O class but a + * radically smaller constant factor and no GC pressure. + * + *

This sandbox class is not referenced by {@code peglib-core} and will + * be promoted, reshaped, or deleted at the Phase 1.0/1.1 GO/NO-GO gate. + * + * @since 0.5.0 + */ +public final class OffsetDecoupledSplicer { + + /** + * Result of a splice — the new root, the new ancestor path + * ({@code root → newPivot}, inclusive), and the new (independent) + * span index. + */ + public record Result(OffsetDecoupledNode newRoot, + List newPath, + SpanIndex newSpans) {} + + private final IdGenerator idGen; + + public OffsetDecoupledSplicer(IdGenerator idGen) { + this.idGen = idGen; + } + + /** + * Splice {@code newPivot} into the tree at {@code oldPath}'s terminus and + * shift every span whose {@code startOffset >= editEnd} by {@code delta}. + * + *

Caller MUST register {@code newPivot}'s subtree spans into + * {@code oldSpans} (or in a way that survives the {@link SpanIndex#copy} + * below) BEFORE calling this method. The splicer registers ancestor + * spans only. + * + * @param oldSpans pre-edit span index. Copied; the receiver is left valid. + * @param oldPath {@code root → oldPivot} chain in the pre-edit tree + * (size ≥ 1, last element is the pivot to replace). + * @param newPivot the replacement subtree. + * @param editEnd the offset at or after which spans should shift. + * @param delta the offset delta to apply. + */ + public Result splice(SpanIndex oldSpans, + List oldPath, + OffsetDecoupledNode newPivot, + int editEnd, + int delta) { + if (oldPath == null || oldPath.isEmpty()) { + throw new IllegalArgumentException("oldPath must contain at least the old pivot"); + } + if (newPivot == null) { + throw new IllegalArgumentException("newPivot must not be null"); + } + if (oldSpans == null) { + throw new IllegalArgumentException("oldSpans must not be null"); + } + + // Step 1 — Independent span index for the post-edit tree. + var newSpans = oldSpans.copy(); + + // Step 2 — Eager shift on the new span index. + newSpans.shift(editEnd, delta); + + // Pivot IS the root: no ancestor rebuild. The pivot's spans must + // already be registered in oldSpans by the caller. + if (oldPath.size() == 1) { + return new Result(newPivot, List.of(newPivot), newSpans); + } + + // Step 3 — Rebuild the ancestor chain leaf-to-root, splicing + // newPivot in and reference-sharing every sibling slot. + var reversedNewPath = new ArrayList(oldPath.size()); + reversedNewPath.add(newPivot); + + OffsetDecoupledNode current = newPivot; + OffsetDecoupledNode oldChild = oldPath.get(oldPath.size() - 1); + + for (int i = oldPath.size() - 2; i >= 0; i--) { + var oldAncestor = oldPath.get(i); + if (!(oldAncestor instanceof OffsetDecoupledNode.NonTerminal nt)) { + throw new IllegalStateException( + "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); + } + var children = nt.children(); + int slot = indexOfByIdentity(children, oldChild); + if (slot < 0) { + throw new IllegalStateException( + "splice path broken at depth " + i + + ": child " + oldChild + " not found in parent's children list"); + } + + // Reference-share every sibling slot — both LEFT AND RIGHT of + // the edit. This is the path-A invariant: siblings right of the + // edit don't need rebuilding because their offsets live in + // newSpans, which we already shifted. + var newChildren = new ArrayList(children.size()); + for (int k = 0; k < children.size(); k++) { + newChildren.add(k == slot ? current : children.get(k)); + } + + long newAncestorId = idGen.next(); + var newAncestor = new OffsetDecoupledNode.NonTerminal( + newAncestorId, + nt.rule(), + List.copyOf(newChildren), + nt.leadingTrivia(), + nt.trailingTrivia()); + + // Compute the new ancestor's span: + // start = old start (unchanged — ancestor began before the edit) + // end = old end + delta if old end >= editEnd, else old end + // Mirrors TreeSplicer.rebuildNonTerminal. + int oldStart = oldSpans.startOffset(oldAncestor.id()); + int oldEnd = oldSpans.endOffset(oldAncestor.id()); + int newEnd = oldEnd >= editEnd ? oldEnd + delta : oldEnd; + newSpans.put(newAncestorId, oldStart, newEnd); + + reversedNewPath.add(newAncestor); + current = newAncestor; + oldChild = oldAncestor; + } + + // Reverse to get root → newPivot order. + var newPath = new ArrayList(reversedNewPath.size()); + for (int i = reversedNewPath.size() - 1; i >= 0; i--) { + newPath.add(reversedNewPath.get(i)); + } + return new Result(current, List.copyOf(newPath), newSpans); + } + + /** Linear scan for a child by record identity ({@code ==}). */ + private static int indexOfByIdentity(List children, OffsetDecoupledNode target) { + for (int i = 0; i < children.size(); i++) { + if (children.get(i) == target) { + return i; + } + } + return -1; + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java new file mode 100644 index 0000000..2ca0c94 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java @@ -0,0 +1,143 @@ +package org.pragmatica.peg.incremental.experimental; + +/** + * External {@code id → (startOffset, endOffset)} index for the path-A spike of + * the v0.5.0 incremental-native rework + * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 + the path-A blocker + * resolution in HANDOVER §10/11). + * + *

Why this exists. Production {@link org.pragmatica.peg.tree.CstNode} + * carries a {@link org.pragmatica.peg.tree.SourceSpan} as a record component. + * Mid-buffer edits force {@code TreeSplicer.spliceAndShift} to deep-copy every + * sibling subtree right of the edit (~half the tree on a typical interior + * keystroke) just to rewrite offsets. Path A decouples offsets from the + * record, storing them in this {@code SpanIndex} keyed by the stable + * {@code long id} carried on each {@link OffsetDecoupledNode}. Edits become + * a single in-place walk over a primitive {@code long[]} (this class) instead + * of a recursive record rebuild. + * + *

Storage

+ * + *

Backed by a {@link LinearProbingLongLongMap} keyed by node id. Values pack + * {@code (startOffset, endOffset)} into a single {@code long}: high 32 bits + * are {@code startOffset}, low 32 bits are {@code endOffset & 0xFFFFFFFFL}. + * This reuses the Phase 0a primitive-long-keyed map verbatim and avoids a + * second parallel-arrays implementation. + * + *

The packed encoding constrains offsets to fit in a signed 32-bit int, + * which matches {@link org.pragmatica.peg.tree.SourceSpan#startOffset()} + * production semantics; offsets up to ~2 GiB are representable. + * + *

Thread-safety

+ * + *

Not thread-safe; mirrors {@link LongLongMap}. + * + * @since 0.5.0 + */ +public final class SpanIndex { + private final LongLongMap map; + + public SpanIndex(int initialCapacity) { + this.map = new LinearProbingLongLongMap(initialCapacity); + } + + private SpanIndex(LongLongMap map) { + this.map = map; + } + + /** Insert or overwrite the {@code (startOffset, endOffset)} for {@code nodeId}. */ + public void put(long nodeId, int startOffset, int endOffset) { + map.put(nodeId, pack(startOffset, endOffset)); + } + + /** + * Start offset for {@code nodeId}. + * + * @throws IllegalStateException when {@code nodeId} is absent. + */ + public int startOffset(long nodeId) { + long packed = map.get(nodeId); + if (packed == LongLongMap.MISSING && !map.containsKey(nodeId)) { + throw new IllegalStateException("nodeId not present in SpanIndex: " + nodeId); + } + return unpackStart(packed); + } + + /** + * End offset for {@code nodeId}. + * + * @throws IllegalStateException when {@code nodeId} is absent. + */ + public int endOffset(long nodeId) { + long packed = map.get(nodeId); + if (packed == LongLongMap.MISSING && !map.containsKey(nodeId)) { + throw new IllegalStateException("nodeId not present in SpanIndex: " + nodeId); + } + return unpackEnd(packed); + } + + /** {@code true} iff an entry exists for {@code nodeId}. */ + public boolean contains(long nodeId) { + return map.containsKey(nodeId); + } + + /** Number of entries. */ + public int size() { + return map.size(); + } + + /** + * Shift every entry whose {@code startOffset >= afterOffset} by + * {@code delta}: both {@code startOffset} and {@code endOffset} move by + * the same amount (a wholesale move, not a resize). Entries whose + * {@code startOffset < afterOffset} but whose {@code endOffset >= + * afterOffset} (i.e., spanning the edit) are NOT touched here — those + * belong to ancestors on the splice path, which {@link OffsetDecoupledSplicer} + * rewrites explicitly with their own end-extension logic. + * + *

Implemented eagerly: walks the underlying primitive {@code long[]} + * via {@link LongLongMap#forEachEntry}. Cost is {@code O(size)} but each + * slot touch is one packed-long compare and one packed-long write — the + * tightest possible inner loop on the JVM. Compared to record-rebuilding + * the same nodes (allocation + List.copyOf + per-record GC pressure) + * this is the whole point of path A. + * + *

{@code delta == 0} is a no-op (skip the walk). + * + *

Future work (out of scope for the spike): a range-tree or lazy-log + * encoding would lower cost to {@code O(log N)} per shift, at the cost of + * read complexity. The eager walk is the simplest correct prove-out shape. + */ + public void shift(int afterOffset, int delta) { + if (delta == 0) { + return; + } + map.forEachEntry((nodeId, packed) -> { + int start = unpackStart(packed); + if (start >= afterOffset) { + int end = unpackEnd(packed); + return pack(start + delta, end + delta); + } + return packed; + }); + } + + /** Independent deep copy; subsequent mutations on either side are isolated. */ + public SpanIndex copy() { + return new SpanIndex(map.copy()); + } + + // --- packing helpers --- + + private static long pack(int start, int end) { + return ((long) start << 32) | (end & 0xFFFFFFFFL); + } + + private static int unpackStart(long packed) { + return (int) (packed >> 32); + } + + private static int unpackEnd(long packed) { + return (int) packed; + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java new file mode 100644 index 0000000..6a29c2d --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java @@ -0,0 +1,94 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Minimal contract tests for {@link OffsetDecoupledNode}. The path-A spike + * design is exhaustively exercised by {@link OffsetDecoupledSplicerTest}; + * this file pins the equality contract and sealed-switch exhaustiveness. + */ +final class OffsetDecoupledNodeTest { + private static final List NO_TRIVIA = List.of(); + + @Test + @DisplayName("Terminal: constructs and exposes its components") + void terminal_construct() { + var t = new OffsetDecoupledNode.Terminal(1L, "Lit", "x", NO_TRIVIA, NO_TRIVIA); + assertThat(t.id()).isEqualTo(1L); + assertThat(t.rule()).isEqualTo("Lit"); + assertThat(t.text()).isEqualTo("x"); + assertThat(t.leadingTrivia()).isEmpty(); + assertThat(t.trailingTrivia()).isEmpty(); + } + + @Test + @DisplayName("NonTerminal: constructs with children") + void nonterminal_construct() { + var c1 = new OffsetDecoupledNode.Terminal(1L, "T", "a", NO_TRIVIA, NO_TRIVIA); + var c2 = new OffsetDecoupledNode.Terminal(2L, "T", "b", NO_TRIVIA, NO_TRIVIA); + var nt = new OffsetDecoupledNode.NonTerminal(3L, "Pair", List.of(c1, c2), NO_TRIVIA, NO_TRIVIA); + + assertThat(nt.id()).isEqualTo(3L); + assertThat(nt.rule()).isEqualTo("Pair"); + assertThat(nt.children()).hasSize(2); + assertThat(nt.children().get(0)).isSameAs(c1); + assertThat(nt.children().get(1)).isSameAs(c2); + } + + @Test + @DisplayName("Token: constructs and exposes text") + void token_construct() { + var tok = new OffsetDecoupledNode.Token(1L, "Number", "42", NO_TRIVIA, NO_TRIVIA); + assertThat(tok.id()).isEqualTo(1L); + assertThat(tok.rule()).isEqualTo("Number"); + assertThat(tok.text()).isEqualTo("42"); + } + + @Test + @DisplayName("Equality excludes id (R1: ids are metadata)") + void equality_excludes_id() { + var a = new OffsetDecoupledNode.Terminal(1L, "T", "x", NO_TRIVIA, NO_TRIVIA); + var b = new OffsetDecoupledNode.Terminal(2L, "T", "x", NO_TRIVIA, NO_TRIVIA); + var c = new OffsetDecoupledNode.Terminal(3L, "T", "y", NO_TRIVIA, NO_TRIVIA); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + assertThat(a).isNotEqualTo(c); + } + + @Test + @DisplayName("Different variants are never equal even if structural fields match") + void variants_never_cross_equal() { + var term = new OffsetDecoupledNode.Terminal(1L, "T", "x", NO_TRIVIA, NO_TRIVIA); + var tok = new OffsetDecoupledNode.Token(1L, "T", "x", NO_TRIVIA, NO_TRIVIA); + + assertThat(term).isNotEqualTo(tok); + assertThat(tok).isNotEqualTo(term); + } + + @Test + @DisplayName("Sealed switch is exhaustive over all three variants") + void sealed_switch_exhaustive() { + OffsetDecoupledNode term = new OffsetDecoupledNode.Terminal(1L, "T", "x", NO_TRIVIA, NO_TRIVIA); + OffsetDecoupledNode tok = new OffsetDecoupledNode.Token(2L, "T", "y", NO_TRIVIA, NO_TRIVIA); + OffsetDecoupledNode nt = new OffsetDecoupledNode.NonTerminal(3L, "Pair", List.of(), NO_TRIVIA, NO_TRIVIA); + + assertThat(label(term)).isEqualTo("Terminal"); + assertThat(label(tok)).isEqualTo("Token"); + assertThat(label(nt)).isEqualTo("NonTerminal"); + } + + private static String label(OffsetDecoupledNode node) { + return switch (node) { + case OffsetDecoupledNode.Terminal t -> "Terminal"; + case OffsetDecoupledNode.Token t -> "Token"; + case OffsetDecoupledNode.NonTerminal n -> "NonTerminal"; + }; + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java new file mode 100644 index 0000000..589825c --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java @@ -0,0 +1,279 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Phase 1.0/1.1 — GO/NO-GO gate for the path-A correctness invariant. + * + *

Central claim under test: after splice, every sibling + * of every node on the splice path satisfies reference equality ({@code ==}) + * with its pre-edit record — including siblings to the RIGHT of the edit + * whose offsets must shift. The shift happens on the {@link SpanIndex} + * side, not by record rebuild. + * + *

Without this invariant, path A doesn't pay off: rebuilding right-of-edit + * siblings is exactly what production {@code TreeSplicer.spliceAndShift} + * already does, and the bench would just re-measure the existing 0.4.3 cost + * with extra primitive-array overhead. + */ +final class OffsetDecoupledSplicerTest { + private static final List NO_TRIVIA = List.of(); + + private static OffsetDecoupledNode.Terminal terminal(IdGenerator gen, String text) { + return new OffsetDecoupledNode.Terminal(gen.next(), "T", text, NO_TRIVIA, NO_TRIVIA); + } + + private static OffsetDecoupledNode.NonTerminal nonTerminal( + IdGenerator gen, String rule, List children) { + return new OffsetDecoupledNode.NonTerminal(gen.next(), rule, children, NO_TRIVIA, NO_TRIVIA); + } + + @Nested + @DisplayName("identity preservation") + class IdentityTests { + + @Test + @DisplayName("Right-of-edit siblings preserve identity AND have shifted spans") + void rightOfEditSiblingsPreserveIdentity() { + // Tree: Root[A, target, C] + // A spans [0, 5) + // target spans [5, 10) + // C spans [10, 20) <-- right of edit + // Edit: replace target with newPivot ([5, 13)) — delta = +3, editEnd = 10. + // Expectation: + // - newRoot.children().get(0) == A (left, identity preserved) + // - newRoot.children().get(2) == C (RIGHT, identity preserved — path A claim) + // - newSpans.startOffset(C.id()) == 13 (10 + 3) + // - newSpans.endOffset(C.id()) == 23 (20 + 3) + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "A"); + var target = terminal(gen, "target"); + var c = terminal(gen, "C"); + var root = nonTerminal(gen, "Root", List.of(a, target, c)); + + var oldSpans = new SpanIndex(16); + oldSpans.put(a.id(), 0, 5); + oldSpans.put(target.id(), 5, 10); + oldSpans.put(c.id(), 10, 20); + oldSpans.put(root.id(), 0, 20); + + var newPivot = terminal(gen, "newTarget"); + // Caller registers newPivot's span before splicing. + oldSpans.put(newPivot.id(), 5, 13); + + var splicer = new OffsetDecoupledSplicer(gen); + var result = splicer.splice(oldSpans, List.of(root, target), newPivot, /*editEnd=*/10, /*delta=*/3); + + // Root has fresh ID — different record. + var newRoot = (OffsetDecoupledNode.NonTerminal) result.newRoot(); + assertThat(newRoot.id()).isNotEqualTo(root.id()); + assertThat(newRoot.children()).hasSize(3); + + // Identity preservation — left and RIGHT. + assertThat(newRoot.children().get(0)).isSameAs(a); + assertThat(newRoot.children().get(1)).isSameAs(newPivot); + assertThat(newRoot.children().get(2)).isSameAs(c); // *** THE PATH-A CLAIM *** + + // Spans for surviving siblings shifted on the SpanIndex side. + var newSpans = result.newSpans(); + assertThat(newSpans.startOffset(a.id())).isEqualTo(0); // left, untouched + assertThat(newSpans.endOffset(a.id())).isEqualTo(5); + assertThat(newSpans.startOffset(c.id())).isEqualTo(13); // RIGHT, shifted + assertThat(newSpans.endOffset(c.id())).isEqualTo(23); + + // newPivot span: caller-registered, NOT in shift range (start=5 < editEnd=10). + assertThat(newSpans.startOffset(newPivot.id())).isEqualTo(5); + assertThat(newSpans.endOffset(newPivot.id())).isEqualTo(13); + + // Ancestor span: start unchanged, end extended by delta. + assertThat(newSpans.startOffset(newRoot.id())).isEqualTo(0); + assertThat(newSpans.endOffset(newRoot.id())).isEqualTo(23); + } + + @Test + @DisplayName("Old SpanIndex receiver remains valid (independent copy)") + void receiver_independence() { + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "A"); + var target = terminal(gen, "target"); + var root = nonTerminal(gen, "Root", List.of(a, target)); + + var oldSpans = new SpanIndex(8); + oldSpans.put(a.id(), 0, 5); + oldSpans.put(target.id(), 5, 10); + oldSpans.put(root.id(), 0, 10); + + var newPivot = terminal(gen, "newTarget"); + oldSpans.put(newPivot.id(), 5, 12); + + var splicer = new OffsetDecoupledSplicer(gen); + splicer.splice(oldSpans, List.of(root, target), newPivot, 10, 2); + + // Receiver still reports pre-edit offsets. + assertThat(oldSpans.startOffset(target.id())).isEqualTo(5); + assertThat(oldSpans.endOffset(target.id())).isEqualTo(10); + } + } + + @Nested + @DisplayName("edge cases") + class EdgeCases { + + @Test + @DisplayName("Pivot is root → returns newPivot, no ancestor rebuild, but shift still applied") + void pivot_as_root() { + var gen = new IdGenerator.PerSessionCounter(); + var oldRoot = terminal(gen, "old"); + + var oldSpans = new SpanIndex(4); + oldSpans.put(oldRoot.id(), 0, 5); + + var newPivot = terminal(gen, "new"); + oldSpans.put(newPivot.id(), 0, 7); + + var splicer = new OffsetDecoupledSplicer(gen); + var result = splicer.splice(oldSpans, List.of(oldRoot), newPivot, 5, 2); + + assertThat(result.newRoot()).isSameAs(newPivot); + assertThat(result.newPath()).hasSize(1); + assertThat(result.newPath().get(0)).isSameAs(newPivot); + // Pivot's own registered span is unchanged (start < editEnd). + assertThat(result.newSpans().startOffset(newPivot.id())).isEqualTo(0); + assertThat(result.newSpans().endOffset(newPivot.id())).isEqualTo(7); + } + + @Test + @DisplayName("delta = 0 → spans unchanged, splice still rebuilds path with fresh ids") + void zero_delta_path() { + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "A"); + var target = terminal(gen, "target"); + var c = terminal(gen, "C"); + var root = nonTerminal(gen, "Root", List.of(a, target, c)); + + var oldSpans = new SpanIndex(8); + oldSpans.put(a.id(), 0, 5); + oldSpans.put(target.id(), 5, 10); + oldSpans.put(c.id(), 10, 20); + oldSpans.put(root.id(), 0, 20); + + // Replace target with same-length newPivot. + var newPivot = terminal(gen, "same"); + oldSpans.put(newPivot.id(), 5, 10); + + var splicer = new OffsetDecoupledSplicer(gen); + var result = splicer.splice(oldSpans, List.of(root, target), newPivot, 10, 0); + + var newRoot = (OffsetDecoupledNode.NonTerminal) result.newRoot(); + assertThat(newRoot.id()).isNotEqualTo(root.id()); // fresh ancestor id + assertThat(newRoot.children().get(0)).isSameAs(a); + assertThat(newRoot.children().get(2)).isSameAs(c); + + // Spans unchanged. + assertThat(result.newSpans().startOffset(c.id())).isEqualTo(10); + assertThat(result.newSpans().endOffset(c.id())).isEqualTo(20); + assertThat(result.newSpans().startOffset(newRoot.id())).isEqualTo(0); + assertThat(result.newSpans().endOffset(newRoot.id())).isEqualTo(20); + } + + @Test + @DisplayName("Multi-level splice: every ancestor on the path is rebuilt with fresh id") + void multi_level_splice() { + // Root[Mid[Inner[target], Sibling]] + // ^ leaf right of edit + // Splice replaces target. Verify Inner, Mid, Root all have fresh ids + // and that Sibling identity is preserved at the Mid level. + var gen = new IdGenerator.PerSessionCounter(); + var target = terminal(gen, "t"); + var inner = nonTerminal(gen, "Inner", List.of(target)); + var sibling = terminal(gen, "S"); + var mid = nonTerminal(gen, "Mid", List.of(inner, sibling)); + var root = nonTerminal(gen, "Root", List.of(mid)); + + var oldSpans = new SpanIndex(16); + oldSpans.put(target.id(), 0, 3); + oldSpans.put(inner.id(), 0, 3); + oldSpans.put(sibling.id(), 3, 5); + oldSpans.put(mid.id(), 0, 5); + oldSpans.put(root.id(), 0, 5); + + var newPivot = terminal(gen, "t'"); + oldSpans.put(newPivot.id(), 0, 4); + + var splicer = new OffsetDecoupledSplicer(gen); + var result = splicer.splice(oldSpans, List.of(root, mid, inner, target), newPivot, 3, 1); + + var newRoot = (OffsetDecoupledNode.NonTerminal) result.newRoot(); + assertThat(newRoot.id()).isNotEqualTo(root.id()); + + var newMid = (OffsetDecoupledNode.NonTerminal) newRoot.children().get(0); + assertThat(newMid.id()).isNotEqualTo(mid.id()); + assertThat(newMid.children().get(1)).isSameAs(sibling); // RIGHT of edit, identity preserved + + var newInner = (OffsetDecoupledNode.NonTerminal) newMid.children().get(0); + assertThat(newInner.id()).isNotEqualTo(inner.id()); + assertThat(newInner.children().get(0)).isSameAs(newPivot); + + // newPath: root → mid → inner → newPivot, all fresh except pivot. + assertThat(result.newPath()).hasSize(4); + assertThat(result.newPath().get(0)).isSameAs(newRoot); + assertThat(result.newPath().get(1)).isSameAs(newMid); + assertThat(result.newPath().get(2)).isSameAs(newInner); + assertThat(result.newPath().get(3)).isSameAs(newPivot); + + // Sibling span shifted (start 3 >= editEnd 3, so shifts). + assertThat(result.newSpans().startOffset(sibling.id())).isEqualTo(4); + assertThat(result.newSpans().endOffset(sibling.id())).isEqualTo(6); + // Mid and Root: ends extended; starts unchanged. + assertThat(result.newSpans().startOffset(newMid.id())).isEqualTo(0); + assertThat(result.newSpans().endOffset(newMid.id())).isEqualTo(6); + assertThat(result.newSpans().startOffset(newRoot.id())).isEqualTo(0); + assertThat(result.newSpans().endOffset(newRoot.id())).isEqualTo(6); + } + + @Test + @DisplayName("Empty oldPath rejected") + void empty_path_rejected() { + var gen = new IdGenerator.PerSessionCounter(); + var pivot = terminal(gen, "x"); + var splicer = new OffsetDecoupledSplicer(gen); + assertThatThrownBy(() -> splicer.splice(new SpanIndex(4), List.of(), pivot, 0, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Null newPivot rejected") + void null_pivot_rejected() { + var gen = new IdGenerator.PerSessionCounter(); + var root = terminal(gen, "x"); + var splicer = new OffsetDecoupledSplicer(gen); + assertThatThrownBy(() -> splicer.splice(new SpanIndex(4), List.of(root), null, 0, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Non-NonTerminal ancestor rejected (broken path)") + void non_nonterminal_ancestor_rejected() { + var gen = new IdGenerator.PerSessionCounter(); + // Construct a fake path where a Terminal pretends to be an ancestor. + var fakeAncestor = terminal(gen, "fake"); + var pivot = terminal(gen, "p"); + var oldSpans = new SpanIndex(4); + oldSpans.put(fakeAncestor.id(), 0, 1); + oldSpans.put(pivot.id(), 0, 1); + var splicer = new OffsetDecoupledSplicer(gen); + + assertThatThrownBy(() -> splicer.splice(oldSpans, List.of(fakeAncestor, pivot), pivot, 0, 0)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not a NonTerminal"); + } + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java new file mode 100644 index 0000000..b8ee1e5 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java @@ -0,0 +1,254 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link SpanIndex} — the path-A external offset store. + * + *

Validates the round-trip, the in-place shift semantics, copy isolation, + * and stress under random workloads. + */ +final class SpanIndexTest { + + @Nested + @DisplayName("put / accessors") + class PutTests { + + @Test + @DisplayName("Round-trip: put then read back start/end") + void put_round_trip() { + var index = new SpanIndex(8); + index.put(42L, 100, 200); + + assertThat(index.contains(42L)).isTrue(); + assertThat(index.startOffset(42L)).isEqualTo(100); + assertThat(index.endOffset(42L)).isEqualTo(200); + assertThat(index.size()).isEqualTo(1); + } + + @Test + @DisplayName("Multiple distinct ids: each round-trips independently") + void multiple_ids_independent() { + var index = new SpanIndex(8); + index.put(1L, 0, 10); + index.put(2L, 10, 20); + index.put(3L, 20, 30); + + assertThat(index.startOffset(1L)).isEqualTo(0); + assertThat(index.endOffset(1L)).isEqualTo(10); + assertThat(index.startOffset(2L)).isEqualTo(10); + assertThat(index.endOffset(2L)).isEqualTo(20); + assertThat(index.startOffset(3L)).isEqualTo(20); + assertThat(index.endOffset(3L)).isEqualTo(30); + assertThat(index.size()).isEqualTo(3); + } + + @Test + @DisplayName("Overwrite same id replaces the offsets, size unchanged") + void overwrite_same_id() { + var index = new SpanIndex(8); + index.put(1L, 0, 10); + index.put(1L, 100, 200); + + assertThat(index.startOffset(1L)).isEqualTo(100); + assertThat(index.endOffset(1L)).isEqualTo(200); + assertThat(index.size()).isEqualTo(1); + } + + @Test + @DisplayName("Absent id: contains() false, accessors throw IllegalStateException") + void absent_id_throws() { + var index = new SpanIndex(8); + assertThat(index.contains(99L)).isFalse(); + assertThatThrownBy(() -> index.startOffset(99L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("99"); + assertThatThrownBy(() -> index.endOffset(99L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("99"); + } + + @Test + @DisplayName("Zero offsets are valid (not confused with MISSING sentinel)") + void zero_offsets_valid() { + var index = new SpanIndex(8); + index.put(1L, 0, 0); + + assertThat(index.contains(1L)).isTrue(); + assertThat(index.startOffset(1L)).isEqualTo(0); + assertThat(index.endOffset(1L)).isEqualTo(0); + } + } + + @Nested + @DisplayName("shift") + class ShiftTests { + + @Test + @DisplayName("Updates entries with start >= afterOffset, leaves others untouched") + void shift_partition() { + var index = new SpanIndex(8); + index.put(1L, 0, 5); // entirely before edit + index.put(2L, 5, 10); // start equals edit boundary — INCLUDED + index.put(3L, 10, 20); // entirely after edit + + index.shift(5, 100); + + // id=1: untouched + assertThat(index.startOffset(1L)).isEqualTo(0); + assertThat(index.endOffset(1L)).isEqualTo(5); + // id=2: shifted + assertThat(index.startOffset(2L)).isEqualTo(105); + assertThat(index.endOffset(2L)).isEqualTo(110); + // id=3: shifted + assertThat(index.startOffset(3L)).isEqualTo(110); + assertThat(index.endOffset(3L)).isEqualTo(120); + } + + @Test + @DisplayName("delta=0 is a no-op (no walk performed; values unchanged)") + void shift_zero_delta_noop() { + var index = new SpanIndex(8); + index.put(1L, 0, 5); + index.put(2L, 5, 10); + + index.shift(0, 0); + + assertThat(index.startOffset(1L)).isEqualTo(0); + assertThat(index.endOffset(1L)).isEqualTo(5); + assertThat(index.startOffset(2L)).isEqualTo(5); + assertThat(index.endOffset(2L)).isEqualTo(10); + } + + @Test + @DisplayName("Negative delta shifts left (deletion case)") + void shift_negative_delta() { + var index = new SpanIndex(8); + index.put(1L, 0, 5); + index.put(2L, 10, 20); + + index.shift(10, -3); + + assertThat(index.startOffset(1L)).isEqualTo(0); + assertThat(index.endOffset(1L)).isEqualTo(5); + assertThat(index.startOffset(2L)).isEqualTo(7); + assertThat(index.endOffset(2L)).isEqualTo(17); + } + + @Test + @DisplayName("All entries before afterOffset → no entry touched") + void shift_all_before_no_op() { + var index = new SpanIndex(8); + index.put(1L, 0, 5); + index.put(2L, 5, 8); + + index.shift(100, 50); + + assertThat(index.startOffset(1L)).isEqualTo(0); + assertThat(index.endOffset(1L)).isEqualTo(5); + assertThat(index.startOffset(2L)).isEqualTo(5); + assertThat(index.endOffset(2L)).isEqualTo(8); + } + } + + @Nested + @DisplayName("copy") + class CopyTests { + + @Test + @DisplayName("Independent state: mutations on copy do not affect original") + void copy_independence_forward() { + var original = new SpanIndex(8); + original.put(1L, 0, 5); + original.put(2L, 5, 10); + + var copy = original.copy(); + copy.put(3L, 10, 20); + copy.put(1L, 999, 1000); + copy.shift(0, 100); + + // Original unchanged + assertThat(original.size()).isEqualTo(2); + assertThat(original.startOffset(1L)).isEqualTo(0); + assertThat(original.endOffset(1L)).isEqualTo(5); + assertThat(original.startOffset(2L)).isEqualTo(5); + assertThat(original.endOffset(2L)).isEqualTo(10); + assertThat(original.contains(3L)).isFalse(); + } + + @Test + @DisplayName("Independent state: mutations on original do not affect copy") + void copy_independence_reverse() { + var original = new SpanIndex(8); + original.put(1L, 0, 5); + + var copy = original.copy(); + original.put(1L, 100, 200); + + // Copy retains the snapshot. + assertThat(copy.startOffset(1L)).isEqualTo(0); + assertThat(copy.endOffset(1L)).isEqualTo(5); + } + } + + @Nested + @DisplayName("stress") + class StressTests { + + @Test + @DisplayName("1000 distinct random ids: insert, shift, verify each readback") + void stress_thousand_entries() { + var rng = new Random(0xC0FFEEL); + var index = new SpanIndex(2048); + var oracle = new HashMap(); // id -> [start, end] + + // Insert 1000 distinct ids with random non-overlapping-ish offsets. + // (Overlap doesn't matter for the index — it's a flat map — but + // we want predictable shift partitioning.) + for (int i = 0; i < 1000; i++) { + long id; + do { + id = rng.nextLong(); + } while (oracle.containsKey(id)); + int start = i * 10; + int end = start + 5 + rng.nextInt(5); + index.put(id, start, end); + oracle.put(id, new int[]{start, end}); + } + assertThat(index.size()).isEqualTo(1000); + + // Verify all readbacks pre-shift. + for (Map.Entry e : oracle.entrySet()) { + assertThat(index.startOffset(e.getKey())).isEqualTo(e.getValue()[0]); + assertThat(index.endOffset(e.getKey())).isEqualTo(e.getValue()[1]); + } + + // Shift at offset 5000 (mid-buffer) by +1000. + int afterOffset = 5000; + int delta = 1000; + index.shift(afterOffset, delta); + for (Map.Entry e : oracle.entrySet()) { + int s = e.getValue()[0]; + if (s >= afterOffset) { + e.getValue()[0] = s + delta; + e.getValue()[1] = e.getValue()[1] + delta; + } + } + + // Verify all readbacks post-shift. + for (Map.Entry e : oracle.entrySet()) { + assertThat(index.startOffset(e.getKey())).isEqualTo(e.getValue()[0]); + assertThat(index.endOffset(e.getKey())).isEqualTo(e.getValue()[1]); + } + } + } +} From 8b27dd6a31f18b294eca394310ea8a93441fef66 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 06:58:55 +0200 Subject: [PATCH 09/46] =?UTF-8?q?feat:=20phase=201.1=20path=20D=20?= =?UTF-8?q?=E2=80=94=20stable-id=20ancestor=20preservation=20(96-604x=20sp?= =?UTF-8?q?eedup,=20GREEN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/bench-results/path-d-results.md | 89 ++++++ .../peg/incremental/bench/PathDBench.java | 182 ++++++++++++ .../experimental/StableIdNodeIndex.java | 246 +++++++++++++++ .../experimental/StableIdSplicer.java | 172 +++++++++++ .../experimental/StableIdNodeIndexTest.java | 279 ++++++++++++++++++ .../experimental/StableIdSplicerTest.java | 182 ++++++++++++ 6 files changed, 1150 insertions(+) create mode 100644 docs/bench-results/path-d-results.md create mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java diff --git a/docs/bench-results/path-d-results.md b/docs/bench-results/path-d-results.md new file mode 100644 index 0000000..83cdf79 --- /dev/null +++ b/docs/bench-results/path-d-results.md @@ -0,0 +1,89 @@ +# Path D — Stable-ID Ancestor Preservation Bench Results + +**Date:** 2026-05-07 +**Branch:** release-0.5.0 (post-Phase-1.0/1.1, sandbox additions only) +**JMH version:** 1.37 +**JVM:** OpenJDK 25.0.2 (Apple Silicon, Homebrew) + +## Configuration + +- **Bench class:** `peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java` +- **Tree shape:** identical to `MidBufferBench` — flat single-level `Root[child_0 … child_{N-1}]` (long Java method body, N statement nodes). +- **Edit:** replace child at index N/2 with a fresh single-Terminal pivot. (Span-decoupled; this bench measures *applyIncremental* cost only — splice work is pre-computed in `@Setup(Level.Trial)`.) +- **Mode:** `AverageTime`, microseconds/op +- **Run flags (reduced for spike):** `-i 3 -wi 2 -f 1` — class declares `(iterations=5, warmup=3, fork=2)` for full runs; the spike uses reduced flags to keep total bench time under ~80 seconds. +- **Per-invocation setup:** all three arms' indices are rebuilt via `IdNodeIndex.build(oldRoot)` / `StableIdNodeIndex.build(oldRoot)` on every `Level.Invocation` because `applyIncremental` mutates the receiver's parents map. Build cost is identical across arms; the bench measures the differential cost of `applyIncremental`. + +## Three arms compared + +| Arm | Splicer | applyIncremental | +|-----|---------|-------------------| +| **productionStyle** | `IdTreeSplicer` (fresh ancestor IDs) | `IdNodeIndex.applyIncremental` (full algorithm: oldPath removal + sibling rewire) | +| **stableIdNoOpt** | `StableIdSplicer` (stable ancestor IDs) | `IdNodeIndex.applyIncremental` (unchanged — measures whether splicer alone helps) | +| **pathD** | `StableIdSplicer` (stable ancestor IDs) | `StableIdNodeIndex.applyIncremental` (skips ancestor removal + sibling rewire) | + +The three-way comparison isolates which change matters: the splicer's ID strategy alone, or the index's optimized walk, or the combination. + +## Measurements + +| Tree size | productionStyle (μs) | stableIdNoOpt (μs) | pathD (μs) | pathD speedup vs production | +|----------:|---------------------:|-------------------:|-----------:|----------------------------:| +| 100 | 0.304 ± 0.019 | 0.286 ± 0.025 | 0.023 ± 0.004 | **13.2×** | +| 1000 | 2.697 ± 0.277 | 2.843 ± 1.112 | 0.028 ± 0.009 | **96.3×** | +| 10000 | 24.762 ± 3.278 | 23.298 ± 6.686 | 0.041 ± 0.176 | **604×** | + +Total bench wallclock: 73 seconds. + +## Perf gate + +**Criterion:** pathD ≥ 5× faster than productionStyle at tree size ≥ 1000. + +**Result: GREEN.** At 1000 nodes the speedup is 96.3× (~19× above the 5× gate). At 10000 nodes the speedup is 604× — and `pathD`'s absolute time (~0.04 μs) is *flat* relative to tree size, exactly as the O(δ) algorithm predicts. `productionStyle` scales linearly with N (0.304 → 2.697 → 24.762 μs at N = 100/1000/10000, ~9× per decade), confirming the original O(N) bottleneck identified in the Phase 1.0/1.1 RED report. + +## Microcount evidence + +From `StableIdNodeIndexTest.microcount_o_delta` on a flat 1000-node tree, single-Terminal-pivot splice: + +``` +[Path D] flat-tree(N=1000) incremental microcount: puts=1 removes=1 (vs full-rebuild N=1001) +``` + +- `parents.put` calls: **1** (wire newPivot → stable root id) +- `parents.remove` calls: **1** (kill oldPivot's up-pointer) +- Total map operations: **2** +- For comparison, `IdNodeIndex.applyIncremental` (Phase 0c) on the same shape would perform `~N` puts (one per direct child of the rebuilt root in step 3) plus `~oldPath.size()` + `oldPivotSize` removes. For N=1000 that's roughly `1000 + 2 = 1002` map operations — **501× more work** for the same logical edit. + +The 96-604× wall-clock speedup is consistent with that microcount ratio; the small headroom factor is HotSpot/TLAB overhead amortizing the constant-time work into measurement noise. + +## Why `stableIdNoOpt` matches `productionStyle` + +The middle arm (stable IDs but unoptimized `applyIncremental`) clocks identical numbers to `productionStyle` (within noise: 0.286/2.843/23.298 vs 0.304/2.697/24.762 μs). This is the key isolation result: **the splicer change alone doesn't pay off**. `IdNodeIndex.applyIncremental`'s step 3 walks every direct child of the new root regardless of whether ancestor IDs are stable. The win comes from *combining* stable IDs with an `applyIncremental` that knows it can skip step 3 entirely — that is, from the `StableIdNodeIndex` algorithm. + +The corollary: a real Path D promotion must ship both pieces together. Shipping just the splicer (without `StableIdNodeIndex`) is no improvement; shipping just `StableIdNodeIndex` against an `IdTreeSplicer`-built tree is incorrect (siblings would point to the dead old ancestor IDs). The two are coupled and must migrate as a unit. + +## Caveats + +- **Bench measures `applyIncremental` only.** Splice cost (`StableIdSplicer.splice`) is pre-computed in `@Setup(Level.Trial)` and *not* part of the per-invocation timing. This is appropriate for the perf-gate question (`applyIncremental` was the identified bottleneck), but the end-to-end edit cost will be `splice + applyIncremental`. On the flat-tree shape the splicer cost is `O(depth × branching)` ≈ a few records, dwarfed by even the optimized `applyIncremental`. +- **Bench tree is single-level flat.** Real source trees are nested. In a deeper shape, the original `IdNodeIndex.applyIncremental` step 3 cost is `O(depth × branching)` — much smaller than the flat-tree O(N). Path D's win shrinks correspondingly: on a balanced 4-ary depth-7 tree (≈16k nodes, depth ≈ 7), step 3 walks ~28 children, so the absolute speedup is bounded by ~28×, not 600×. The flat shape is the worst case for the original algorithm and the best case for Path D. Real-world wins will land between the flat-shape and balanced-tree extremes. +- **`pathD@10000` error band is wide** (±0.176 μs on 0.041 μs mean). At ~40 ns/op the measurement is at the edge of JMH's resolution; the bench is dominated by setup/teardown noise. The *qualitative* result — flat scaling, ≥100× faster than `productionStyle` — is robust. +- **Mutate-in-place semantics still in effect.** `StableIdNodeIndex.applyIncremental` mutates the receiver's parents map in place; the receiver is invalid after the call. Production v0.5.0 will need a persistent map for snapshot/rollback (out of scope for this spike). +- **Single edit measured.** A long edit session would amortize `StableIdNodeIndex.build` cost over many `applyIncremental` calls — the per-edit advantage of Path D compounds across an editing session. + +## Verdict + +**GO** — Path D is the architecture for Phase 1.2+. The flat-tree perf gate is met by ~19× headroom at N=1000 and ~120× at N=10000, the microcount confirms genuine O(δ) behaviour (2 map ops on a 1001-node tree), and the migration scope is small: two new sandbox classes (`StableIdSplicer`, `StableIdNodeIndex`), each a near-clone of an existing class with one localized change. + +### ID-semantics judgement + +The semantic shift is real and worth naming: under `IdTreeSplicer` an `id` was equivalent to JVM record identity (one record, one id); under `StableIdSplicer` an `id` is logical identity preserved across structural rebuilds at the splicer's discretion. Two distinct `IdCstNode` records may share an id across edit generations. + +For the use case this design serves — IDE-style incremental reparse, where the question "is this still the same node as before?" is *exactly* the question we want a stable id to answer — this is a clean architectural fit, not an awkward concession. The stable id IS the logical-identity primitive; treating it as JVM identity was the accidental simplification, and Phase 0c's `IdNodeIndex` was paying linear cost per edit specifically because it didn't trust the id beyond JVM identity. The honest framing for the v0.5.0 contract is: **id = logical node identity, preserved across splices unless the splicer says otherwise** — which is what every downstream caller (LSP server, IDE refactoring engine, language server diagnostics) actually wants. + +## Files + +- `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java` +- `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java` +- `peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java` +- `peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java` +- `peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java` +- `peglib-incremental/target/path-d.json` — raw JMH output (not committed) diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java new file mode 100644 index 0000000..3de79bd --- /dev/null +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java @@ -0,0 +1,182 @@ +package org.pragmatica.peg.incremental.bench; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.pragmatica.peg.incremental.experimental.IdCstNode; +import org.pragmatica.peg.incremental.experimental.IdGenerator; +import org.pragmatica.peg.incremental.experimental.IdNodeIndex; +import org.pragmatica.peg.incremental.experimental.IdTreeSplicer; +import org.pragmatica.peg.incremental.experimental.StableIdNodeIndex; +import org.pragmatica.peg.incremental.experimental.StableIdSplicer; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Path D — perf-gate JMH bench. Three-way comparison on the same flat-tree + * mid-buffer-splice shape used by {@link MidBufferBench}, isolating where the + * Path D win comes from. + * + *

Three arms

+ * + *
    + *
  • productionStyle: original spec §2 algorithm. Fresh + * ancestor IDs (via {@link IdTreeSplicer}) and full + * {@link IdNodeIndex#applyIncremental} including step 3 + * sibling-rewire. The baseline. + *
  • stableIdNoOpt: stable ancestor IDs (via + * {@link StableIdSplicer}) but unoptimized + * {@link IdNodeIndex#applyIncremental}. Measures whether the splicer + * change alone pays off — it shouldn't, because step 3 still walks all + * direct children of the new (stable-ID) root and rewrites their parent + * links to a value that's already correct. + *
  • pathD: stable IDs + optimized + * {@link StableIdNodeIndex#applyIncremental}. The actual O(δ) + * algorithm — skips ancestor-removal and sibling-rewire entirely. + *
+ * + *

Tree shape & edit

+ * + *

Identical to {@link MidBufferBench}: single root with N terminal children; + * splice replaces the child at index N/2 with a single fresh terminal. + * + *

Perf gate

+ * + *

Path D passes the gate iff {@code productionStyle / pathD ≥ 5} at tree + * size ≥ 1000. NO-GO falls back to investigating further architectural + * changes (lazy parent-link computation, persistent map structures, etc). + * + *

How to run

+ *
{@code
+ *   mvn -pl peglib-incremental -am -Pbench -DskipTests package
+ *   java -jar peglib-incremental/target/benchmarks.jar PathDBench \
+ *     -rf json -rff peglib-incremental/target/path-d.json -i 3 -wi 2 -f 1
+ * }
+ * + * @since 0.5.0 + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 2) +@Fork(2) +@State(Scope.Benchmark) +public class PathDBench { + + private static final List NO_TRIVIA = List.of(); + + @Param({"100", "1000", "10000"}) + private int treeSize; + + // --- Shared trial state (built once per @Param value) --- + private IdCstNode oldRoot; // fresh-ID-style baseline root (used by productionStyle + stableIdNoOpt — same tree, both splicers operate on it) + private IdCstNode oldTarget; // child being replaced + private List oldPath; // [oldRoot, oldTarget] + private IdGenerator idGenProd; + private IdGenerator idGenStable; + + // Pre-built splice results — productionStyle (fresh ancestor IDs). + private IdCstNode newRoot_freshIds; + private List newPath_freshIds; + + // Pre-built splice results — stable ancestor IDs. + private IdCstNode newRoot_stableIds; + private List newPath_stableIds; + + // Per-invocation indices (rebuilt fresh because applyIncremental mutates). + private IdNodeIndex prodIndex; + private IdNodeIndex stableNoOptIndex; + private StableIdNodeIndex pathDIndex; + + @Setup(Level.Trial) + public void buildTrees() { + idGenProd = new IdGenerator.PerSessionCounter(); + oldRoot = MidBufferTreeBuilder.buildIdTree(treeSize, idGenProd); + var oldChildren = ((IdCstNode.NonTerminal) oldRoot).children(); + int midIndex = treeSize / 2; + oldTarget = oldChildren.get(midIndex); + oldPath = List.of(oldRoot, oldTarget); + + // Pre-build the new pivot once. Same span/text for both arms. + int targetStart = midIndex * MidBufferTreeBuilder.CHILD_WIDTH; + var newPivotSpan = new SourceSpan(1, 1, targetStart, 1, 1, targetStart + 13); + + // productionStyle splicer (fresh ancestor IDs). Pre-compute the splice + // result once — the bench measures only applyIncremental cost. + IdCstNode newPivotProd = new IdCstNode.Terminal( + idGenProd.next(), newPivotSpan, "Stmt", "yyy", NO_TRIVIA, NO_TRIVIA); + var freshSplicer = new IdTreeSplicer(idGenProd); + var freshResult = freshSplicer.splice(oldPath, newPivotProd); + newRoot_freshIds = freshResult.newRoot(); + newPath_freshIds = freshResult.newPath(); + + // stable-ID splicer. Use a separate generator so ID streams don't + // interleave; same target span. + idGenStable = new IdGenerator.PerSessionCounter(); + // Re-prime idGenStable past the existing tree's IDs to avoid collision + // when StableIdSplicer is asked to allocate (it doesn't, in current + // impl, but defensive in case future variants do). + for (int i = 0; i <= treeSize + 2; i++) { + idGenStable.next(); + } + IdCstNode newPivotStable = new IdCstNode.Terminal( + idGenStable.next(), newPivotSpan, "Stmt", "yyy", NO_TRIVIA, NO_TRIVIA); + var stableSplicer = new StableIdSplicer(idGenStable); + var stableResult = stableSplicer.splice(oldPath, newPivotStable); + newRoot_stableIds = stableResult.newRoot(); + newPath_stableIds = stableResult.newPath(); + } + + /** + * Per-invocation: rebuild all three arms' indices, because + * {@code applyIncremental} mutates the receiver's parents map. Build cost + * is identical across arms ({@code O(N)} on the same tree); the bench + * measures the differential cost of the {@code applyIncremental} call + * itself. + */ + @Setup(Level.Invocation) + public void rebuildIndices() { + prodIndex = IdNodeIndex.build(oldRoot); + stableNoOptIndex = IdNodeIndex.build(oldRoot); + pathDIndex = StableIdNodeIndex.build(oldRoot); + } + + /** + * Original spec §2 algorithm: fresh ancestor IDs + step-3 sibling rewire. + */ + @Benchmark + public IdNodeIndex productionStyle() { + return prodIndex.applyIncremental(newRoot_freshIds, oldPath, newPath_freshIds); + } + + /** + * Stable IDs but unoptimized applyIncremental — measures whether the + * splicer change alone is sufficient. (Expectation: no — step 3 still + * walks every direct child.) + */ + @Benchmark + public IdNodeIndex stableIdNoOpt() { + return stableNoOptIndex.applyIncremental(newRoot_stableIds, oldPath, newPath_stableIds); + } + + /** + * Path D — stable IDs + optimized applyIncremental. The actual O(δ) + * algorithm. + */ + @Benchmark + public StableIdNodeIndex pathD() { + return pathDIndex.applyIncremental(newRoot_stableIds, oldPath, newPath_stableIds); + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java new file mode 100644 index 0000000..a778974 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java @@ -0,0 +1,246 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.pragmatica.lang.Option; + +import java.util.ArrayList; +import java.util.List; + +/** + * Path D — stable-id-aware parent index for {@link IdCstNode} trees. + * + *

Identical surface to {@link IdNodeIndex}; the sole difference is the + * implementation of {@link #applyIncremental}, which exploits the stable + * ancestor-id invariant guaranteed by {@link StableIdSplicer} to drop the + * cost from {@code O(N)} (on flat trees) to + * {@code O(oldPivotSize + newPivotSize)} — independent of N and of + * tree shape. + * + *

Path D vs IdNodeIndex.applyIncremental

+ * + *

{@link IdNodeIndex#applyIncremental} executes three steps: + * + *

    + *
  1. Remove every old-path ancestor's up-entry, plus every old-pivot + * descendant's up-entry. + *
  2. Insert the new pivot's subtree internal links and wire the pivot to + * its new parent. + *
  3. Walk the new ancestor chain top-down and rewire ALL direct children + * of every ancestor — needed because each new ancestor has a FRESH id + * (the old siblings' parent-links still point to the dead old ancestor + * ids). + *
+ * + *

{@link StableIdSplicer} reuses each old ancestor's id when building the + * corresponding new ancestor record. Consequence: + * + *

    + *
  • Old-path ancestor entries in the parents map remain valid — + * their parent's id is unchanged (the parent record was rebuilt but + * carries the same id as before). Step 1's old-path-ancestor removal + * becomes wrong (would delete still-correct entries). + *
  • Step 3's sibling-rewire becomes redundant — sibling subtrees + * are reference-shared (per the identity invariant), so their + * parent-link entries already point to the correct id (the stable + * ancestor id, unchanged across the splice). + *
+ * + *

Path D therefore performs only the work that must happen: + * + *

    + *
  1. Remove the old pivot's descendants (the wholesale-replaced subtree).
  2. + *
  3. Remove the old pivot's own up-pointer (the new pivot has a fresh id by + * caller construction, so this entry is dead even with stable ancestors).
  4. + *
  5. Insert the new pivot's subtree internal links.
  6. + *
  7. Wire the new pivot's up-pointer to its parent's stable id.
  8. + *
+ * + *

Total cost: {@code O(oldPivotSize + newPivotSize)}. Independent of N. + * + *

Mutate-in-place / invalidate-on-incremental semantics

+ * + *

Same as {@link IdNodeIndex}: {@link #applyIncremental} mutates the + * receiver's parents map in place and the receiver becomes invalid after the + * call. Production v0.5.0 will likely use a persistent map for snapshot + * semantics; that design is out of scope for the spike. + * + *

This sandbox class is not referenced by {@code peglib-core} and will be + * promoted, reshaped, or deleted at the Phase 0/1 GO/NO-GO gate. + * + * @since 0.5.0 + */ +public final class StableIdNodeIndex { + private final IdCstNode root; + private final LongLongMap parents; + + /** + * Test hook — number of {@code parents.put} calls performed during the + * most recent {@link #applyIncremental} invocation that produced this + * index, or {@code -1} when this index was produced by {@link #build}. + * Mirrors {@link IdNodeIndex#lastIncrementalPutCount} for microcount + * comparisons. + */ + final int lastIncrementalPutCount; + + /** + * Test hook — number of {@code parents.remove} calls performed during + * the most recent {@link #applyIncremental} invocation that produced this + * index, or {@code -1} when this index was produced by {@link #build}. + */ + final int lastIncrementalRemoveCount; + + private StableIdNodeIndex(IdCstNode root, + LongLongMap parents, + int lastIncrementalPutCount, + int lastIncrementalRemoveCount) { + this.root = root; + this.parents = parents; + this.lastIncrementalPutCount = lastIncrementalPutCount; + this.lastIncrementalRemoveCount = lastIncrementalRemoveCount; + } + + /** + * Build a fresh index over {@code root}. {@code O(N)} in the descendant + * count. Used only on the initial parse; subsequent edits should call + * {@link #applyIncremental} for {@code O(δ)} updates. + */ + public static StableIdNodeIndex build(IdCstNode root) { + int expectedSize = countDescendants(root); + var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); + indexChildren(root, parents); + return new StableIdNodeIndex(root, parents, -1, -1); + } + + /** + * Path D optimized incremental index update. Assumes the splicer + * preserved ancestor IDs (use {@link StableIdSplicer}). + * + *

Cost: {@code O(oldPivotSize + newPivotSize)} — independent of N. + * + *

Invalidates the receiver. Callers MUST use the + * returned instance and discard {@code this}. + * + * @param newRoot root of the post-edit tree + * @param oldPath {@code root → oldPivot} chain in the pre-edit tree + * (size ≥ 1) + * @param newPath {@code root → newPivot} chain in the post-edit tree + * (size ≥ 1; ancestors carry stable ids matching + * {@code oldPath}) + */ + public StableIdNodeIndex applyIncremental(IdCstNode newRoot, + List oldPath, + List newPath) { + if (oldPath == null || oldPath.isEmpty()) { + throw new IllegalArgumentException("oldPath must contain at least the old pivot"); + } + if (newPath == null || newPath.isEmpty()) { + throw new IllegalArgumentException("newPath must contain at least the new pivot"); + } + var oldPivot = oldPath.get(oldPath.size() - 1); + var newPivot = newPath.get(newPath.size() - 1); + + int putCount = 0; + int removeCount = 0; + + // Step 1 — remove oldPivot's descendants (wholesale replaced subtree). + // Note: we do NOT touch oldPath ancestors' entries — their ids are + // reused on the new path, so the entries are still valid. + var oldPivotDescendants = new ArrayList(); + flattenDescendantsInto(oldPivot, oldPivotDescendants); + for (var d : oldPivotDescendants) { + parents.remove(d.id()); + removeCount++; + } + + // Step 2 — remove the oldPivot's own up-pointer. The new pivot + // typically has a fresh id (caller responsibility), so the old entry + // is dead. (If the caller chose to reuse oldPivot.id() for the new + // pivot, step 4 below will simply re-insert the same entry — still + // correct.) + parents.remove(oldPivot.id()); + removeCount++; + + // Step 3 — insert new pivot's subtree internal links. + indexChildren(newPivot, parents); + putCount += subtreeChildCount(newPivot); + + // Step 4 — wire the new pivot to its parent's stable id, unless the + // pivot is itself the new root. + if (newPath.size() >= 2) { + var newPivotParent = newPath.get(newPath.size() - 2); + parents.put(newPivot.id(), newPivotParent.id()); + putCount++; + } + + // Steps explicitly SKIPPED vs IdNodeIndex.applyIncremental: + // - removal of oldPath ancestors' entries (their ids are reused) + // - sibling rewire under each new ancestor (siblings already point + // to the correct stable id, unchanged across the splice). + + return new StableIdNodeIndex(newRoot, parents, putCount, removeCount); + } + + /** Root of the CST this index reflects. */ + public IdCstNode root() { + return root; + } + + /** + * Parent ID of the node identified by {@code childId}, or + * {@link Option#none()} when {@code childId} is the root, absent from + * this index, or has been removed. + */ + public Option parentIdOf(long childId) { + if (!parents.containsKey(childId)) { + return Option.none(); + } + return Option.some(parents.get(childId)); + } + + /** + * {@code true} iff {@code id} is present as a key (i.e., the node has a + * parent in this index). Note: the root itself returns {@code false} + * because the root has no parent entry. + */ + public boolean contains(long id) { + return parents.containsKey(id); + } + + /** Number of parent entries — equals {@code descendantCount(root)}. */ + public int size() { + return parents.size(); + } + + // -- Helpers (mirror IdNodeIndex's static helpers) -- + + private static int countDescendants(IdCstNode node) { + int count = 0; + if (node instanceof IdCstNode.NonTerminal nt) { + for (var child : nt.children()) { + count += 1 + countDescendants(child); + } + } + return count; + } + + private static void indexChildren(IdCstNode node, LongLongMap parents) { + if (node instanceof IdCstNode.NonTerminal nt) { + for (var child : nt.children()) { + parents.put(child.id(), nt.id()); + indexChildren(child, parents); + } + } + } + + private static void flattenDescendantsInto(IdCstNode node, List out) { + if (node instanceof IdCstNode.NonTerminal nt) { + for (var child : nt.children()) { + out.add(child); + flattenDescendantsInto(child, out); + } + } + } + + private static int subtreeChildCount(IdCstNode node) { + return countDescendants(node); + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java new file mode 100644 index 0000000..cdc4356 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java @@ -0,0 +1,172 @@ +package org.pragmatica.peg.incremental.experimental; + +import java.util.ArrayList; +import java.util.List; + +/** + * Stable-ID variant of {@link IdTreeSplicer} — Path D spike for the v0.5.0 + * architectural rework + * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A and §8 Q3, + * and {@code docs/bench-results/phase1-spanindex-results.md} "Architectural + * insight" for the motivation). + * + *

Identical to {@link IdTreeSplicer} in every respect EXCEPT one: when + * building the new ancestor records on the splice path, this splicer + * reuses each old ancestor's {@code id} instead of allocating + * a fresh one from the {@link IdGenerator}. + * + *

Why stable IDs?

+ * + *

Phase 1 (Path A) bench surfaced the actual bottleneck on flat trees: + * {@code IdNodeIndex.applyIncremental} step 3 walks every direct child of every + * rebuilt ancestor because the new ancestor carries a fresh ID — every sibling + * needs its parent-link rewritten to point to the new ID. On a flat tree + * (one root with N statement children), that's {@code O(N)} parent-link + * rewrites per edit, dominating any splice-side savings. + * + *

Reusing the old ancestor's id flips that: + * + *

    + *
  • The new ancestor record is structurally distinct ({@code !=}) but + * carries the same {@code id} as the old one. + *
  • Sibling subtrees that are reference-shared (per the identity invariant + * carried over from {@link IdTreeSplicer}) also have their + * existing parent-link entries in any {@link IdNodeIndex}'s parents map + * remain valid — the parent's id is unchanged. + *
  • {@link StableIdNodeIndex#applyIncremental} can therefore skip the + * sibling-rewire step entirely, dropping the cost from + * {@code O(N)} per edit to {@code O(oldPivotSize + newPivotSize)}. + *
+ * + *

ID semantics tradeoff

+ * + *

This is a semantic shift. With {@link IdTreeSplicer}, an id corresponds + * 1:1 to a JVM record instance — {@code id == JVM identity}. With + * {@code StableIdSplicer}, an id corresponds to a logical node + * preserved across splices when the splicer's discretion deems the structural + * role unchanged (here: every node on the splice path keeps its id, even + * though its {@code children} list was rebuilt). Two distinct {@link IdCstNode} + * records may share an id across edit generations. + * + *

{@link IdGenerator} is still required (and still parameterizable) because + * future callers may need to allocate ids for genuinely new internal nodes + * (e.g., a node split during recovery). The splicer itself does not consume + * any new ids during the ancestor rebuild on the splice path. + * + *

This sandbox class is not referenced by {@code peglib-core} and will be + * promoted, reshaped, or deleted at the Phase 0/1 GO/NO-GO gate. + * + * @since 0.5.0 + */ +public final class StableIdSplicer { + + /** + * Result of a splice — the new root and the new path (root → newPivot, + * inclusive). Path elements at indices {@code [0, newPath.size() - 2]} + * carry stable ids matching the corresponding {@code oldPath} entries; the + * last element is the supplied {@code newPivot} (its id is whatever the + * caller built it with). + */ + public record Result(IdCstNode newRoot, List newPath) {} + + @SuppressWarnings("unused") // retained for symmetry with IdTreeSplicer; future use cases may need to allocate IDs for genuinely new internal nodes (e.g., recovery splits). + private final IdGenerator idGen; + + public StableIdSplicer(IdGenerator idGen) { + this.idGen = idGen; + } + + /** + * Splice {@code newPivot} into the tree by replacing the last element of + * {@code oldPath}. Each new ancestor on the path reuses the corresponding + * old ancestor's id; sibling subtrees are reference-shared. + * + * @param oldPath root → oldPivot, inclusive (size ≥ 1) + * @param newPivot the replacement subtree + * @return new root + new path; ancestors on the path are fresh records but + * carry stable ids matching their {@code oldPath} counterparts + * @throws IllegalArgumentException when {@code oldPath} is null or empty + * @throws IllegalStateException when an ancestor is not a {@code NonTerminal} + * or the child-identity link in {@code oldPath} is broken + */ + public Result splice(List oldPath, IdCstNode newPivot) { + if (oldPath == null || oldPath.isEmpty()) { + throw new IllegalArgumentException("oldPath must contain at least the old pivot"); + } + if (newPivot == null) { + throw new IllegalArgumentException("newPivot must not be null"); + } + + // Pivot IS the root — no rebuild needed. + if (oldPath.size() == 1) { + return new Result(newPivot, List.of(newPivot)); + } + + // Accumulator: builds newPath in REVERSE (leaf → root), reversed at end. + var reversedNewPath = new ArrayList(oldPath.size()); + reversedNewPath.add(newPivot); + + var current = newPivot; + var oldChild = oldPath.get(oldPath.size() - 1); + + for (int i = oldPath.size() - 2; i >= 0; i--) { + var oldAncestor = oldPath.get(i); + if (!(oldAncestor instanceof IdCstNode.NonTerminal nt)) { + throw new IllegalStateException( + "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); + } + var children = nt.children(); + int slot = indexOfByIdentity(children, oldChild); + if (slot < 0) { + throw new IllegalStateException( + "splice path broken at depth " + i + + ": child " + oldChild + " not found in parent's children list"); + } + + // Copy the children list, replacing only the spliced slot. Every + // other entry is the same reference — preserves the spec §8 Q3 + // identity invariant. + var newChildren = new ArrayList(children.size()); + for (int k = 0; k < children.size(); k++) { + newChildren.add(k == slot ? current : children.get(k)); + } + + // SOLE DIVERGENCE FROM IdTreeSplicer: reuse oldAncestor.id() instead + // of idGen.next(). The new record is structurally distinct from the + // old (different children list) but carries the same id; the + // parents map in any StableIdNodeIndex therefore preserves every + // sibling's parent-link entry across the edit. + var newAncestor = new IdCstNode.NonTerminal( + oldAncestor.id(), + nt.span(), + nt.rule(), + List.copyOf(newChildren), + nt.leadingTrivia(), + nt.trailingTrivia()); + + reversedNewPath.add(newAncestor); + current = newAncestor; + oldChild = oldAncestor; + } + + var newPath = new ArrayList(reversedNewPath.size()); + for (int i = reversedNewPath.size() - 1; i >= 0; i--) { + newPath.add(reversedNewPath.get(i)); + } + return new Result(current, List.copyOf(newPath)); + } + + /** + * Linear scan for a child by record identity ({@code ==}). Children lists + * are typically small (≤ 8); a linear scan dominates any hash-based + * approach. + */ + private static int indexOfByIdentity(List children, IdCstNode target) { + for (int i = 0; i < children.size(); i++) { + if (children.get(i) == target) { + return i; + } + } + return -1; + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java new file mode 100644 index 0000000..1676258 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java @@ -0,0 +1,279 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Path D — tests for {@link StableIdNodeIndex}. Central correctness claim: + * after {@link StableIdNodeIndex#applyIncremental} on a tree spliced via + * {@link StableIdSplicer}, the resulting parents map matches what + * {@code StableIdNodeIndex.build(newRoot)} would produce — even though the + * incremental update skips the ancestor-removal and sibling-rewire steps that + * {@link IdNodeIndex#applyIncremental} performs. + * + *

Central perf claim: the {@code parents.put} + {@code parents.remove} call + * counts during {@code applyIncremental} are bounded by + * {@code oldPivotSize + newPivotSize + small constant}, independent of N. + */ +final class StableIdNodeIndexTest { + private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); + private static final List NO_TRIVIA = List.of(); + + private static IdCstNode.Terminal terminal(IdGenerator gen, String text) { + return new IdCstNode.Terminal(gen.next(), SPAN, "T", text, NO_TRIVIA, NO_TRIVIA); + } + + private static IdCstNode.NonTerminal nonTerminal(IdGenerator gen, String rule, List children) { + return new IdCstNode.NonTerminal(gen.next(), SPAN, rule, children, NO_TRIVIA, NO_TRIVIA); + } + + @Nested + @DisplayName("build") + class BuildTests { + @Test + @DisplayName("Equivalence to IdNodeIndex.build: same parents map on the same tree") + void build_equivalent_to_idnodeindex() { + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "a"); + var b = terminal(gen, "b"); + var c = terminal(gen, "c"); + IdCstNode inner = nonTerminal(gen, "Inner", List.of(b, c)); + IdCstNode root = nonTerminal(gen, "Root", List.of(a, inner)); + + var stableIndex = StableIdNodeIndex.build(root); + var idIndex = IdNodeIndex.build(root); + + assertThat(stableIndex.size()).isEqualTo(idIndex.size()); + for (var node : flatten(root)) { + assertThat(stableIndex.parentIdOf(node.id())) + .as("node id %d (rule %s) parent must agree", node.id(), node.rule()) + .isEqualTo(idIndex.parentIdOf(node.id())); + } + assertThat(stableIndex.root()).isSameAs(root); + } + } + + @Nested + @DisplayName("applyIncremental") + class ApplyIncrementalTests { + + @Test + @DisplayName("Equivalence to full rebuild after StableIdSplicer splice") + void equivalence_to_full_rebuild() { + // 3-level tree, splice at depth 2 via StableIdSplicer, verify + // the resulting StableIdNodeIndex matches build(newRoot) exactly. + var gen = new IdGenerator.PerSessionCounter(); + var leaf1 = terminal(gen, "1"); + var leaf2 = terminal(gen, "2"); + var leaf3 = terminal(gen, "3"); + var leaf4 = terminal(gen, "4"); + IdCstNode oldChildA = nonTerminal(gen, "A", List.of(leaf1, leaf2)); + IdCstNode oldChildB = nonTerminal(gen, "B", List.of(leaf3, leaf4)); + IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(oldChildA, oldChildB)); + + var index = StableIdNodeIndex.build(oldRoot); + + // Replace child B with a fresh subtree using StableIdSplicer. + var newLeaf5 = terminal(gen, "5"); + var newLeaf6 = terminal(gen, "6"); + var newLeaf7 = terminal(gen, "7"); + IdCstNode newChildB = nonTerminal(gen, "B", List.of(newLeaf5, newLeaf6, newLeaf7)); + + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(List.of(oldRoot, oldChildB), newChildB); + + var oldPath = List.of(oldRoot, oldChildB); + var newPath = result.newPath(); + + var incremental = index.applyIncremental(result.newRoot(), oldPath, newPath); + var rebuilt = StableIdNodeIndex.build(result.newRoot()); + + // Both indices must agree on every node in the new tree. + for (var node : flatten(result.newRoot())) { + assertThat(incremental.parentIdOf(node.id())) + .as("parent of node id %d (rule %s)", node.id(), node.rule()) + .isEqualTo(rebuilt.parentIdOf(node.id())); + } + assertThat(incremental.size()).isEqualTo(rebuilt.size()); + // Old pivot and its old descendants are gone. + assertThat(incremental.contains(oldChildB.id())).isFalse(); + assertThat(incremental.contains(leaf3.id())).isFalse(); + assertThat(incremental.contains(leaf4.id())).isFalse(); + // Stable ancestor: the new root has the SAME id as old root. + assertThat(result.newRoot().id()).isEqualTo(oldRoot.id()); + } + + @Test + @DisplayName("Flat tree: 100 children, splice middle; remaining children's parent entries unchanged") + void flat_tree_splice_preserves_sibling_entries() { + // Build a flat tree: root with 100 terminal children. + var gen = new IdGenerator.PerSessionCounter(); + var children = new ArrayList(100); + for (int i = 0; i < 100; i++) { + children.add(terminal(gen, "c" + i)); + } + IdCstNode oldRoot = nonTerminal(gen, "Root", children); + long stableRootId = oldRoot.id(); + + var index = StableIdNodeIndex.build(oldRoot); + assertThat(index.size()).isEqualTo(100); + + // Replace child #50 via StableIdSplicer. + var oldTarget = children.get(50); + IdCstNode newTarget = terminal(gen, "newC50"); + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(List.of(oldRoot, oldTarget), newTarget); + + var newIndex = index.applyIncremental(result.newRoot(), List.of(oldRoot, oldTarget), result.newPath()); + + // Stable root id propagated. + assertThat(result.newRoot().id()).isEqualTo(stableRootId); + // Every remaining child still points to the (stable-id) root. + for (int i = 0; i < 100; i++) { + if (i == 50) { + // old c50 is dead. + assertThat(newIndex.contains(children.get(i).id())).isFalse(); + continue; + } + assertThat(newIndex.parentIdOf(children.get(i).id())) + .as("flat-tree sibling at index %d still points to stable root id", i) + .isEqualTo(org.pragmatica.lang.Option.some(stableRootId)); + } + // The new c50 points to the stable root id. + assertThat(newIndex.parentIdOf(newTarget.id())) + .isEqualTo(org.pragmatica.lang.Option.some(stableRootId)); + assertThat(newIndex.size()).isEqualTo(100); + } + + @Test + @DisplayName("Microcount on flat tree: put + remove counts ≤ oldPivotSize + newPivotSize + small slack") + void microcount_o_delta() { + // Central perf claim: applyIncremental cost is O(oldPivotSize + newPivotSize), + // NOT O(N). Build a 1000-child flat tree, splice one terminal, assert + // microcounts are tiny. + var gen = new IdGenerator.PerSessionCounter(); + int N = 1000; + var children = new ArrayList(N); + for (int i = 0; i < N; i++) { + children.add(terminal(gen, "c" + i)); + } + IdCstNode oldRoot = nonTerminal(gen, "Root", children); + + var index = StableIdNodeIndex.build(oldRoot); + int totalNodes = countAll(oldRoot); // 1001 (root + 1000 children) + assertThat(totalNodes).isEqualTo(N + 1); + + var oldTarget = children.get(N / 2); + IdCstNode newTarget = terminal(gen, "newMid"); + + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(List.of(oldRoot, oldTarget), newTarget); + + var newIndex = index.applyIncremental(result.newRoot(), List.of(oldRoot, oldTarget), result.newPath()); + + // oldPivotSize: oldTarget is a leaf — 0 descendants. + // newPivotSize: newTarget is a leaf — 0 descendants. +1 for pivot up-pointer. + // Removes: 0 descendants + 1 (oldPivot) = 1 + // Puts: 0 internal + 1 pivot up-pointer = 1 + // Slack of 5 for any future implementation detail. + int oldPivotSize = countAll(oldTarget); // 1 (just the leaf itself) + int newPivotSize = countAll(newTarget); // 1 + int microBound = oldPivotSize + newPivotSize + 5; + + int totalMicro = newIndex.lastIncrementalPutCount + newIndex.lastIncrementalRemoveCount; + + assertThat(totalMicro) + .as("put + remove count must be O(oldPivotSize + newPivotSize), NOT O(N=%d). " + + "Got puts=%d, removes=%d (bound=%d)", + totalNodes, newIndex.lastIncrementalPutCount, newIndex.lastIncrementalRemoveCount, + microBound) + .isLessThanOrEqualTo(microBound) + .isLessThan(totalNodes); + + System.out.println("[Path D] flat-tree(N=" + N + ") incremental microcount: puts=" + + newIndex.lastIncrementalPutCount + + " removes=" + newIndex.lastIncrementalRemoveCount + + " (vs full-rebuild N=" + totalNodes + ")"); + } + + @Test + @DisplayName("Deep splice (4-level): equivalence to fresh build, stable ancestor ids preserved") + void deep_splice_equivalence() { + var gen = new IdGenerator.PerSessionCounter(); + var lvl1Sib = terminal(gen, "L1S"); + var midLvl2Sib = terminal(gen, "L2S"); + var oldPivot = terminal(gen, "OLD_PIVOT"); + IdCstNode oldInner = nonTerminal(gen, "Inner", List.of(oldPivot)); + IdCstNode oldMid = nonTerminal(gen, "Mid", List.of(midLvl2Sib, oldInner)); + IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(lvl1Sib, oldMid)); + + var index = StableIdNodeIndex.build(oldRoot); + + IdCstNode newPivot = terminal(gen, "NEW_PIVOT"); + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(List.of(oldRoot, oldMid, oldInner, (IdCstNode) oldPivot), newPivot); + + var oldPath = List.of(oldRoot, oldMid, oldInner, (IdCstNode) oldPivot); + var newIndex = index.applyIncremental(result.newRoot(), oldPath, result.newPath()); + + // Stable ancestor ids: newRoot.id == oldRoot.id, etc. + for (int i = 0; i < oldPath.size() - 1; i++) { + assertThat(result.newPath().get(i).id()) + .as("ancestor at depth %d preserves stable id", i) + .isEqualTo(oldPath.get(i).id()); + } + + // Equivalence to full rebuild. + var fresh = StableIdNodeIndex.build(result.newRoot()); + for (var node : flatten(result.newRoot())) { + assertThat(newIndex.parentIdOf(node.id())) + .as("deep splice: node id %d (rule %s) parent agrees with full build", + node.id(), node.rule()) + .isEqualTo(fresh.parentIdOf(node.id())); + } + assertThat(newIndex.size()).isEqualTo(fresh.size()); + // Old pivot is dead. + assertThat(newIndex.contains(oldPivot.id())).isFalse(); + // BUT old ancestor ids remain LIVE (their records were rebuilt with same ids). + assertThat(newIndex.contains(oldMid.id())).isTrue(); + assertThat(newIndex.contains(oldInner.id())).isTrue(); + // Old root id has no parent entry (it's the root). + assertThat(newIndex.contains(oldRoot.id())).isFalse(); + } + } + + // -- helpers -- + + private static int countAll(IdCstNode node) { + int c = 1; + if (node instanceof IdCstNode.NonTerminal nt) { + for (var ch : nt.children()) { + c += countAll(ch); + } + } + return c; + } + + private static List flatten(IdCstNode root) { + var out = new ArrayList(); + flattenInto(root, out); + return out; + } + + private static void flattenInto(IdCstNode node, List out) { + out.add(node); + if (node instanceof IdCstNode.NonTerminal nt) { + for (var ch : nt.children()) { + flattenInto(ch, out); + } + } + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java new file mode 100644 index 0000000..ceb0412 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java @@ -0,0 +1,182 @@ +package org.pragmatica.peg.incremental.experimental; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Path D — tests for {@link StableIdSplicer}. Inherits identity-preservation + * tests from the {@link IdTreeSplicer} pattern, plus tests asserting the + * stable-id invariant: every ancestor on the new path carries the same id as + * its old-path counterpart, despite being a structurally distinct record. + */ +final class StableIdSplicerTest { + private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); + private static final List NO_TRIVIA = List.of(); + + private static IdCstNode.Terminal terminal(IdGenerator gen, String text) { + return new IdCstNode.Terminal(gen.next(), SPAN, "T", text, NO_TRIVIA, NO_TRIVIA); + } + + private static IdCstNode.NonTerminal nonTerminal(IdGenerator gen, String rule, List children) { + return new IdCstNode.NonTerminal(gen.next(), SPAN, rule, children, NO_TRIVIA, NO_TRIVIA); + } + + @Nested + @DisplayName("splice") + class SpliceTests { + + @Test + @DisplayName("Pivot is root: returns newPivot directly, newPath = [newPivot]") + void pivot_as_root() { + var gen = new IdGenerator.PerSessionCounter(); + IdCstNode oldRoot = terminal(gen, "old"); + IdCstNode newPivot = terminal(gen, "new"); + + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(List.of(oldRoot), newPivot); + + assertThat(result.newRoot()).isSameAs(newPivot); + assertThat(result.newPath()).hasSize(1); + assertThat(result.newPath().get(0)).isSameAs(newPivot); + } + + @Test + @DisplayName("Single-level splice: ancestor reuses old id; siblings reference-shared") + void single_level_stable_id() { + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "A"); + var b = terminal(gen, "B"); + var c = terminal(gen, "C"); + IdCstNode root = nonTerminal(gen, "Root", List.of(a, b, c)); + IdCstNode bPrime = terminal(gen, "B'"); + + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(List.of(root, b), bPrime); + + // Stable id: new root's id matches old root's id. + assertThat(result.newRoot().id()) + .as("ancestor id reused on splice path") + .isEqualTo(root.id()); + // But the records are structurally distinct (different children list). + assertThat(result.newRoot()).isNotSameAs(root); + + var newRootNT = (IdCstNode.NonTerminal) result.newRoot(); + assertThat(newRootNT.rule()).isEqualTo("Root"); + // Siblings are reference-shared (identity invariant). + assertThat(newRootNT.children().get(0)).isSameAs(a); + assertThat(newRootNT.children().get(1)).isSameAs(bPrime); + assertThat(newRootNT.children().get(2)).isSameAs(c); + // newPath = [newRoot, newPivot]. + assertThat(result.newPath()).hasSize(2); + assertThat(result.newPath().get(0)).isSameAs(result.newRoot()); + assertThat(result.newPath().get(1)).isSameAs(bPrime); + } + + @Test + @DisplayName("Deep splice: every ancestor on path reuses its old id; pivot keeps caller-supplied id") + void deep_splice_stable_ids() { + // 4-level tree: root → mid → inner → pivot, with siblings at each level. + var gen = new IdGenerator.PerSessionCounter(); + var lvl1Sib = terminal(gen, "lvl1Sib"); + var midSib = terminal(gen, "midSib"); + var innerSib = terminal(gen, "innerSib"); + var pivot = terminal(gen, "PIVOT"); + IdCstNode inner = nonTerminal(gen, "Inner", List.of(innerSib, pivot)); + IdCstNode mid = nonTerminal(gen, "Mid", List.of(midSib, inner)); + IdCstNode root = nonTerminal(gen, "Root", List.of(lvl1Sib, mid)); + + IdCstNode newPivot = terminal(gen, "NEW_PIVOT"); + var oldPath = List.of(root, mid, inner, (IdCstNode) pivot); + + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(oldPath, newPivot); + + assertThat(result.newPath()).hasSize(4); + // Ancestors at indices [0, oldPath.size() - 2) carry stable ids. + for (int i = 0; i < oldPath.size() - 1; i++) { + assertThat(result.newPath().get(i).id()) + .as("ancestor at depth %d must reuse old id", i) + .isEqualTo(oldPath.get(i).id()); + // ...but the records are structurally distinct. + assertThat(result.newPath().get(i)) + .as("ancestor at depth %d is a fresh record", i) + .isNotSameAs(oldPath.get(i)); + } + // The pivot slot carries the caller-supplied newPivot, with whatever + // id the caller gave it (typically fresh). + assertThat(result.newPath().get(3)).isSameAs(newPivot); + assertThat(result.newPath().get(3).id()) + .as("pivot id is whatever the caller built it with") + .isEqualTo(newPivot.id()); + // Sibling identity preservation (inherited invariant). + var newRootNT = (IdCstNode.NonTerminal) result.newRoot(); + assertThat(newRootNT.children().get(0)).isSameAs(lvl1Sib); + var newMidNT = (IdCstNode.NonTerminal) result.newPath().get(1); + assertThat(newMidNT.children().get(0)).isSameAs(midSib); + var newInnerNT = (IdCstNode.NonTerminal) result.newPath().get(2); + assertThat(newInnerNT.children().get(0)).isSameAs(innerSib); + assertThat(newInnerNT.children().get(1)).isSameAs(newPivot); + } + + @Test + @DisplayName("Structural inequality despite ID equality: new ancestor records are not == old, but ids match") + void structural_inequality_with_id_equality() { + // Verify the central Path D claim spelled out: same id, different + // record. This is the exact invariant that lets StableIdNodeIndex + // skip the ancestor-removal and sibling-rewire steps. + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "A"); + var b = terminal(gen, "B"); + IdCstNode root = nonTerminal(gen, "Root", List.of(a, b)); + IdCstNode bPrime = terminal(gen, "B'"); + + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(List.of(root, b), bPrime); + + assertThat(result.newPath().get(0)) + .as("structural inequality: different record") + .isNotSameAs(root); + assertThat(result.newPath().get(0).id()) + .as("ID equality: same logical node") + .isEqualTo(root.id()); + // The children lists differ — that's the structural delta. + var newRootNT = (IdCstNode.NonTerminal) result.newPath().get(0); + var oldRootNT = (IdCstNode.NonTerminal) root; + assertThat(newRootNT.children()).isNotEqualTo(oldRootNT.children()); + } + + @Test + @DisplayName("Generator is not consumed for ancestor rebuild — unlike IdTreeSplicer") + void generator_unused_for_ancestor_rebuild() { + // IdTreeSplicer would have called gen.next() for the new root; + // StableIdSplicer reuses the old root's id. The generator's state + // must not advance during the splice itself. + var gen = new IdGenerator.PerSessionCounter(); + var a = terminal(gen, "A"); + var b = terminal(gen, "B"); + IdCstNode root = nonTerminal(gen, "Root", List.of(a, b)); + // Caller pre-allocates the newPivot's id (consumes gen). + IdCstNode bPrime = terminal(gen, "B'"); + + long preSpliceCounter = gen.next(); + // Reset the gen to the same state by capturing pre-splice value + // and asserting post-splice gen returns the very next value. + // (IdGenerator has no peek(); we just verify monotonic +1.) + var splicer = new StableIdSplicer(gen); + var result = splicer.splice(List.of(root, b), bPrime); + + long postSpliceCounter = gen.next(); + assertThat(postSpliceCounter) + .as("StableIdSplicer must not consume the generator during ancestor rebuild") + .isEqualTo(preSpliceCounter + 1L); + assertThat(result.newRoot().id()).isEqualTo(root.id()); + } + } +} From 4043ddcbd04b622f586f81b5e7c1c875c905eb15 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 07:01:06 +0200 Subject: [PATCH 10/46] =?UTF-8?q?docs:=20phase=201=20prove-out=20summary?= =?UTF-8?q?=20=E2=80=94=20path=20D=20is=20the=20architecture,=20HANDOVER?= =?UTF-8?q?=20updated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/HANDOVER.md | 31 +++++--- docs/incremental/PHASE-1-PROVE-OUT.md | 107 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 docs/incremental/PHASE-1-PROVE-OUT.md diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 2a32a04..e15de02 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -272,25 +272,38 @@ Regen via: **Phase 0 spike landed GO on 2026-05-07.** All three GO/NO-GO gates green; Phase 1 (production migration of Lever A) is the next-session entry. Read `docs/incremental/PHASE-0-RESULTS.md` for the full verdict + bench numbers (38–67× speedup confirmed) before starting. -State of `release-0.5.0` branch (5 commits past `1619604` chore — local only, not pushed): +State of `release-0.5.0` branch (8 commits past `1619604` chore — local only, not pushed): - `d00eaa1` Phase 0a — `IdGenerator` + `LongLongMap` foundation - `f0696a1` Phase 0b — sandbox `IdCstNode` + `IdCstNodeBuilder` - `849b4ba` Phase 0c — sandbox `IdNodeIndex` with O(splicedSize+depth) incremental update - `9b55253` Phase 0d.1 — `IdTreeSplicer` + identity-invariant + trivia-edit gates -- `a2dd8ac` Phase 0d.2 — JMH `Phase0SpikeBench` + results note +- `a2dd8ac` Phase 0d.2 — JMH `Phase0SpikeBench` + results note (38-67× balanced-tree) +- `a8c6efe` Phase 0e — GO verdict doc + CHANGELOG entry +- `8f844eb` Path A prove-out — SpanIndex / offset decoupling (RED, 1.10-1.29×) +- `8b27dd6` Path D prove-out — stable-id ancestor preservation (GREEN, 96-604×) -All work additive in `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/` and the parallel test/jmh directories. 897-test production surface untouched throughout. Local incremental count: 154 (was 100, +54 sandbox tests). 699 core unchanged. +All work additive in `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/` and the parallel test/jmh directories. 897-test production surface untouched throughout. Local incremental count: 190 (was 100, +90 sandbox tests). 699 core unchanged. -### Phase 1 — production Lever A migration (next session) +### Phase 1 — production migration (path D, next session) -Per spec §6 Phase 1 (~1 week elapsed): migrate production `CstNode` to ID-bearing variants, switch `NodeIndex` from `IdentityHashMap` to `LongLongMap`. Three concrete items surfaced by Phase 0 (per `PHASE-0-RESULTS.md` §"Notes for Phase 1") that Phase 1 should address: +The Phase 1 prove-out (`docs/incremental/PHASE-1-PROVE-OUT.md`) discovered that the spec §2 algorithm degrades to O(N) on flat trees (e.g., a method body with 1000 statements). Path A (offset decoupling) was prove-out RED — 1.10-1.29× speedup, far short of the 5× gate. **Path D (stable-id ancestor preservation) is GREEN** — 96× at 1000 nodes, 604× at 10000 nodes, with absolute time *flat* across N (~25-40 ns) confirming genuine O(δ) scaling. -1. **Cross-parse record sharing** — `IdCstNodeBuilder` re-assigns fresh IDs every conversion. The 0.5.0 perf depends on `TreeSplicer.spliceAndShift` being the source of identity-shared trees, not the parser. Phase 1 task #1: confirm `TreeSplicer.spliceAndShift` preserves sibling identity (spec §8 Q3 second sentence). Fix it if not. -2. **`applyIncremental` mutate-in-place semantics** — sandbox API invalidates the receiver. Production needs to choose: copy-on-write via `LongLongMap.copy()` (cheap — primitive arrays vs IdentityHashMap entries) or persistent map. Decision affects `IncrementalSession`'s API shape. -3. **Worst-case splices not benched** — spike measures depth-3, small-pivot. Class-body-scale rewrites need a separate bench. +The fix is small: when `TreeSplicer.spliceAndShift` builds new ancestor records, reuse `oldAncestor.id()` instead of allocating a fresh one. Then `applyIncremental` skips the redundant ancestor rewiring (steps 1 and 3 of spec §2 disappear — only oldPivot's descendants and newPivot's subtree need touching). -After Phase 1 lands and IncrementalParityTest stays green at 22×100, proceed to Phase 2 (Lever B top-down pivot — should dissolve the §6.2 lever-1 puzzle into a 30-line method per spec §3). +**Critical:** splicer + index migrate as a unit. The bench's middle column (`stableIdNoOpt` = stable splicer + original `applyIncremental`) matches productionStyle within noise. The optimized `applyIncremental` that *trusts* ID stability is what realizes the gain. + +Phase 1 sub-phases (per `PHASE-1-PROVE-OUT.md` §"Phase 1 production migration plan"): +1. **1.2** — add `long id` field to production `CstNode` variants; override equals/hashCode to exclude id (spec §7 R1). Migrate hundreds of pattern-match call sites across all 5 modules. +2. **1.3** — thread `IdGenerator` through `PegEngine` + `ParserGenerator` emission templates. +3. **1.4** — modify `TreeSplicer.spliceAndShift` to reuse ancestor IDs (one-line behavioral change). +4. **1.5** — switch `NodeIndex` from `IdentityHashMap` to `LongLongMap`. +5. **1.6** — replace `IncrementalSession.applyIncremental` with Path D's optimized algorithm (step 1 and step 3 deleted). +6. **1.7** — verify parity (897 + IncrementalParityTest 22×100) + bench against 0.4.3 baseline on the 1900-LOC fixture. + +**Estimated 3-5 days focused engineering.** Smaller than the spec's original 1-week estimate because Path A is shelved and Path D is a much smaller change. + +After Phase 1 lands, proceed to Phase 2 (Lever B top-down pivot — should dissolve the §6.2 lever-1 puzzle into a 30-line method per spec §3). ### Items now superseded diff --git a/docs/incremental/PHASE-1-PROVE-OUT.md b/docs/incremental/PHASE-1-PROVE-OUT.md new file mode 100644 index 0000000..e571bc1 --- /dev/null +++ b/docs/incremental/PHASE-1-PROVE-OUT.md @@ -0,0 +1,107 @@ +# Phase 1 Prove-Out — Path A → Path D + +**Date:** 2026-05-07 +**Branch:** release-0.5.0 (commits `8f844eb` through `8b27dd6`) +**Spec:** [`ARCHITECTURE-0.5.0.md`](ARCHITECTURE-0.5.0.md), [`PHASE-0-RESULTS.md`](PHASE-0-RESULTS.md) + +## Verdict + +**Path D is Phase 1's architecture.** Path A (offset decoupling from records) was prove-out RED; Path D (stable-id ancestor preservation) is GREEN with **96× at 1000 nodes, 604× at 10000 nodes** — and a flat absolute cost (~25-40 ns) that confirms genuine O(δ) scaling independent of tree size. + +Phase 0's spike measured 67× speedup on balanced trees. Real CSTs aren't balanced — Java method bodies have flat fanout (1 NonTerminal "Block" with N "Stmt" children). Phase 1's prove-out exposed that the spec §2 algorithm degrades to O(N) on flat trees because step 3 rewires every sibling under a freshly-IDed ancestor. Path D fixes this by reusing old ancestor IDs across splices — the parents-map entries for unchanged siblings stay valid, step 3 disappears. + +## Why Path A was prove-out RED + +**Hypothesis:** removing `SourceSpan` from `CstNode` records eliminates the deep-copy of right-of-edit subtrees in `TreeSplicer.shiftAll` (lines 113-142 in `TreeSplicer.java`). Sibling subtrees retain identity → incremental NodeIndex update stays at O(δ). + +**Bench result (path A — `MidBufferBench`, flat tree, mid-buffer single-Terminal splice):** + +| Tree size | productionStyle (μs) | offsetDecoupled (μs) | Speedup | +|----------:|---------------------:|---------------------:|--------:| +| 100 | 1.272 | 1.153 | 1.10× | +| 1000 | 12.524 | 9.710 | 1.29× | +| 10000 | 81.656 | 102.620 | 0.80× | + +**RED.** The hypothesis was wrong about the bottleneck. Path A *did* preserve right-of-edit sibling identity (correctness test green), but the dominant cost on flat trees isn't the deep-copy — it's `applyIncremental` step 3 rewiring every direct child of the freshly-IDed ancestor. + +The Path A migration (eliminate `span()` from CstNode interface across all 5 modules; introduce SpanIndex; refactor every span-reading site) would have been weeks of cross-cutting refactor for a 1.10-1.29× best-case speedup. Honest dead end. + +## Why Path D is GREEN + +**Hypothesis:** reuse `oldAncestor.id()` when building new ancestor records during splice. Then: +- Old `parents` map entries for unchanged sibling subtrees remain valid (parent's *ID* didn't change, only its record instance did) +- step 3's "rewire children to new ancestor IDs" becomes redundant work +- step 1's "remove oldPath ancestors' up-pointers" becomes wrong (those entries are still valid) + +Optimized `applyIncremental`: +- Skip step 1 oldPath ancestor removal +- Just remove oldPivot + descendants (those ARE dead — newPivot has fresh IDs) +- Step 2: insert newPivot subtree + set newPivot's parent to `oldPath[size-2].id()` +- Skip step 3 entirely + +Total cost: O(oldPivotSize + newPivotSize). Independent of N AND of tree shape. + +**Bench result (`PathDBench`, same flat-tree shape):** + +| Tree size | productionStyle (μs) | stableIdNoOpt (μs) | pathD (μs) | pathD speedup vs production | +|----------:|---------------------:|-------------------:|-----------:|----------------------------:| +| 100 | 0.304 | 0.286 | 0.023 | 13.2× | +| 1000 | 2.697 | 2.843 | 0.028 | **96.3×** | +| 10000 | 24.762 | 23.298 | 0.041 | **604×** | + +**GREEN.** And the absolute time is **flat** across tree sizes (~25-40 ns) — proving the algorithm's cost is purely a function of splice size, not tree size. + +Microcount evidence: 1 `put` + 1 `remove` per `applyIncremental` for a single-node splice on a 1000-node flat tree, vs ~1002 ops for the original algorithm. The 500× microcount ratio tracks the wallclock speedup at the appropriate scale. + +## Critical insight — splicer and index migrate as a unit + +The bench's middle column (`stableIdNoOpt` — stable IDs from splicer, but original spec §2 `applyIncremental`) matches `productionStyle` within noise. Stable ancestor IDs alone don't help. The optimized `applyIncremental` that **trusts** the stability is what realizes the gain. + +This means Phase 1 must migrate splicer + index together, not piecemeal. A staging branch that flips the splicer to stable IDs but keeps the old `applyIncremental` would show no perf change — Phase 1 must commit to both at once. + +## Phase 1 production migration plan (Path D) + +**Total scope: ~3-5 days focused engineering.** Substantially smaller than original spec §6 estimate of 1 week, because: +- No `SpanIndex` needed (Path A is shelved) +- No CstNode interface change beyond adding `id()` accessor +- TreeSplicer change is **one line** (replace fresh ancestor record allocation with reused-id construction) +- `applyIncremental` simplifies (steps 1 and 3 deleted) + +### Sub-phases + +1. **1.2 — production `CstNode` ID field.** Add `long id` as the leading record component to all four variants (`Terminal`, `NonTerminal`, `Token`, `Error`). Override `equals`/`hashCode` to exclude id (per spec §7 R1). Migrate all pattern-match callers across `peglib-core`, `peglib-incremental`, `peglib-formatter`, `peglib-maven-plugin`, `peglib-playground` to include the new component. Hundreds of sites; mostly mechanical sweep. + +2. **1.3 — production `IdGenerator` plumbing.** Thread `IdGenerator` through `PegEngine` and `ParserGenerator`'s emission templates. Per-Session counter is the v0.5.0 default per spec §8 Q1. + +3. **1.4 — `TreeSplicer.spliceAndShift` reuses ancestor IDs.** One-line behavioral change. Add a parity test asserting `oldPath[i].id() == newPath[i].id()` for ancestors. + +4. **1.5 — `NodeIndex` switches from `IdentityHashMap` to `LongLongMap`.** Port the experimental package's `LinearProbingLongLongMap` to production. NodeIndex's API stays put externally. + +5. **1.6 — `IncrementalSession.applyIncremental` adopts Path D's optimized algorithm.** Steps 1 and 3 deleted (per Path D's analysis). Microcount instrumentation in tests as a hard regression net. + +6. **1.7 — verify parity + bench against 0.4.3 baseline on the 1900-LOC fixture.** All 897 tests + IncrementalParityTest 22×100 must stay green. JMH bench replicates the IncrementalSessionBench suite to confirm the speedup translates to real workloads (mid-buffer realistic edits, not just synthetic). + +### Risks specific to Path D + +- **TreeSplicer's `shiftAll` for right-of-edit subtrees still deep-copies.** Path D fixes the *NodeIndex* perf problem but doesn't address record allocation pressure for right-of-edit sibling subtrees. Phase 1 ships with this remaining; if profiling shows it's a tail-latency contributor, address as 0.5.x patch via path-A-style offset decoupling, but only if the bench shows it's worth it. The MidBufferBench Path A run (`phase1-spanindex-results.md`) is the existing data point — at 10k nodes path A was *slower* due to its own overheads. So decoupling for allocation-pressure reasons alone isn't a clear win. + +- **ID semantics shift.** Pre-0.5.0: nodes have JVM identity. Post-0.5.0: nodes have explicit `long id`, which is preserved across edits *unless the splicer says otherwise*. This is the right abstraction for incremental editing (LSP, IDE plugins want to track "is this still the same Block as before this keystroke?"), but downstream callers must understand the new semantic. Document loudly in the migration guide. + +- **Pattern-match call site sweep.** Adding `long id` to record variants breaks every `case Terminal(SourceSpan span, ...)` style match across the codebase. Mechanical but high-volume. Phase 1.2 is the bulk of the work; recommend doing it via a single `jbct-coder` pass with a clean baseline. + +## Files added during prove-out + +In `peglib-incremental/src/main/java/.../experimental/` (sandbox, all additive): + +- Path A (commit `8f844eb`, RED): `SpanIndex`, `OffsetDecoupledNode`, `OffsetDecoupledNodeIndex`, `OffsetDecoupledSplicer`, plus `LongLongMap.forEachEntry` extension. +- Path D (commit `8b27dd6`, GREEN): `StableIdSplicer`, `StableIdNodeIndex`. + +In test sources: corresponding test classes for both paths. + +In `peglib-incremental/src/jmh/java/.../bench/`: `MidBufferBench`, `MidBufferTreeBuilder` (Path A), `PathDBench` (Path D). + +Bench result MDs: +- `docs/bench-results/phase1-spanindex-results.md` (Path A) +- `docs/bench-results/path-d-results.md` (Path D) + +**Sandbox stays sandbox.** No production source touched. Existing 897-test suite unchanged. Sandbox tests: 190 (was 100 pre-Phase-0; +90 from Phase 0 + Path A + Path D combined). From 244377944b99cca653b347a892f2786145a42aa9 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 07:29:03 +0200 Subject: [PATCH 11/46] =?UTF-8?q?feat:=20phase=201.2=20=E2=80=94=20product?= =?UTF-8?q?ion=20CstNode=20gains=20long=20id=20(BREAKING=20record=20shape)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peg/generator/ParserGenerator.java | 144 ++++++++++---- .../pragmatica/peg/parser/ParsingContext.java | 15 ++ .../org/pragmatica/peg/parser/PegEngine.java | 150 +++++++++++--- .../java/org/pragmatica/peg/tree/CstNode.java | 90 ++++++++- .../org/pragmatica/peg/tree/IdGenerator.java | 48 +++++ .../peg/examples/SourceGenerationExample.java | 4 +- .../peg/generator/ParserGeneratorTest.java | 10 +- .../peg/parser/ParseResultTest.java | 2 +- .../peg/parser/ParseRuleAtTest.java | 1 + .../peg/parser/ParsingContextTest.java | 2 +- .../org/pragmatica/peg/formatter/Doc.java | 6 +- .../org/pragmatica/peg/formatter/Docs.java | 6 +- .../peg/formatter/FormatContext.java | 8 +- .../pragmatica/peg/formatter/Formatter.java | 37 ++-- .../peg/formatter/FormatterConfig.java | 2 - .../peg/formatter/TriviaPolicy.java | 8 +- .../peg/formatter/internal/Renderer.java | 31 +-- .../org/pragmatica/peg/incremental/Edit.java | 1 - .../org/pragmatica/peg/incremental/Stats.java | 12 +- .../incremental/experimental/IdCstNode.java | 42 ++-- .../experimental/IdCstNodeBuilder.java | 14 +- .../incremental/experimental/IdGenerator.java | 2 +- .../incremental/experimental/IdNodeIndex.java | 14 +- .../experimental/IdTreeSplicer.java | 37 ++-- .../LinearProbingLongLongMap.java | 8 +- .../experimental/OffsetDecoupledNode.java | 25 +-- .../OffsetDecoupledNodeIndex.java | 7 +- .../experimental/OffsetDecoupledSplicer.java | 43 ++-- .../incremental/experimental/SpanIndex.java | 20 +- .../experimental/StableIdNodeIndex.java | 16 +- .../experimental/StableIdSplicer.java | 37 ++-- .../internal/BackReferenceScan.java | 10 +- .../peg/incremental/internal/CstHash.java | 17 +- .../internal/IncrementalSession.java | 45 +++-- .../incremental/internal/RuleIdRegistry.java | 35 ++-- .../incremental/internal/SessionFactory.java | 52 +++-- .../peg/incremental/internal/TreeSplicer.java | 79 +++++--- .../internal/TriviaRedistribution.java | 187 ++++++++++++------ .../experimental/IdCstNodeBuilderTest.java | 32 +-- .../org/pragmatica/peg/maven/CheckMojo.java | 35 ++-- .../pragmatica/peg/maven/GenerateMojo.java | 33 ++-- .../org/pragmatica/peg/maven/LintMojo.java | 26 +-- .../peg/playground/ParseTracer.java | 36 ++-- .../peg/playground/PlaygroundEngine.java | 15 +- .../peg/playground/PlaygroundRepl.java | 52 +++-- .../peg/playground/PlaygroundServer.java | 81 +++++--- .../org/pragmatica/peg/playground/Stats.java | 2 - .../peg/playground/TraceRecord.java | 12 +- .../peg/playground/internal/JsonDecoder.java | 64 +++--- .../peg/playground/internal/JsonEncoder.java | 116 +++++++---- 50 files changed, 1109 insertions(+), 662 deletions(-) create mode 100644 peglib-core/src/main/java/org/pragmatica/peg/tree/IdGenerator.java diff --git a/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java b/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java index de107f5..494c20f 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java @@ -1704,27 +1704,97 @@ record LineComment(SourceSpan span, String text) implements Trivia {} record BlockComment(SourceSpan span, String text) implements Trivia {} } + /** + * v0.5.0 Phase 1.2 (Lever A): per-parse stable-ID source for CST nodes. + * IDs are unique within a parse-session lineage and excluded from + * structural equality (see CstNode equals/hashCode). + */ + public sealed interface IdGenerator permits IdGenerator.PerSessionCounter { + long next(); + + final class PerSessionCounter implements IdGenerator { + private long next = 0L; + @Override public long next() { return next++; } + } + } + public sealed interface CstNode { + long id(); SourceSpan span(); RuleId rule(); List leadingTrivia(); List trailingTrivia(); - record Terminal(SourceSpan span, RuleId rule, String text, - List leadingTrivia, List trailingTrivia) implements CstNode {} + record Terminal(long id, SourceSpan span, RuleId rule, String text, + List leadingTrivia, List trailingTrivia) implements CstNode { + @Override + public boolean equals(Object other) { + return other instanceof Terminal that + && java.util.Objects.equals(span, that.span) + && java.util.Objects.equals(rule, that.rule) + && java.util.Objects.equals(text, that.text) + && java.util.Objects.equals(leadingTrivia, that.leadingTrivia) + && java.util.Objects.equals(trailingTrivia, that.trailingTrivia); + } + @Override + public int hashCode() { + return java.util.Objects.hash(Terminal.class, span, rule, text, leadingTrivia, trailingTrivia); + } + } - record NonTerminal(SourceSpan span, RuleId rule, List children, - List leadingTrivia, List trailingTrivia) implements CstNode {} + record NonTerminal(long id, SourceSpan span, RuleId rule, List children, + List leadingTrivia, List trailingTrivia) implements CstNode { + @Override + public boolean equals(Object other) { + return other instanceof NonTerminal that + && java.util.Objects.equals(span, that.span) + && java.util.Objects.equals(rule, that.rule) + && java.util.Objects.equals(children, that.children) + && java.util.Objects.equals(leadingTrivia, that.leadingTrivia) + && java.util.Objects.equals(trailingTrivia, that.trailingTrivia); + } + @Override + public int hashCode() { + return java.util.Objects.hash(NonTerminal.class, span, rule, children, leadingTrivia, trailingTrivia); + } + } - record Token(SourceSpan span, RuleId rule, String text, - List leadingTrivia, List trailingTrivia) implements CstNode {} + record Token(long id, SourceSpan span, RuleId rule, String text, + List leadingTrivia, List trailingTrivia) implements CstNode { + @Override + public boolean equals(Object other) { + return other instanceof Token that + && java.util.Objects.equals(span, that.span) + && java.util.Objects.equals(rule, that.rule) + && java.util.Objects.equals(text, that.text) + && java.util.Objects.equals(leadingTrivia, that.leadingTrivia) + && java.util.Objects.equals(trailingTrivia, that.trailingTrivia); + } + @Override + public int hashCode() { + return java.util.Objects.hash(Token.class, span, rule, text, leadingTrivia, trailingTrivia); + } + } """); // Only add Error node type for ADVANCED mode (used in error recovery) if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" - record Error(SourceSpan span, String skippedText, String expected, + record Error(long id, SourceSpan span, String skippedText, String expected, List leadingTrivia, List trailingTrivia) implements CstNode { @Override public RuleId rule() { return null; } + @Override + public boolean equals(Object other) { + return other instanceof Error that + && java.util.Objects.equals(span, that.span) + && java.util.Objects.equals(skippedText, that.skippedText) + && java.util.Objects.equals(expected, that.expected) + && java.util.Objects.equals(leadingTrivia, that.leadingTrivia) + && java.util.Objects.equals(trailingTrivia, that.trailingTrivia); + } + @Override + public int hashCode() { + return java.util.Objects.hash(Error.class, span, skippedText, expected, leadingTrivia, trailingTrivia); + } } """); } @@ -2030,6 +2100,11 @@ private void generateCstParseContext(StringBuilder sb) { // leadingTrivia. Backtracking combinators save/restore snapshots. private final ArrayList pendingLeadingTrivia = new ArrayList<>(); + // v0.5.0 Phase 1.2 (Lever A): per-parse stable-ID allocator. + // Reset on every init() so each parse session has its own + // monotonically increasing ID lineage starting from 0. + private IdGenerator idGen = new IdGenerator.PerSessionCounter(); + /** * Enable or disable packrat memoization. * Disabling may reduce memory usage for large inputs. @@ -2109,6 +2184,7 @@ private void init(String input) { this.furthestFailure = Option.none(); this.furthestExpected = Option.none(); this.pendingLeadingTrivia.clear(); + this.idGen = new IdGenerator.PerSessionCounter(); """); if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" @@ -2380,7 +2456,7 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) { var skippedSpan = skipToRecoveryPoint(); if (skippedSpan.length() > 0) { var skippedText = skippedSpan.extract(input); - var errorNode = new CstNode.Error(skippedSpan, skippedText, expected, List.of(), List.of()); + var errorNode = new CstNode.Error(idGen.next(), skippedSpan, skippedText, expected, List.of(), List.of()); return ParseResultWithDiagnostics.withErrors(Option.some(errorNode), diagnostics, input); } return ParseResultWithDiagnostics.withErrors(Option.none(), diagnostics, input); @@ -3475,7 +3551,7 @@ private void generateCstExpressionCode(StringBuilder sb, .append(zomStart) .append(", location());\n"); sb.append(pad) - .append(" children.add(new CstNode.NonTerminal(zomSpan") + .append(" children.add(new CstNode.NonTerminal(idGen.next(), zomSpan") .append(id) .append(", __ruleName, zomChildren") .append(id) @@ -3657,7 +3733,7 @@ private void generateCstExpressionCode(StringBuilder sb, .append(oomStart) .append(", location());\n"); sb.append(pad) - .append(" children.add(new CstNode.NonTerminal(oomSpan") + .append(" children.add(new CstNode.NonTerminal(idGen.next(), oomSpan") .append(id) .append(", __ruleName, oomChildren") .append(id) @@ -3946,7 +4022,7 @@ private void generateCstExpressionCode(StringBuilder sb, .append(repStart) .append(", location());\n"); sb.append(pad) - .append(" children.add(new CstNode.NonTerminal(repSpan") + .append(" children.add(new CstNode.NonTerminal(idGen.next(), repSpan") .append(id) .append(", __ruleName, repChildren") .append(id) @@ -4123,7 +4199,7 @@ private void generateCstExpressionCode(StringBuilder sb, sb.append(pad) .append(" var tbNode") .append(id) - .append(" = new CstNode.Token(tbSpan") + .append(" = new CstNode.Token(idGen.next(), tbSpan") .append(id) .append(", ") .append(tokenRuleId) @@ -4559,21 +4635,21 @@ private CstNode wrapWithRuleName(CstParseResult result, List children, if (result.node.isPresent()) { var inner = result.node.unwrap(); return switch (inner) { - case CstNode.Token tok -> new CstNode.Token(span, ruleId, tok.text(), leadingTrivia, List.of()); - case CstNode.Terminal t -> new CstNode.Terminal(span, ruleId, t.text(), leadingTrivia, List.of()); - case CstNode.NonTerminal nt -> new CstNode.NonTerminal(span, ruleId, nt.children(), leadingTrivia, List.of()); + case CstNode.Token tok -> new CstNode.Token(tok.id(), span, ruleId, tok.text(), leadingTrivia, List.of()); + case CstNode.Terminal t -> new CstNode.Terminal(t.id(), span, ruleId, t.text(), leadingTrivia, List.of()); + case CstNode.NonTerminal nt -> new CstNode.NonTerminal(nt.id(), span, ruleId, nt.children(), leadingTrivia, List.of()); """); // Add Error case only for ADVANCED mode if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" - case CstNode.Error err -> new CstNode.NonTerminal(span, ruleId, children, leadingTrivia, List.of()); + case CstNode.Error err -> new CstNode.NonTerminal(err.id(), span, ruleId, children, leadingTrivia, List.of()); """); } sb.append(""" }; } // No inner node — wrap children in NonTerminal - return new CstNode.NonTerminal(span, ruleId, children, leadingTrivia, List.of()); + return new CstNode.NonTerminal(idGen.next(), span, ruleId, children, leadingTrivia, List.of()); } private Trivia classifyTrivia(SourceSpan span, String text) { @@ -4628,19 +4704,19 @@ private CstNode attachLeadingTrivia(CstNode node, List leadingTrivia) { } return switch (node) { case CstNode.Terminal t -> new CstNode.Terminal( - t.span(), t.rule(), t.text(), leadingTrivia, t.trailingTrivia() + t.id(), t.span(), t.rule(), t.text(), leadingTrivia, t.trailingTrivia() ); case CstNode.NonTerminal nt -> new CstNode.NonTerminal( - nt.span(), nt.rule(), nt.children(), leadingTrivia, nt.trailingTrivia() + nt.id(), nt.span(), nt.rule(), nt.children(), leadingTrivia, nt.trailingTrivia() ); case CstNode.Token tok -> new CstNode.Token( - tok.span(), tok.rule(), tok.text(), leadingTrivia, tok.trailingTrivia() + tok.id(), tok.span(), tok.rule(), tok.text(), leadingTrivia, tok.trailingTrivia() ); """); if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" case CstNode.Error err -> new CstNode.Error( - err.span(), err.skippedText(), err.expected(), leadingTrivia, err.trailingTrivia() + err.id(), err.span(), err.skippedText(), err.expected(), leadingTrivia, err.trailingTrivia() ); """); } @@ -4654,20 +4730,20 @@ private CstNode attachTrailingTrivia(CstNode node, List trailingTrivia) } return switch (node) { case CstNode.Terminal t -> new CstNode.Terminal( - t.span(), t.rule(), t.text(), t.leadingTrivia(), trailingTrivia + t.id(), t.span(), t.rule(), t.text(), t.leadingTrivia(), trailingTrivia ); case CstNode.NonTerminal nt -> new CstNode.NonTerminal( - nt.span(), nt.rule(), nt.children(), nt.leadingTrivia(), trailingTrivia + nt.id(), nt.span(), nt.rule(), nt.children(), nt.leadingTrivia(), trailingTrivia ); case CstNode.Token tok -> new CstNode.Token( - tok.span(), tok.rule(), tok.text(), tok.leadingTrivia(), trailingTrivia + tok.id(), tok.span(), tok.rule(), tok.text(), tok.leadingTrivia(), trailingTrivia ); """); // Add Error case only for ADVANCED mode if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" case CstNode.Error err -> new CstNode.Error( - err.span(), err.skippedText(), err.expected(), err.leadingTrivia(), trailingTrivia + err.id(), err.span(), err.skippedText(), err.expected(), err.leadingTrivia(), trailingTrivia ); """); } @@ -4692,7 +4768,7 @@ private CstNode attachTrailingToTail(CstNode node, List trivia) { if (ntChildren.isEmpty()) { var combined = concatTrivia(nt.trailingTrivia(), trivia); yield new CstNode.NonTerminal( - nt.span(), nt.rule(), ntChildren, nt.leadingTrivia(), combined + nt.id(), nt.span(), nt.rule(), ntChildren, nt.leadingTrivia(), combined ); } var newChildren = new ArrayList(ntChildren); @@ -4700,20 +4776,20 @@ private CstNode attachTrailingToTail(CstNode node, List trivia) { var lastChild = newChildren.get(lastIdx); newChildren.set(lastIdx, attachTrailingToTail(lastChild, trivia)); yield new CstNode.NonTerminal( - nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia() + nt.id(), nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia() ); } case CstNode.Terminal t -> new CstNode.Terminal( - t.span(), t.rule(), t.text(), t.leadingTrivia(), concatTrivia(t.trailingTrivia(), trivia) + t.id(), t.span(), t.rule(), t.text(), t.leadingTrivia(), concatTrivia(t.trailingTrivia(), trivia) ); case CstNode.Token tok -> new CstNode.Token( - tok.span(), tok.rule(), tok.text(), tok.leadingTrivia(), concatTrivia(tok.trailingTrivia(), trivia) + tok.id(), tok.span(), tok.rule(), tok.text(), tok.leadingTrivia(), concatTrivia(tok.trailingTrivia(), trivia) ); """); if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" case CstNode.Error err -> new CstNode.Error( - err.span(), err.skippedText(), err.expected(), err.leadingTrivia(), concatTrivia(err.trailingTrivia(), trivia) + err.id(), err.span(), err.skippedText(), err.expected(), err.leadingTrivia(), concatTrivia(err.trailingTrivia(), trivia) ); """); } @@ -4863,7 +4939,7 @@ private void emitMatchLiteralCst(StringBuilder sb) { sb.append(" var span = SourceSpan.sourceSpan(startLoc, ") .append(endLocVar) .append(");\n"); - sb.append(" var node = new CstNode.Terminal(span, RULE_PEG_LITERAL, text, takePendingLeading(), List.of());\n"); + sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_LITERAL, text, takePendingLeading(), List.of());\n"); sb.append(" return CstParseResult.success(node, text, ") .append(endLocVar) .append(");\n"); @@ -4910,7 +4986,7 @@ private void emitMatchDictionaryCst(StringBuilder sb) { sb.append(" var span = SourceSpan.sourceSpan(startLoc, ") .append(endLocVar) .append(");\n"); - sb.append(" var node = new CstNode.Terminal(span, RULE_PEG_LITERAL, longestMatch, takePendingLeading(), List.of());\n"); + sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_LITERAL, longestMatch, takePendingLeading(), List.of());\n"); sb.append(" return CstParseResult.success(node, longestMatch, ") .append(endLocVar) .append(");\n"); @@ -4962,7 +5038,7 @@ private void emitMatchCharClassCst(StringBuilder sb) { sb.append(" var span = SourceSpan.sourceSpan(startLoc, ") .append(endLocVar) .append(");\n"); - sb.append(" var node = new CstNode.Terminal(span, RULE_PEG_CHAR_CLASS, text, takePendingLeading(), List.of());\n"); + sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_CHAR_CLASS, text, takePendingLeading(), List.of());\n"); sb.append(" return CstParseResult.success(node, text, ") .append(endLocVar) .append(");\n"); @@ -5002,7 +5078,7 @@ private void emitMatchAnyCst(StringBuilder sb) { sb.append(" var span = SourceSpan.sourceSpan(startLoc, ") .append(endLocVar) .append(");\n"); - sb.append(" var node = new CstNode.Terminal(span, RULE_PEG_ANY, text, takePendingLeading(), List.of());\n"); + sb.append(" var node = new CstNode.Terminal(idGen.next(), span, RULE_PEG_ANY, text, takePendingLeading(), List.of());\n"); sb.append(" return CstParseResult.success(node, text, ") .append(endLocVar) .append(");\n"); diff --git a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java index 2138f8a..38dd9fc 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java @@ -4,6 +4,7 @@ import org.pragmatica.peg.error.Diagnostic; import org.pragmatica.peg.error.RecoveryStrategy; import org.pragmatica.peg.grammar.Grammar; +import org.pragmatica.peg.tree.IdGenerator; import org.pragmatica.peg.tree.SourceLocation; import org.pragmatica.peg.tree.SourceSpan; import org.pragmatica.peg.tree.Trivia; @@ -28,6 +29,11 @@ public final class ParsingContext { private final Option> ruleIds; private final Map captures; + // Phase 1.2 (v0.5.0): per-session ID generator. Allocated once per parse; + // every CstNode constructed by PegEngine pulls its id from here. Spec + // §2 Lever A; see docs/incremental/ARCHITECTURE-0.5.0.md. + private final IdGenerator idGen; + private int pos; private int line; private int column; @@ -89,6 +95,7 @@ private ParsingContext(String input, Grammar grammar, ParserConfig config) { : Option.none(); this.captures = new HashMap<>(); this.diagnostics = new ArrayList<>(); + this.idGen = new IdGenerator.PerSessionCounter(); this.pos = 0; this.line = 1; this.column = 1; @@ -649,6 +656,14 @@ public ParserConfig config() { return config; } + /** + * Phase 1.2 (v0.5.0): the per-session ID generator. PegEngine pulls a + * fresh id from this for every {@code CstNode} it constructs. + */ + public IdGenerator idGen() { + return idGen; + } + // === Span Creation === public SourceSpan spanFrom(SourceLocation start) { return SourceSpan.sourceSpan(start, location()); diff --git a/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java b/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java index 1198003..3b92788 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java @@ -602,7 +602,13 @@ private ParseResultWithDiagnostics parseWithRecovery(ParsingContext ctx, Rule st skippedSpan.end() .offset()); var errorNode = new CstNode.Error( - skippedSpan, skippedText, failureExpected, leadingTrivia, List.of()); + ctx.idGen() + .next(), + skippedSpan, + skippedText, + failureExpected, + leadingTrivia, + List.of()); fragments.add(errorNode); } // Skip the recovery character itself (;, }, newline, etc.) @@ -627,7 +633,13 @@ private ParseResultWithDiagnostics parseWithRecovery(ParsingContext ctx, Rule st .span(); var fullSpan = SourceSpan.sourceSpan(firstSpan.start(), lastSpan.end()); rootNode = Option.some(new CstNode.NonTerminal( - fullSpan, startRule.name(), fragments, List.of(), trailingTrivia)); + ctx.idGen() + .next(), + fullSpan, + startRule.name(), + fragments, + List.of(), + trailingTrivia)); } return ParseResultWithDiagnostics.withErrors(rootNode, ctx.diagnostics(), input); } @@ -1089,7 +1101,13 @@ private ParseResult parseTokenBoundaryWithActions(ParsingContext ctx, // Set token capture so $0 returns this captured text tokenCapture[0] = text; var span = ctx.spanFrom(startLoc); - var node = new CstNode.Token(span, ruleName, text, ctx.takePendingLeadingTrivia(), List.of()); + var node = new CstNode.Token(ctx.idGen() + .next(), + span, + ruleName, + text, + ctx.takePendingLeadingTrivia(), + List.of()); return ParseResult.Success.success(node, ctx.location()); } finally{ ctx.exitTokenBoundary(); @@ -1159,7 +1177,13 @@ private ParseResult parseLiteral(ParsingContext ctx, Expression.Literal lit) { advanceLiteral(ctx, text, len); var endLoc = ctx.location(); var span = SourceSpan.sourceSpan(startLoc, endLoc); - var node = new CstNode.Terminal(span, "", text, ctx.takePendingLeadingTrivia(), List.of()); + var node = new CstNode.Terminal(ctx.idGen() + .next(), + span, + "", + text, + ctx.takePendingLeadingTrivia(), + List.of()); return new ParseResult.Success(node, endLoc, List.of(), Option.none()); } @@ -1219,7 +1243,13 @@ private ParseResult parseDictionary(ParsingContext ctx, Expression.Dictionary di advanceLiteral(ctx, matched, longestLen); var endLoc = ctx.location(); var span = SourceSpan.sourceSpan(startLoc, endLoc); - var node = new CstNode.Terminal(span, "", matched, ctx.takePendingLeadingTrivia(), List.of()); + var node = new CstNode.Terminal(ctx.idGen() + .next(), + span, + "", + matched, + ctx.takePendingLeadingTrivia(), + List.of()); return new ParseResult.Success(node, endLoc, List.of(), Option.none()); } @@ -1269,7 +1299,13 @@ private ParseResult parseCharClass(ParsingContext ctx, Expression.CharClass cc) ctx.advance(); var endLoc = ctx.location(); var span = SourceSpan.sourceSpan(startLoc, endLoc); - var node = new CstNode.Terminal(span, "", String.valueOf(c), ctx.takePendingLeadingTrivia(), List.of()); + var node = new CstNode.Terminal(ctx.idGen() + .next(), + span, + "", + String.valueOf(c), + ctx.takePendingLeadingTrivia(), + List.of()); return new ParseResult.Success(node, endLoc, List.of(), Option.none()); } @@ -1382,7 +1418,13 @@ private ParseResult parseAny(ParsingContext ctx, Expression.Any any) { char c = ctx.advance(); var endLoc = ctx.location(); var span = SourceSpan.sourceSpan(startLoc, endLoc); - var node = new CstNode.Terminal(span, "", String.valueOf(c), ctx.takePendingLeadingTrivia(), List.of()); + var node = new CstNode.Terminal(ctx.idGen() + .next(), + span, + "", + String.valueOf(c), + ctx.takePendingLeadingTrivia(), + List.of()); return new ParseResult.Success(node, endLoc, List.of(), Option.none()); } @@ -1445,7 +1487,13 @@ private ParseResult parseTokenBoundary(ParsingContext ctx, Expression.TokenBound var endPos = ctx.pos(); var text = ctx.substring(startPos, endPos); var span = ctx.spanFrom(startLoc); - var node = new CstNode.Token(span, ruleName, text, ctx.takePendingLeadingTrivia(), List.of()); + var node = new CstNode.Token(ctx.idGen() + .next(), + span, + ruleName, + text, + ctx.takePendingLeadingTrivia(), + List.of()); return ParseResult.Success.success(node, ctx.location()); } finally{ ctx.exitTokenBoundary(); @@ -1502,7 +1550,13 @@ private ParseResult parseBackReference(ParsingContext ctx, Expression.BackRefere ctx.advance(); } var span = ctx.spanFrom(startLoc); - var node = new CstNode.Terminal(span, "", text, ctx.takePendingLeadingTrivia(), List.of()); + var node = new CstNode.Terminal(ctx.idGen() + .next(), + span, + "", + text, + ctx.takePendingLeadingTrivia(), + List.of()); return ParseResult.Success.success(node, ctx.location()); } @@ -1647,7 +1701,13 @@ private ParseResult parseSequenceWithMode(ParsingContext ctx, } } var span = ctx.spanFrom(startLoc); - var node = new CstNode.NonTerminal(span, ruleName, children, List.of(), List.of()); + var node = new CstNode.NonTerminal(ctx.idGen() + .next(), + span, + ruleName, + children, + List.of(), + List.of()); return ParseResult.Success.success(node, ctx.location()); } @@ -1749,7 +1809,13 @@ private ParseResult parseZeroOrMoreWithMode(ParsingContext ctx, if (children.size() == 1) { return ParseResult.Success.success(children.getFirst(), ctx.location()); } - var node = new CstNode.NonTerminal(span, ruleName, children, List.of(), List.of()); + var node = new CstNode.NonTerminal(ctx.idGen() + .next(), + span, + ruleName, + children, + List.of(), + List.of()); return ParseResult.Success.success(node, ctx.location()); } @@ -1814,7 +1880,13 @@ private ParseResult parseOneOrMoreWithMode(ParsingContext ctx, if (children.size() == 1) { return ParseResult.Success.success(children.getFirst(), ctx.location()); } - var node = new CstNode.NonTerminal(span, ruleName, children, List.of(), List.of()); + var node = new CstNode.NonTerminal(ctx.idGen() + .next(), + span, + ruleName, + children, + List.of(), + List.of()); return ParseResult.Success.success(node, ctx.location()); } @@ -1839,7 +1911,13 @@ private ParseResult parseOptionalWithMode(ParsingContext ctx, ctx.restoreLocation(startLoc); ctx.restorePendingLeadingTrivia(entryPendingSnapshot); var span = SourceSpan.sourceSpan(startLoc); - var node = new CstNode.NonTerminal(span, ruleName, List.of(), List.of(), List.of()); + var node = new CstNode.NonTerminal(ctx.idGen() + .next(), + span, + ruleName, + List.of(), + List.of(), + List.of()); return ParseResult.Success.success(node, ctx.location()); } @@ -1906,7 +1984,13 @@ private ParseResult parseRepetitionWithMode(ParsingContext ctx, if (children.size() == 1) { return ParseResult.Success.success(children.getFirst(), ctx.location()); } - var node = new CstNode.NonTerminal(span, ruleName, children, List.of(), List.of()); + var node = new CstNode.NonTerminal(ctx.idGen() + .next(), + span, + ruleName, + children, + List.of(), + List.of()); return ParseResult.Success.success(node, ctx.location()); } @@ -1984,13 +2068,13 @@ private static List concatTrivia(List first, List second private CstNode wrapWithRuleName(CstNode node, String ruleName, List leadingTrivia) { return switch (node) { case CstNode.Terminal t -> new CstNode.Terminal( - t.span(), ruleName, t.text(), leadingTrivia, t.trailingTrivia()); + t.id(), t.span(), ruleName, t.text(), leadingTrivia, t.trailingTrivia()); case CstNode.NonTerminal nt -> new CstNode.NonTerminal( - nt.span(), ruleName, nt.children(), leadingTrivia, nt.trailingTrivia()); + nt.id(), nt.span(), ruleName, nt.children(), leadingTrivia, nt.trailingTrivia()); case CstNode.Token tok -> new CstNode.Token( - tok.span(), ruleName, tok.text(), leadingTrivia, tok.trailingTrivia()); + tok.id(), tok.span(), ruleName, tok.text(), leadingTrivia, tok.trailingTrivia()); case CstNode.Error err -> new CstNode.Error( - err.span(), err.skippedText(), err.expected(), leadingTrivia, err.trailingTrivia()); + err.id(), err.span(), err.skippedText(), err.expected(), leadingTrivia, err.trailingTrivia()); }; } @@ -2005,13 +2089,13 @@ private CstNode attachLeadingTrivia(CstNode node, List leadingTrivia) { } return switch (node) { case CstNode.Terminal t -> new CstNode.Terminal( - t.span(), t.rule(), t.text(), leadingTrivia, t.trailingTrivia()); + t.id(), t.span(), t.rule(), t.text(), leadingTrivia, t.trailingTrivia()); case CstNode.NonTerminal nt -> new CstNode.NonTerminal( - nt.span(), nt.rule(), nt.children(), leadingTrivia, nt.trailingTrivia()); + nt.id(), nt.span(), nt.rule(), nt.children(), leadingTrivia, nt.trailingTrivia()); case CstNode.Token tok -> new CstNode.Token( - tok.span(), tok.rule(), tok.text(), leadingTrivia, tok.trailingTrivia()); + tok.id(), tok.span(), tok.rule(), tok.text(), leadingTrivia, tok.trailingTrivia()); case CstNode.Error err -> new CstNode.Error( - err.span(), err.skippedText(), err.expected(), leadingTrivia, err.trailingTrivia()); + err.id(), err.span(), err.skippedText(), err.expected(), leadingTrivia, err.trailingTrivia()); }; } @@ -2024,13 +2108,13 @@ private CstNode attachTrailingTrivia(CstNode node, List trailingTrivia) } return switch (node) { case CstNode.Terminal t -> new CstNode.Terminal( - t.span(), t.rule(), t.text(), t.leadingTrivia(), trailingTrivia); + t.id(), t.span(), t.rule(), t.text(), t.leadingTrivia(), trailingTrivia); case CstNode.NonTerminal nt -> new CstNode.NonTerminal( - nt.span(), nt.rule(), nt.children(), nt.leadingTrivia(), trailingTrivia); + nt.id(), nt.span(), nt.rule(), nt.children(), nt.leadingTrivia(), trailingTrivia); case CstNode.Token tok -> new CstNode.Token( - tok.span(), tok.rule(), tok.text(), tok.leadingTrivia(), trailingTrivia); + tok.id(), tok.span(), tok.rule(), tok.text(), tok.leadingTrivia(), trailingTrivia); case CstNode.Error err -> new CstNode.Error( - err.span(), err.skippedText(), err.expected(), err.leadingTrivia(), trailingTrivia); + err.id(), err.span(), err.skippedText(), err.expected(), err.leadingTrivia(), trailingTrivia); }; } @@ -2052,20 +2136,26 @@ private CstNode attachTrailingToTail(CstNode node, List trivia) { if (children.isEmpty()) { var combined = combineTrivia(nt.trailingTrivia(), trivia); yield new CstNode.NonTerminal( - nt.span(), nt.rule(), children, nt.leadingTrivia(), combined); + nt.id(), nt.span(), nt.rule(), children, nt.leadingTrivia(), combined); } var newChildren = new ArrayList<>(children); var lastIdx = newChildren.size() - 1; var lastChild = newChildren.get(lastIdx); newChildren.set(lastIdx, attachTrailingToTail(lastChild, trivia)); yield new CstNode.NonTerminal( - nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia()); + nt.id(), nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia()); } case CstNode.Terminal t -> new CstNode.Terminal( - t.span(), t.rule(), t.text(), t.leadingTrivia(), combineTrivia(t.trailingTrivia(), trivia)); + t.id(), t.span(), t.rule(), t.text(), t.leadingTrivia(), combineTrivia(t.trailingTrivia(), trivia)); case CstNode.Token tok -> new CstNode.Token( - tok.span(), tok.rule(), tok.text(), tok.leadingTrivia(), combineTrivia(tok.trailingTrivia(), trivia)); + tok.id(), + tok.span(), + tok.rule(), + tok.text(), + tok.leadingTrivia(), + combineTrivia(tok.trailingTrivia(), trivia)); case CstNode.Error err -> new CstNode.Error( + err.id(), err.span(), err.skippedText(), err.expected(), diff --git a/peglib-core/src/main/java/org/pragmatica/peg/tree/CstNode.java b/peglib-core/src/main/java/org/pragmatica/peg/tree/CstNode.java index fd3079b..b2e9f71 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/tree/CstNode.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/tree/CstNode.java @@ -1,12 +1,27 @@ package org.pragmatica.peg.tree; import java.util.List; +import java.util.Objects; /** * Concrete Syntax Tree node - lossless representation preserving all source details. * Can be used for formatting, linting, and round-trip transformations. + * + *

v0.5.0 (Phase 1.2): every variant carries a stable {@code long id} for + * downstream incremental-parser machinery (Lever A in + * {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2). IDs are produced by + * {@link IdGenerator} during parse and must be excluded from structural + * equality (§7 R1) so two parses of the same input produce equal trees. */ public sealed interface CstNode { + /** + * Stable identifier within the owning parse-session's lineage. + * + *

Excluded from {@code equals}/{@code hashCode}: see §7 R1 of the v0.5.0 + * spec. Two structurally identical nodes with different IDs are equal. + */ + long id(); + /** * The source span covered by this node (excluding trivia). */ @@ -31,44 +46,89 @@ public sealed interface CstNode { * Terminal node - a leaf that matched literal text. */ record Terminal( + long id, SourceSpan span, String rule, String text, List leadingTrivia, - List trailingTrivia) implements CstNode {} + List trailingTrivia) implements CstNode { + @Override + public boolean equals(Object other) { + return other instanceof Terminal that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(text, + that.text) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(Terminal.class, span, rule, text, leadingTrivia, trailingTrivia); + } + } /** * Non-terminal node - an interior node with children. */ record NonTerminal( + long id, SourceSpan span, String rule, List children, List leadingTrivia, - List trailingTrivia) implements CstNode {} + List trailingTrivia) implements CstNode { + @Override + public boolean equals(Object other) { + return other instanceof NonTerminal that && Objects.equals(span, that.span) && Objects.equals(rule, + that.rule) && Objects.equals(children, + that.children) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(NonTerminal.class, span, rule, children, leadingTrivia, trailingTrivia); + } + } /** * Token node - result of token boundary operator {@code < >}. * Captures the matched text as a single unit. */ record Token( + long id, SourceSpan span, String rule, String text, List leadingTrivia, - List trailingTrivia) implements CstNode {} + List trailingTrivia) implements CstNode { + @Override + public boolean equals(Object other) { + return other instanceof Token that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(text, + that.text) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(Token.class, span, rule, text, leadingTrivia, trailingTrivia); + } + } /** * Error node - represents unparseable input during error recovery. * Contains the skipped text and what was expected at this position. * - * @param span Source span of the error region - * @param skippedText The input that couldn't be parsed - * @param expected What the parser expected at this position - * @param leadingTrivia Trivia before the error - * @param trailingTrivia Trivia after the error (usually empty) + * @param id Stable session-scoped identifier + * @param span Source span of the error region + * @param skippedText The input that couldn't be parsed + * @param expected What the parser expected at this position + * @param leadingTrivia Trivia before the error + * @param trailingTrivia Trivia after the error (usually empty) */ record Error( + long id, SourceSpan span, String skippedText, String expected, @@ -78,5 +138,19 @@ record Error( public String rule() { return ""; } + + @Override + public boolean equals(Object other) { + return other instanceof Error that && Objects.equals(span, that.span) && Objects.equals(skippedText, + that.skippedText) && Objects.equals(expected, + that.expected) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); + } + + @Override + public int hashCode() { + return Objects.hash(Error.class, span, skippedText, expected, leadingTrivia, trailingTrivia); + } } } diff --git a/peglib-core/src/main/java/org/pragmatica/peg/tree/IdGenerator.java b/peglib-core/src/main/java/org/pragmatica/peg/tree/IdGenerator.java new file mode 100644 index 0000000..8b883e6 --- /dev/null +++ b/peglib-core/src/main/java/org/pragmatica/peg/tree/IdGenerator.java @@ -0,0 +1,48 @@ +package org.pragmatica.peg.tree; +/** + * Source of stable {@code long} identifiers for CST nodes. + * + *

Phase 1.2 of the v0.5.0 incremental-native rework promotes the sandbox + * Phase-0 spike (Lever A in {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2) + * into the production tree. Each {@link org.pragmatica.peg.parser.ParsingContext} + * instantiates its own generator at parse start; IDs are unique within that + * parse session's lineage but not across sessions. + * + *

The interface exists so that future strategies — a process-global + * {@link java.util.concurrent.atomic.AtomicLong}, content-derived hashes, or + * an external store — can swap in without disturbing call sites. Only + * {@link PerSessionCounter} is permitted today (YAGNI; add variants when a + * concrete need lands). + * + *

Implementations are not required to be thread-safe. + * Parsing is single-threaded; the engine never races on ID allocation. + * + * @since 0.5.0 + */ +public sealed interface IdGenerator permits IdGenerator.PerSessionCounter { + /** + * Produce the next ID. Successive calls on the same instance return + * strictly monotonically increasing values starting from 0. + */ + long next(); + + /** + * Single-threaded counter. The first call returns 0, then 1, 2, … + * + *

Overflow is theoretically possible after 2^63 calls but practically + * unreachable: at one ID per nanosecond it would take ~292 years to wrap. + * No overflow check is performed. + */ + final class PerSessionCounter implements IdGenerator { + private long next; + + public PerSessionCounter() { + this.next = 0L; + } + + @Override + public long next() { + return next++ ; + } + } +} diff --git a/peglib-core/src/test/java/org/pragmatica/peg/examples/SourceGenerationExample.java b/peglib-core/src/test/java/org/pragmatica/peg/examples/SourceGenerationExample.java index e019fd8..f16085a 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/examples/SourceGenerationExample.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/examples/SourceGenerationExample.java @@ -449,8 +449,8 @@ void generateCstParser_withAdvancedErrorReporting() { // Verify parseWithDiagnostics method assertThat(source).contains("public ParseResultWithDiagnostics parseWithDiagnostics(String input)"); - // Verify Error node type for CST - assertThat(source).contains("record Error(SourceSpan span, String skippedText"); + // Verify Error node type for CST (v0.5.0 Phase 1.2: long id is the first component) + assertThat(source).contains("record Error(long id, SourceSpan span, String skippedText"); // Verify error recovery helpers assertThat(source).contains("skipToRecoveryPoint"); diff --git a/peglib-core/src/test/java/org/pragmatica/peg/generator/ParserGeneratorTest.java b/peglib-core/src/test/java/org/pragmatica/peg/generator/ParserGeneratorTest.java index c141b32..d50c61e 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/generator/ParserGeneratorTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/generator/ParserGeneratorTest.java @@ -255,8 +255,8 @@ void generateCst_advancedMode_includesErrorNodeType() { var source = result.onFailure(cause -> fail(cause.message())).unwrap(); - // Should include Error node type for CST - assertThat(source).contains("record Error(SourceSpan span, String skippedText"); + // Should include Error node type for CST (v0.5.0 Phase 1.2: long id is the first component) + assertThat(source).contains("record Error(long id, SourceSpan span, String skippedText"); } @Test @@ -303,8 +303,8 @@ void generateCst_advancedMode_includesErrorCaseInSwitch() { var source = result.onFailure(cause -> fail(cause.message())).unwrap(); - // CstNode.Error should be defined - assertThat(source).contains("record Error(SourceSpan span, String skippedText, String expected,"); + // CstNode.Error should be defined (v0.5.0 Phase 1.2: long id is the first component) + assertThat(source).contains("record Error(long id, SourceSpan span, String skippedText, String expected,"); // attachTrailingTrivia switch should handle Error case assertThat(source).contains("case CstNode.Error err -> new CstNode.Error("); @@ -323,7 +323,7 @@ void generateCst_basicMode_noErrorCaseInSwitch() { var source = result.onFailure(cause -> fail(cause.message())).unwrap(); // CstNode.Error should NOT be defined in BASIC mode - assertFalse(source.contains("record Error(SourceSpan span, String skippedText")); + assertFalse(source.contains("record Error(long id, SourceSpan span, String skippedText")); // No Error case in switch assertFalse(source.contains("case CstNode.Error")); diff --git a/peglib-core/src/test/java/org/pragmatica/peg/parser/ParseResultTest.java b/peglib-core/src/test/java/org/pragmatica/peg/parser/ParseResultTest.java index 71e80e2..6a0f36e 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/parser/ParseResultTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/parser/ParseResultTest.java @@ -203,6 +203,6 @@ void semanticValue_optionBehavior() { // === Helper Methods === private static CstNode.Terminal createTerminal(String text) { - return new CstNode.Terminal(SPAN, "Test", text, List.of(), List.of()); + return new CstNode.Terminal(0L, SPAN, "Test", text, List.of(), List.of()); } } diff --git a/peglib-core/src/test/java/org/pragmatica/peg/parser/ParseRuleAtTest.java b/peglib-core/src/test/java/org/pragmatica/peg/parser/ParseRuleAtTest.java index 5d0ed79..dd457f9 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/parser/ParseRuleAtTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/parser/ParseRuleAtTest.java @@ -150,6 +150,7 @@ void partialParse_accessorsExposeFields() { @Test void partialParse_equalityAndHashCode() { CstNode node = new CstNode.Terminal( + 0L, org.pragmatica.peg.tree.SourceSpan.sourceSpan( org.pragmatica.peg.tree.SourceLocation.sourceLocation(1, 1, 0), org.pragmatica.peg.tree.SourceLocation.sourceLocation(1, 3, 2)), diff --git a/peglib-core/src/test/java/org/pragmatica/peg/parser/ParsingContextTest.java b/peglib-core/src/test/java/org/pragmatica/peg/parser/ParsingContextTest.java index 8633f69..fb42cb6 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/parser/ParsingContextTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/parser/ParsingContextTest.java @@ -224,6 +224,6 @@ private static Grammar parseGrammar(String grammar) { private static CstNode.Terminal createTerminal(String text) { var span = SourceSpan.sourceSpan(SourceLocation.sourceLocation(1, 1, 0), SourceLocation.sourceLocation(1, text.length() + 1, text.length())); - return new CstNode.Terminal(span, "Test", text, List.of(), List.of()); + return new CstNode.Terminal(0L, span, "Test", text, List.of(), List.of()); } } diff --git a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Doc.java b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Doc.java index 236d397..6b42c52 100644 --- a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Doc.java +++ b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Doc.java @@ -46,7 +46,7 @@ record Text(String value) implements Doc { } if (value.indexOf('\n') >= 0) { throw new IllegalArgumentException( - "Text.value must not contain newlines; use Doc.line() for line breaks"); + "Text.value must not contain newlines; use Doc.line() for line breaks"); } } } @@ -108,12 +108,12 @@ record Concat(Doc left, Doc right) implements Doc { * Convenience: flatten a list of docs into a single {@link Concat} chain * (right-associated). Returns {@link Empty} for an empty list. */ - static Doc concatAll(List parts) { + static Doc concatAll(List< ? extends Doc> parts) { if (parts == null || parts.isEmpty()) { return new Empty(); } Doc acc = parts.getLast(); - for (int i = parts.size() - 2; i >= 0; i--) { + for (int i = parts.size() - 2; i >= 0; i-- ) { acc = new Concat(parts.get(i), acc); } return acc; diff --git a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Docs.java b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Docs.java index a1ac1b4..cb9cd1c 100644 --- a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Docs.java +++ b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Docs.java @@ -84,7 +84,7 @@ public static Doc concat(Doc... parts) { } /** Concatenate a list of docs. */ - public static Doc concat(List parts) { + public static Doc concat(List< ? extends Doc> parts) { return Doc.concatAll(parts); } @@ -92,12 +92,12 @@ public static Doc concat(List parts) { * Join {@code parts} with {@code separator} between adjacent elements. * Returns {@link #empty()} for an empty list. */ - public static Doc join(Doc separator, List parts) { + public static Doc join(Doc separator, List< ? extends Doc> parts) { if (parts == null || parts.isEmpty()) { return empty(); } Doc acc = parts.getFirst(); - for (int i = 1; i < parts.size(); i++) { + for (int i = 1; i < parts.size(); i++ ) { acc = new Doc.Concat(new Doc.Concat(acc, separator), parts.get(i)); } return acc; diff --git a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/FormatContext.java b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/FormatContext.java index 5dc4900..8e8a89f 100644 --- a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/FormatContext.java +++ b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/FormatContext.java @@ -47,8 +47,12 @@ public FormatContext forNode(CstNode child) { /** The source text covered by the current node (excluding trivia). */ public String nodeText() { var span = node.span(); - int start = Math.max(0, span.start().offset()); - int end = Math.min(source.length(), span.end().offset()); + int start = Math.max(0, + span.start() + .offset()); + int end = Math.min(source.length(), + span.end() + .offset()); if (start >= end) { return ""; } diff --git a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Formatter.java b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Formatter.java index 79657cb..c6f0e17 100644 --- a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Formatter.java +++ b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/Formatter.java @@ -115,7 +115,7 @@ public Result format(CstNode cst, String source) { if (source == null) { return FormatterError.NULL_SOURCE.result(); } - try { + try{ var doc = walk(cst, source); var out = Renderer.render(doc, config.maxLineWidth()); return Result.success(out); @@ -132,7 +132,8 @@ private Doc walk(CstNode node, String source) { private List collectChildDocs(CstNode node, String source) { if (node instanceof CstNode.NonTerminal nt) { - var out = new ArrayList(nt.children().size()); + var out = new ArrayList(nt.children() + .size()); for (var child : nt.children()) { out.add(walk(child, source)); } @@ -145,7 +146,11 @@ private Doc applyRule(CstNode node, String source, List childDocs) { Map rules = config.rules(); var rule = rules.get(node.rule()); if (rule != null) { - var ctx = new FormatContext(node, source, config.defaultIndent(), config.maxLineWidth(), config.triviaPolicy()); + var ctx = new FormatContext(node, + source, + config.defaultIndent(), + config.maxLineWidth(), + config.triviaPolicy()); return rule.format(ctx, childDocs); } return defaultFallback(node, source, childDocs); @@ -172,7 +177,8 @@ private Doc wrapWithTrivia(CstNode node, Doc nodeDoc) { private static Doc triviaDoc(Trivia trivia) { return switch (trivia) { case Trivia.Whitespace ws -> whitespaceDoc(ws.text()); - case Trivia.LineComment lc -> Docs.concat(Docs.text(stripTrailingNewline(lc.text())), Docs.hardline()); + case Trivia.LineComment lc -> Docs.concat(Docs.text(stripTrailingNewline(lc.text())), + Docs.hardline()); case Trivia.BlockComment bc -> blockCommentDoc(bc.text()); }; } @@ -194,7 +200,7 @@ private static Doc whitespaceDoc(String text) { } var parts = new ArrayList(); var run = new StringBuilder(); - for (int i = 0; i < text.length(); i++) { + for (int i = 0; i < text.length(); i++ ) { var c = text.charAt(i); if (c == '\n') { if (!run.isEmpty()) { @@ -202,7 +208,7 @@ private static Doc whitespaceDoc(String text) { run.setLength(0); } parts.add(hardBreak()); - } else { + }else { run.append(c); } } @@ -216,9 +222,9 @@ private static Doc blockCommentDoc(String text) { if (text.indexOf('\n') < 0) { return Docs.text(text); } - var lines = text.split("\n", -1); + var lines = text.split("\n", - 1); var parts = new ArrayList(lines.length * 2 - 1); - for (int i = 0; i < lines.length; i++) { + for (int i = 0; i < lines.length; i++ ) { if (i > 0) { parts.add(hardBreak()); } @@ -241,8 +247,12 @@ private static Doc defaultFallback(CstNode node, String source, List childD // case of rules whose CST is essentially a single literal match. if (childDocs.isEmpty() && !source.isEmpty()) { var span = nt.span(); - int start = Math.max(0, span.start().offset()); - int end = Math.min(source.length(), span.end().offset()); + int start = Math.max(0, + span.start() + .offset()); + int end = Math.min(source.length(), + span.end() + .offset()); if (start < end) { yield Docs.text(source.substring(start, end)); } @@ -258,13 +268,10 @@ public sealed interface FormatterError extends Cause { enum General implements FormatterError { NULL_NODE("Cannot format a null CST node"), NULL_SOURCE("Source buffer must not be null"); - private final String message; - General(String message) { this.message = message; } - @Override public String message() { return message; @@ -280,8 +287,8 @@ public String message() { record RuleFailed(String rule, Throwable cause) implements FormatterError { @Override public String message() { - return "Formatter rule for '" + rule + "' threw: " + cause.getClass().getSimpleName() - + ": " + cause.getMessage(); + return "Formatter rule for '" + rule + "' threw: " + cause.getClass() + .getSimpleName() + ": " + cause.getMessage(); } } } diff --git a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/FormatterConfig.java b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/FormatterConfig.java index 3226813..00cb8fa 100644 --- a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/FormatterConfig.java +++ b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/FormatterConfig.java @@ -31,7 +31,6 @@ public record FormatterConfig(int defaultIndent, int maxLineWidth, TriviaPolicy triviaPolicy, Map rules) { - public FormatterConfig { if (defaultIndent < 0) { throw new IllegalArgumentException("defaultIndent must be >= 0"); @@ -71,7 +70,6 @@ public record Builder(int defaultIndent, int maxLineWidth, TriviaPolicy triviaPolicy, Map rules) { - public Builder { if (rules == null) { throw new IllegalArgumentException("rules must not be null"); diff --git a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/TriviaPolicy.java b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/TriviaPolicy.java index e3f3c60..5c13d6f 100644 --- a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/TriviaPolicy.java +++ b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/TriviaPolicy.java @@ -38,7 +38,7 @@ public interface TriviaPolicy { TriviaPolicy STRIP_WHITESPACE = trivia -> { var out = new ArrayList(trivia.size()); for (var t : trivia) { - if (!(t instanceof Trivia.Whitespace)) { + if (! (t instanceof Trivia.Whitespace)) { out.add(t); } } @@ -58,7 +58,7 @@ public interface TriviaPolicy { for (var t : trivia) { if (t instanceof Trivia.Whitespace ws) { out.add(collapseBlankLines(ws)); - } else { + }else { out.add(t); } } @@ -68,9 +68,9 @@ public interface TriviaPolicy { private static Trivia.Whitespace collapseBlankLines(Trivia.Whitespace ws) { var text = ws.text(); int newlines = 0; - for (int i = 0; i < text.length(); i++) { + for (int i = 0; i < text.length(); i++ ) { if (text.charAt(i) == '\n') { - newlines++; + newlines++ ; } } if (newlines <= 1) { diff --git a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/internal/Renderer.java b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/internal/Renderer.java index cf75c9e..69bb314 100644 --- a/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/internal/Renderer.java +++ b/peglib-formatter/src/main/java/org/pragmatica/peg/formatter/internal/Renderer.java @@ -23,7 +23,10 @@ * @since 0.3.3 */ public final class Renderer { - private enum Mode { FLAT, BREAK } + private enum Mode { + FLAT, + BREAK + } private record Frame(int indent, Mode mode, Doc doc) {} @@ -60,7 +63,8 @@ private static int step(Frame frame, Deque stack, StringBuilder sb, int w } case Doc.Text text -> { sb.append(text.value()); - return column + text.value().length(); + return column + text.value() + .length(); } case Doc.Line ignored -> { if (frame.mode() == Mode.FLAT) { @@ -90,14 +94,16 @@ private static int step(Frame frame, Deque stack, StringBuilder sb, int w return column; } case Doc.Indent indent -> { - stack.push(new Frame(frame.indent() + indent.amount(), frame.mode(), indent.inner())); + stack.push(new Frame(frame.indent() + indent.amount(), + frame.mode(), + indent.inner())); return column; } case Doc.Group group -> { Mode mode; if (containsHardLine(group.inner())) { mode = Mode.BREAK; - } else { + }else { mode = fits(group.inner(), frame.indent(), width - column, stack) ? Mode.FLAT : Mode.BREAK; @@ -125,7 +131,7 @@ private static boolean containsHardLine(Doc doc) { } private static void appendSpaces(StringBuilder sb, int count) { - for (int i = 0; i < count; i++) { + for (int i = 0; i < count; i++ ) { sb.append(' '); } } @@ -144,22 +150,22 @@ private static boolean fits(Doc inner, int indent, int remaining, Deque s } var probe = new ArrayDeque(); probe.push(new Frame(indent, Mode.FLAT, inner)); - var surroundingIter = surrounding.iterator(); int budget = remaining; while (true) { Frame f; if (!probe.isEmpty()) { f = probe.pop(); - } else if (surroundingIter.hasNext()) { + }else if (surroundingIter.hasNext()) { f = surroundingIter.next(); - } else { + }else { return true; } switch (f.doc()) { case Doc.Empty ignored -> {} case Doc.Text text -> { - budget -= text.value().length(); + budget -= text.value() + .length(); if (budget < 0) { return false; } @@ -170,7 +176,7 @@ private static boolean fits(Doc inner, int indent, int remaining, Deque s if (budget < 0) { return false; } - } else { + }else { return true; } } @@ -178,7 +184,6 @@ private static boolean fits(Doc inner, int indent, int remaining, Deque s if (f.mode() == Mode.BREAK) { return true; } - // FLAT softline contributes 0 columns } case Doc.HardLine ignored -> { return true; @@ -188,7 +193,9 @@ private static boolean fits(Doc inner, int indent, int remaining, Deque s probe.push(new Frame(f.indent(), f.mode(), concat.left())); } case Doc.Indent ind -> { - probe.push(new Frame(f.indent() + ind.amount(), f.mode(), ind.inner())); + probe.push(new Frame(f.indent() + ind.amount(), + f.mode(), + ind.inner())); } case Doc.Group group -> { // Optimistically assume the group fits flat too. diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Edit.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Edit.java index 843811b..f22dd62 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Edit.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Edit.java @@ -1,5 +1,4 @@ package org.pragmatica.peg.incremental; - /** * A single splice over a {@link Session}'s buffer: replace the substring * {@code text[offset, offset + oldLen)} with {@code newText}. diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Stats.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Stats.java index 5737226..d2ba2b1 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Stats.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Stats.java @@ -1,5 +1,4 @@ package org.pragmatica.peg.incremental; - /** * Per-session diagnostic counters. * @@ -23,12 +22,11 @@ * @since 0.3.1 */ public record Stats( - int reparseCount, - int fullReparseCount, - String lastReparsedRule, - int lastReparsedNodeCount, - long lastReparseNanos -) { + int reparseCount, + int fullReparseCount, + String lastReparsedRule, + int lastReparsedNodeCount, + long lastReparseNanos) { /** Fresh stats for a just-initialized session. */ public static final Stats INITIAL = new Stats(0, 0, "", 0, 0L); diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java index d38f1b8..2588f66 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java @@ -53,12 +53,10 @@ record Terminal(long id, List trailingTrivia) implements IdCstNode { @Override public boolean equals(Object other) { - return other instanceof Terminal that - && Objects.equals(span, that.span) - && Objects.equals(rule, that.rule) - && Objects.equals(text, that.text) - && Objects.equals(leadingTrivia, that.leadingTrivia) - && Objects.equals(trailingTrivia, that.trailingTrivia); + return other instanceof Terminal that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(text, + that.text) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); } @Override @@ -76,12 +74,11 @@ record NonTerminal(long id, List trailingTrivia) implements IdCstNode { @Override public boolean equals(Object other) { - return other instanceof NonTerminal that - && Objects.equals(span, that.span) - && Objects.equals(rule, that.rule) - && Objects.equals(children, that.children) - && Objects.equals(leadingTrivia, that.leadingTrivia) - && Objects.equals(trailingTrivia, that.trailingTrivia); + return other instanceof NonTerminal that && Objects.equals(span, that.span) && Objects.equals(rule, + that.rule) && Objects.equals(children, + that.children) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); } @Override @@ -102,12 +99,10 @@ record Token(long id, List trailingTrivia) implements IdCstNode { @Override public boolean equals(Object other) { - return other instanceof Token that - && Objects.equals(span, that.span) - && Objects.equals(rule, that.rule) - && Objects.equals(text, that.text) - && Objects.equals(leadingTrivia, that.leadingTrivia) - && Objects.equals(trailingTrivia, that.trailingTrivia); + return other instanceof Token that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(text, + that.text) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); } @Override @@ -134,12 +129,11 @@ public String rule() { @Override public boolean equals(Object other) { - return other instanceof Error that - && Objects.equals(span, that.span) - && Objects.equals(skippedText, that.skippedText) - && Objects.equals(expected, that.expected) - && Objects.equals(leadingTrivia, that.leadingTrivia) - && Objects.equals(trailingTrivia, that.trailingTrivia); + return other instanceof Error that && Objects.equals(span, that.span) && Objects.equals(skippedText, + that.skippedText) && Objects.equals(expected, + that.expected) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); } @Override diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java index 3cf2152..41c9c44 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java @@ -47,21 +47,11 @@ public IdCstNode build(CstNode source) { } private IdCstNode buildTerminal(CstNode.Terminal t) { - return new IdCstNode.Terminal(idGen.next(), - t.span(), - t.rule(), - t.text(), - t.leadingTrivia(), - t.trailingTrivia()); + return new IdCstNode.Terminal(idGen.next(), t.span(), t.rule(), t.text(), t.leadingTrivia(), t.trailingTrivia()); } private IdCstNode buildToken(CstNode.Token t) { - return new IdCstNode.Token(idGen.next(), - t.span(), - t.rule(), - t.text(), - t.leadingTrivia(), - t.trailingTrivia()); + return new IdCstNode.Token(idGen.next(), t.span(), t.rule(), t.text(), t.leadingTrivia(), t.trailingTrivia()); } private IdCstNode buildError(CstNode.Error e) { diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java index b4e1e24..bd30b52 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java @@ -46,7 +46,7 @@ public PerSessionCounter() { @Override public long next() { - return next++; + return next++ ; } } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java index f6f151f..395cc66 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java @@ -82,7 +82,7 @@ public static IdNodeIndex build(IdCstNode root) { int expectedSize = countDescendants(root); var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); indexChildren(root, parents); - return new IdNodeIndex(root, parents, -1); + return new IdNodeIndex(root, parents, - 1); } /** @@ -163,9 +163,7 @@ public IdNodeIndex applyIncremental(IdCstNode newRoot, List oldPath, } var oldPivot = oldPath.get(oldPath.size() - 1); var newPivot = newPath.get(newPath.size() - 1); - int putCount = 0; - // Step 1a — Remove every old-path node's up-entry (their records are dead). for (var oldNode : oldPath) { parents.remove(oldNode.id()); @@ -176,7 +174,6 @@ public IdNodeIndex applyIncremental(IdCstNode newRoot, List oldPath, for (var d : oldPivotDescendants) { parents.remove(d.id()); } - // Step 2 — Insert new pivot's subtree internal links. indexChildren(newPivot, parents); putCount += subtreeChildCount(newPivot); @@ -184,22 +181,20 @@ public IdNodeIndex applyIncremental(IdCstNode newRoot, List oldPath, if (newPath.size() >= 2) { var newPivotParent = newPath.get(newPath.size() - 2); parents.put(newPivot.id(), newPivotParent.id()); - putCount++; + putCount++ ; } - // Step 3 — Walk new ancestor chain (excluding pivot — already wired in step 2). // For each ancestor, set parent links for ALL its direct children. Sibling // subtrees keep their internal entries (same IDs, still correct). - for (int i = 0; i < newPath.size() - 1; i++) { + for (int i = 0; i < newPath.size() - 1; i++ ) { var ancestor = newPath.get(i); if (ancestor instanceof IdCstNode.NonTerminal nt) { for (var child : nt.children()) { parents.put(child.id(), ancestor.id()); - putCount++; + putCount++ ; } } } - return new IdNodeIndex(newRoot, parents, putCount); } @@ -235,7 +230,6 @@ public int size() { } // -- Helpers (mirror the production NodeIndex static helpers) -- - private static int countDescendants(IdCstNode node) { int count = 0; if (node instanceof IdCstNode.NonTerminal nt) { diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java index 5cfc0fa..18a89bf 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java @@ -44,7 +44,6 @@ * @since 0.5.0 */ public final class IdTreeSplicer { - /** * Result of a splice — the new root and the new path (root → newPivot, * inclusive). The new path is parallel to {@code oldPath}: {@code newPath.get(i)} @@ -78,58 +77,46 @@ public Result splice(List oldPath, IdCstNode newPivot) { if (newPivot == null) { throw new IllegalArgumentException("newPivot must not be null"); } - // Pivot IS the root — no rebuild needed. if (oldPath.size() == 1) { return new Result(newPivot, List.of(newPivot)); } - // Accumulator: builds newPath in REVERSE (leaf → root), reversed at end. // We pre-size to oldPath.size() to avoid resize churn. var reversedNewPath = new ArrayList(oldPath.size()); reversedNewPath.add(newPivot); - var current = newPivot; var oldChild = oldPath.get(oldPath.size() - 1); - - for (int i = oldPath.size() - 2; i >= 0; i--) { + for (int i = oldPath.size() - 2; i >= 0; i-- ) { var oldAncestor = oldPath.get(i); - if (!(oldAncestor instanceof IdCstNode.NonTerminal nt)) { + if (! (oldAncestor instanceof IdCstNode.NonTerminal nt)) { throw new IllegalStateException( - "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); + "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); } var children = nt.children(); int slot = indexOfByIdentity(children, oldChild); if (slot < 0) { throw new IllegalStateException( - "splice path broken at depth " + i - + ": child " + oldChild + " not found in parent's children list"); + "splice path broken at depth " + i + ": child " + oldChild + " not found in parent's children list"); } - // Copy the children list, replacing only the spliced slot. // CRITICAL: every other entry is the same reference — this is what // preserves the identity invariant (spec §8 Q3). var newChildren = new ArrayList(children.size()); - for (int k = 0; k < children.size(); k++) { - newChildren.add(k == slot ? current : children.get(k)); + for (int k = 0; k < children.size(); k++ ) { + newChildren.add(k == slot + ? current + : children.get(k)); } - var newAncestor = new IdCstNode.NonTerminal( - idGen.next(), - nt.span(), - nt.rule(), - List.copyOf(newChildren), - nt.leadingTrivia(), - nt.trailingTrivia()); - + idGen.next(), nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia()); reversedNewPath.add(newAncestor); current = newAncestor; oldChild = oldAncestor; } - // Reverse to get root → newPivot order. var newPath = new ArrayList(reversedNewPath.size()); - for (int i = reversedNewPath.size() - 1; i >= 0; i--) { + for (int i = reversedNewPath.size() - 1; i >= 0; i-- ) { newPath.add(reversedNewPath.get(i)); } return new Result(current, List.copyOf(newPath)); @@ -140,11 +127,11 @@ public Result splice(List oldPath, IdCstNode newPivot) { * are typically small (≤ 8); a linear scan is dominant and avoids hashing. */ private static int indexOfByIdentity(List children, IdCstNode target) { - for (int i = 0; i < children.size(); i++) { + for (int i = 0; i < children.size(); i++ ) { if (children.get(i) == target) { return i; } } - return -1; + return - 1; } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java index bd52d2b..1ccc7b3 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java @@ -73,7 +73,7 @@ public void put(long key, long value) { keys[target] = key; values[target] = value; state[target] = OCCUPIED; - size++; + size++ ; if (size > threshold) { resize(state.length<< 1); } @@ -130,7 +130,7 @@ public void remove(long key) { } if (s == OCCUPIED && keys[slot] == key) { state[slot] = TOMBSTONE; - size--; + size-- ; return; } slot = (slot + 1) & mask; @@ -161,7 +161,7 @@ public LongLongMap copy() { @Override public void forEachEntry(EntryVisitor visitor) { - for (int i = 0; i < state.length; i++) { + for (int i = 0; i < state.length; i++ ) { if (state[i] == OCCUPIED) { long oldValue = values[i]; long newValue = visitor.visit(keys[i], oldValue); @@ -186,7 +186,7 @@ private void resize(int newCapacity) { this.mask = newCapacity - 1; this.threshold = (int)(newCapacity * LOAD_FACTOR); this.size = 0; - for (int i = 0; i < oldState.length; i++) { + for (int i = 0; i < oldState.length; i++ ) { if (oldState[i] == OCCUPIED) { put(oldKeys[i], oldValues[i]); } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java index a234ac5..1327b3f 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java @@ -76,11 +76,9 @@ record Terminal(long id, List trailingTrivia) implements OffsetDecoupledNode { @Override public boolean equals(Object other) { - return other instanceof Terminal that - && Objects.equals(rule, that.rule) - && Objects.equals(text, that.text) - && Objects.equals(leadingTrivia, that.leadingTrivia) - && Objects.equals(trailingTrivia, that.trailingTrivia); + return other instanceof Terminal that && Objects.equals(rule, that.rule) && Objects.equals(text, that.text) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); } @Override @@ -97,11 +95,10 @@ record NonTerminal(long id, List trailingTrivia) implements OffsetDecoupledNode { @Override public boolean equals(Object other) { - return other instanceof NonTerminal that - && Objects.equals(rule, that.rule) - && Objects.equals(children, that.children) - && Objects.equals(leadingTrivia, that.leadingTrivia) - && Objects.equals(trailingTrivia, that.trailingTrivia); + return other instanceof NonTerminal that && Objects.equals(rule, that.rule) && Objects.equals(children, + that.children) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); } @Override @@ -121,11 +118,9 @@ record Token(long id, List trailingTrivia) implements OffsetDecoupledNode { @Override public boolean equals(Object other) { - return other instanceof Token that - && Objects.equals(rule, that.rule) - && Objects.equals(text, that.text) - && Objects.equals(leadingTrivia, that.leadingTrivia) - && Objects.equals(trailingTrivia, that.trailingTrivia); + return other instanceof Token that && Objects.equals(rule, that.rule) && Objects.equals(text, that.text) && Objects.equals(leadingTrivia, + that.leadingTrivia) && Objects.equals(trailingTrivia, + that.trailingTrivia); } @Override diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java index 7017a4d..c4375d4 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java @@ -58,7 +58,6 @@ public OffsetDecoupledNodeIndex applyIncremental(OffsetDecoupledNode newRoot, } var oldPivot = oldPath.get(oldPath.size() - 1); var newPivot = newPath.get(newPath.size() - 1); - // Step 1a — Remove every old-path node's up-entry. for (var oldNode : oldPath) { parents.remove(oldNode.id()); @@ -69,17 +68,15 @@ public OffsetDecoupledNodeIndex applyIncremental(OffsetDecoupledNode newRoot, for (var d : oldPivotDescendants) { parents.remove(d.id()); } - // Step 2 — Insert new pivot's subtree internal links. indexChildren(newPivot, parents); if (newPath.size() >= 2) { var newPivotParent = newPath.get(newPath.size() - 2); parents.put(newPivot.id(), newPivotParent.id()); } - // Step 3 — Walk new ancestor chain top-down, set parent links for // ALL direct children of each ancestor. - for (int i = 0; i < newPath.size() - 1; i++) { + for (int i = 0; i < newPath.size() - 1; i++ ) { var ancestor = newPath.get(i); if (ancestor instanceof OffsetDecoupledNode.NonTerminal nt) { for (var child : nt.children()) { @@ -87,7 +84,6 @@ public OffsetDecoupledNodeIndex applyIncremental(OffsetDecoupledNode newRoot, } } } - return new OffsetDecoupledNodeIndex(newRoot, parents); } @@ -111,7 +107,6 @@ public int size() { } // -- Helpers -- - private static int countDescendants(OffsetDecoupledNode node) { int count = 0; if (node instanceof OffsetDecoupledNode.NonTerminal nt) { diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java index 7ad046a..820d489 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java @@ -66,7 +66,6 @@ * @since 0.5.0 */ public final class OffsetDecoupledSplicer { - /** * Result of a splice — the new root, the new ancestor path * ({@code root → newPivot}, inclusive), and the new (independent) @@ -112,75 +111,63 @@ public Result splice(SpanIndex oldSpans, if (oldSpans == null) { throw new IllegalArgumentException("oldSpans must not be null"); } - // Step 1 — Independent span index for the post-edit tree. var newSpans = oldSpans.copy(); - // Step 2 — Eager shift on the new span index. newSpans.shift(editEnd, delta); - // Pivot IS the root: no ancestor rebuild. The pivot's spans must // already be registered in oldSpans by the caller. if (oldPath.size() == 1) { return new Result(newPivot, List.of(newPivot), newSpans); } - // Step 3 — Rebuild the ancestor chain leaf-to-root, splicing // newPivot in and reference-sharing every sibling slot. var reversedNewPath = new ArrayList(oldPath.size()); reversedNewPath.add(newPivot); - OffsetDecoupledNode current = newPivot; OffsetDecoupledNode oldChild = oldPath.get(oldPath.size() - 1); - - for (int i = oldPath.size() - 2; i >= 0; i--) { + for (int i = oldPath.size() - 2; i >= 0; i-- ) { var oldAncestor = oldPath.get(i); - if (!(oldAncestor instanceof OffsetDecoupledNode.NonTerminal nt)) { + if (! (oldAncestor instanceof OffsetDecoupledNode.NonTerminal nt)) { throw new IllegalStateException( - "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); + "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); } var children = nt.children(); int slot = indexOfByIdentity(children, oldChild); if (slot < 0) { throw new IllegalStateException( - "splice path broken at depth " + i - + ": child " + oldChild + " not found in parent's children list"); + "splice path broken at depth " + i + ": child " + oldChild + " not found in parent's children list"); } - // Reference-share every sibling slot — both LEFT AND RIGHT of // the edit. This is the path-A invariant: siblings right of the // edit don't need rebuilding because their offsets live in // newSpans, which we already shifted. var newChildren = new ArrayList(children.size()); - for (int k = 0; k < children.size(); k++) { - newChildren.add(k == slot ? current : children.get(k)); + for (int k = 0; k < children.size(); k++ ) { + newChildren.add(k == slot + ? current + : children.get(k)); } - long newAncestorId = idGen.next(); var newAncestor = new OffsetDecoupledNode.NonTerminal( - newAncestorId, - nt.rule(), - List.copyOf(newChildren), - nt.leadingTrivia(), - nt.trailingTrivia()); - + newAncestorId, nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia()); // Compute the new ancestor's span: // start = old start (unchanged — ancestor began before the edit) // end = old end + delta if old end >= editEnd, else old end // Mirrors TreeSplicer.rebuildNonTerminal. int oldStart = oldSpans.startOffset(oldAncestor.id()); int oldEnd = oldSpans.endOffset(oldAncestor.id()); - int newEnd = oldEnd >= editEnd ? oldEnd + delta : oldEnd; + int newEnd = oldEnd >= editEnd + ? oldEnd + delta + : oldEnd; newSpans.put(newAncestorId, oldStart, newEnd); - reversedNewPath.add(newAncestor); current = newAncestor; oldChild = oldAncestor; } - // Reverse to get root → newPivot order. var newPath = new ArrayList(reversedNewPath.size()); - for (int i = reversedNewPath.size() - 1; i >= 0; i--) { + for (int i = reversedNewPath.size() - 1; i >= 0; i-- ) { newPath.add(reversedNewPath.get(i)); } return new Result(current, List.copyOf(newPath), newSpans); @@ -188,11 +175,11 @@ public Result splice(SpanIndex oldSpans, /** Linear scan for a child by record identity ({@code ==}). */ private static int indexOfByIdentity(List children, OffsetDecoupledNode target) { - for (int i = 0; i < children.size(); i++) { + for (int i = 0; i < children.size(); i++ ) { if (children.get(i) == target) { return i; } } - return -1; + return - 1; } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java index 2ca0c94..9a7b35e 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java @@ -1,5 +1,4 @@ package org.pragmatica.peg.incremental.experimental; - /** * External {@code id → (startOffset, endOffset)} index for the path-A spike of * the v0.5.0 incremental-native rework @@ -113,13 +112,13 @@ public void shift(int afterOffset, int delta) { return; } map.forEachEntry((nodeId, packed) -> { - int start = unpackStart(packed); - if (start >= afterOffset) { - int end = unpackEnd(packed); - return pack(start + delta, end + delta); - } - return packed; - }); + int start = unpackStart(packed); + if (start >= afterOffset) { + int end = unpackEnd(packed); + return pack(start + delta, end + delta); + } + return packed; + }); } /** Independent deep copy; subsequent mutations on either side are isolated. */ @@ -128,13 +127,12 @@ public SpanIndex copy() { } // --- packing helpers --- - private static long pack(int start, int end) { - return ((long) start << 32) | (end & 0xFFFFFFFFL); + return ((long) start<< 32) | (end & 0xFFFFFFFFL); } private static int unpackStart(long packed) { - return (int) (packed >> 32); + return (int)(packed>> 32); } private static int unpackEnd(long packed) { diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java index a778974..ae6ce46 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java @@ -107,7 +107,7 @@ public static StableIdNodeIndex build(IdCstNode root) { int expectedSize = countDescendants(root); var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); indexChildren(root, parents); - return new StableIdNodeIndex(root, parents, -1, -1); + return new StableIdNodeIndex(root, parents, - 1, - 1); } /** @@ -137,10 +137,8 @@ public StableIdNodeIndex applyIncremental(IdCstNode newRoot, } var oldPivot = oldPath.get(oldPath.size() - 1); var newPivot = newPath.get(newPath.size() - 1); - int putCount = 0; int removeCount = 0; - // Step 1 — remove oldPivot's descendants (wholesale replaced subtree). // Note: we do NOT touch oldPath ancestors' entries — their ids are // reused on the new path, so the entries are still valid. @@ -148,34 +146,29 @@ public StableIdNodeIndex applyIncremental(IdCstNode newRoot, flattenDescendantsInto(oldPivot, oldPivotDescendants); for (var d : oldPivotDescendants) { parents.remove(d.id()); - removeCount++; + removeCount++ ; } - // Step 2 — remove the oldPivot's own up-pointer. The new pivot // typically has a fresh id (caller responsibility), so the old entry // is dead. (If the caller chose to reuse oldPivot.id() for the new // pivot, step 4 below will simply re-insert the same entry — still // correct.) parents.remove(oldPivot.id()); - removeCount++; - + removeCount++ ; // Step 3 — insert new pivot's subtree internal links. indexChildren(newPivot, parents); putCount += subtreeChildCount(newPivot); - // Step 4 — wire the new pivot to its parent's stable id, unless the // pivot is itself the new root. if (newPath.size() >= 2) { var newPivotParent = newPath.get(newPath.size() - 2); parents.put(newPivot.id(), newPivotParent.id()); - putCount++; + putCount++ ; } - // Steps explicitly SKIPPED vs IdNodeIndex.applyIncremental: // - removal of oldPath ancestors' entries (their ids are reused) // - sibling rewire under each new ancestor (siblings already point // to the correct stable id, unchanged across the splice). - return new StableIdNodeIndex(newRoot, parents, putCount, removeCount); } @@ -211,7 +204,6 @@ public int size() { } // -- Helpers (mirror IdNodeIndex's static helpers) -- - private static int countDescendants(IdCstNode node) { int count = 0; if (node instanceof IdCstNode.NonTerminal nt) { diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java index cdc4356..57ea833 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java @@ -59,7 +59,6 @@ * @since 0.5.0 */ public final class StableIdSplicer { - /** * Result of a splice — the new root and the new path (root → newPivot, * inclusive). Path elements at indices {@code [0, newPath.size() - 2]} @@ -96,61 +95,49 @@ public Result splice(List oldPath, IdCstNode newPivot) { if (newPivot == null) { throw new IllegalArgumentException("newPivot must not be null"); } - // Pivot IS the root — no rebuild needed. if (oldPath.size() == 1) { return new Result(newPivot, List.of(newPivot)); } - // Accumulator: builds newPath in REVERSE (leaf → root), reversed at end. var reversedNewPath = new ArrayList(oldPath.size()); reversedNewPath.add(newPivot); - var current = newPivot; var oldChild = oldPath.get(oldPath.size() - 1); - - for (int i = oldPath.size() - 2; i >= 0; i--) { + for (int i = oldPath.size() - 2; i >= 0; i-- ) { var oldAncestor = oldPath.get(i); - if (!(oldAncestor instanceof IdCstNode.NonTerminal nt)) { + if (! (oldAncestor instanceof IdCstNode.NonTerminal nt)) { throw new IllegalStateException( - "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); + "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); } var children = nt.children(); int slot = indexOfByIdentity(children, oldChild); if (slot < 0) { throw new IllegalStateException( - "splice path broken at depth " + i - + ": child " + oldChild + " not found in parent's children list"); + "splice path broken at depth " + i + ": child " + oldChild + " not found in parent's children list"); } - // Copy the children list, replacing only the spliced slot. Every // other entry is the same reference — preserves the spec §8 Q3 // identity invariant. var newChildren = new ArrayList(children.size()); - for (int k = 0; k < children.size(); k++) { - newChildren.add(k == slot ? current : children.get(k)); + for (int k = 0; k < children.size(); k++ ) { + newChildren.add(k == slot + ? current + : children.get(k)); } - // SOLE DIVERGENCE FROM IdTreeSplicer: reuse oldAncestor.id() instead // of idGen.next(). The new record is structurally distinct from the // old (different children list) but carries the same id; the // parents map in any StableIdNodeIndex therefore preserves every // sibling's parent-link entry across the edit. var newAncestor = new IdCstNode.NonTerminal( - oldAncestor.id(), - nt.span(), - nt.rule(), - List.copyOf(newChildren), - nt.leadingTrivia(), - nt.trailingTrivia()); - + oldAncestor.id(), nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia()); reversedNewPath.add(newAncestor); current = newAncestor; oldChild = oldAncestor; } - var newPath = new ArrayList(reversedNewPath.size()); - for (int i = reversedNewPath.size() - 1; i >= 0; i--) { + for (int i = reversedNewPath.size() - 1; i >= 0; i-- ) { newPath.add(reversedNewPath.get(i)); } return new Result(current, List.copyOf(newPath)); @@ -162,11 +149,11 @@ public Result splice(List oldPath, IdCstNode newPivot) { * approach. */ private static int indexOfByIdentity(List children, IdCstNode target) { - for (int i = 0; i < children.size(); i++) { + for (int i = 0; i < children.size(); i++ ) { if (children.get(i) == target) { return i; } } - return -1; + return - 1; } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/BackReferenceScan.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/BackReferenceScan.java index 57ed9ee..7164861 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/BackReferenceScan.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/BackReferenceScan.java @@ -90,7 +90,7 @@ private static boolean hasBackReference(Expression expr) { case Expression.CaptureScope cs -> hasBackReference(cs.expression()); case Expression.Group grp -> hasBackReference(grp.expression()); case Expression.Literal _, Expression.CharClass _, Expression.Any _, - Expression.Reference _, Expression.Dictionary _, Expression.Cut _ -> false; + Expression.Reference _, Expression.Dictionary _, Expression.Cut _ -> false; }; } @@ -116,8 +116,10 @@ private static Map> referenceGraph(Grammar grammar) { private static void collectReferences(Expression expr, Set out) { switch (expr) { case Expression.Reference ref -> out.add(ref.ruleName()); - case Expression.Sequence seq -> seq.elements().forEach(e -> collectReferences(e, out)); - case Expression.Choice choice -> choice.alternatives().forEach(e -> collectReferences(e, out)); + case Expression.Sequence seq -> seq.elements() + .forEach(e -> collectReferences(e, out)); + case Expression.Choice choice -> choice.alternatives() + .forEach(e -> collectReferences(e, out)); case Expression.ZeroOrMore zom -> collectReferences(zom.expression(), out); case Expression.OneOrMore oom -> collectReferences(oom.expression(), out); case Expression.Optional opt -> collectReferences(opt.expression(), out); @@ -130,7 +132,7 @@ private static void collectReferences(Expression expr, Set out) { case Expression.CaptureScope cs -> collectReferences(cs.expression(), out); case Expression.Group grp -> collectReferences(grp.expression(), out); case Expression.Literal _, Expression.CharClass _, Expression.Any _, - Expression.BackReference _, Expression.Dictionary _, Expression.Cut _ -> {} + Expression.BackReference _, Expression.Dictionary _, Expression.Cut _ -> {} } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/CstHash.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/CstHash.java index 2b9422b..f26506a 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/CstHash.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/CstHash.java @@ -32,11 +32,16 @@ public static long cstHash(CstNode node) { if (node == null) { return 0L; } - long h = 1125899906842597L; // large prime seed - h = 31 * h + node.getClass().getName().hashCode(); + long h = 1125899906842597L; + // large prime seed + h = 31 * h + node.getClass() + .getName() + .hashCode(); h = 31 * h + safeHash(node.rule()); - h = 31 * h + node.span().startOffset(); - h = 31 * h + node.span().endOffset(); + h = 31 * h + node.span() + .startOffset(); + h = 31 * h + node.span() + .endOffset(); switch (node) { case CstNode.Terminal t -> h = 31 * h + safeHash(t.text()); case CstNode.Token t -> h = 31 * h + safeHash(t.text()); @@ -58,6 +63,8 @@ private static long hashChildren(List children) { } private static int safeHash(String s) { - return s == null ? 0 : s.hashCode(); + return s == null + ? 0 + : s.hashCode(); } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java index ce53213..babbdfc 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java @@ -30,15 +30,13 @@ * @since 0.3.1 */ record IncrementalSession( - SessionFactory factory, - String text, - CstNode root, - int cursor, - CstNode enclosingNode, - NodeIndex index, - Stats stats -) implements Session { - + SessionFactory factory, + String text, + CstNode root, + int cursor, + CstNode enclosingNode, + NodeIndex index, + Stats stats) implements Session { /** Build the initial session after a fresh full parse. */ static IncrementalSession initial(SessionFactory factory, String text, int cursor, CstNode root) { var index = NodeIndex.build(root); @@ -84,7 +82,13 @@ public Session edit(Edit edit) { var nextIndex = NodeIndex.build(triviaRoot); var nextEnclosing = nextIndex.smallestContaining(newCursor) .or(triviaRoot); - return new IncrementalSession(factory, newText, triviaRoot, newCursor, nextEnclosing, nextIndex, nextStats); + return new IncrementalSession(factory, + newText, + triviaRoot, + newCursor, + nextEnclosing, + nextIndex, + nextStats); } } // Try incremental reparse next. @@ -150,13 +154,14 @@ private Session applyIncremental(IncrementalResult incremental, String newText, // leading-trivia direction since parseRuleAt already attaches // trivia per 0.2.4 attribution; the seam exists for v2.5+). var normalized = TriviaRedistribution.normalizeSplicedTrivia( - incremental.newRoot, incremental.spliced); + incremental.newRoot, incremental.spliced); var nextStats = new Stats( - stats.reparseCount() + 1, - stats.fullReparseCount(), - incremental.ruleName, - NodeIndex.flatten(incremental.spliced).size(), - System.nanoTime() - t0); + stats.reparseCount() + 1, + stats.fullReparseCount(), + incremental.ruleName, + NodeIndex.flatten(incremental.spliced) + .size(), + System.nanoTime() - t0); var nextIndex = NodeIndex.build(normalized); var nextEnclosing = nextIndex.smallestContaining(newCursor) .or(normalized); @@ -203,7 +208,10 @@ private Option tryIncrementalReparse(String newText, Edit edi return Option.none(); } - private IncrementalResult buildIncrementalResult(CstNode.NonTerminal nt, CstNode reparsedNode, int editEnd, int delta) { + private IncrementalResult buildIncrementalResult(CstNode.NonTerminal nt, + CstNode reparsedNode, + int editEnd, + int delta) { var path = index.pathTo(nt); if (path.isEmpty()) { // pivot == root — reparsed subtree replaces root wholesale. @@ -223,7 +231,8 @@ private CstNode findBoundaryCandidate(int editStart, int editEnd) { // 0.4.0 — Option.option() defends against a (theoretically) null // {@code enclosingNode} record component; falls back to {@code root} // so the walk is well-defined. - var current = Option.option(enclosingNode).orElse(Option.some(root)); + var current = Option.option(enclosingNode) + .orElse(Option.some(root)); while (current.isPresent()) { var cursorNode = current.unwrap(); int spanStart = cursorNode.span() diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/RuleIdRegistry.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/RuleIdRegistry.java index 8f72a4d..a112a15 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/RuleIdRegistry.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/RuleIdRegistry.java @@ -36,41 +36,44 @@ final class RuleIdRegistry { private static final ClassDesc CD_RULE_ID = ClassDesc.of(RuleId.class.getName()); private final GeneratedClassLoader loader = new GeneratedClassLoader(RuleIdRegistry.class.getClassLoader()); - private final Map> cache = new ConcurrentHashMap<>(); + private final Map> cache = new ConcurrentHashMap<>(); /** * Return a {@link RuleId} {@link Class} whose simple name equals * {@code ruleName}. Result is cached; repeated calls for the same name * return the same class. */ - Class classFor(String ruleName) { + Class< ? extends RuleId> classFor(String ruleName) { return cache.computeIfAbsent(ruleName, this::generate); } @SuppressWarnings("unchecked") - private Class generate(String ruleName) { + private Class< ? extends RuleId> generate(String ruleName) { if (!isValidJavaIdentifier(ruleName)) { throw new IllegalArgumentException("rule name is not a valid Java identifier: " + ruleName); } var classDesc = ClassDesc.of(PACKAGE + "." + ruleName); - - byte[] bytes = ClassFile.of().build(classDesc, classBuilder -> classBuilder - .withSuperclass(CD_Object) - .withInterfaceSymbols(CD_RULE_ID) - .withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL) - .withMethodBody(INIT_NAME, MTD_void, ClassFile.ACC_PUBLIC, codeBuilder -> codeBuilder - .aload(0) - .invokespecial(CD_Object, INIT_NAME, MTD_void) - .return_())); - - return (Class) loader.define(PACKAGE + "." + ruleName, bytes); + byte[] bytes = ClassFile.of() + .build(classDesc, + classBuilder -> classBuilder.withSuperclass(CD_Object) + .withInterfaceSymbols(CD_RULE_ID) + .withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL) + .withMethodBody(INIT_NAME, + MTD_void, + ClassFile.ACC_PUBLIC, + codeBuilder -> codeBuilder.aload(0) + .invokespecial(CD_Object, + INIT_NAME, + MTD_void) + .return_())); + return (Class< ? extends RuleId>) loader.define(PACKAGE + "." + ruleName, bytes); } private static boolean isValidJavaIdentifier(String s) { if (s == null || s.isEmpty() || !Character.isJavaIdentifierStart(s.charAt(0))) { return false; } - for (int i = 1; i < s.length(); i++) { + for (int i = 1; i < s.length(); i++ ) { if (!Character.isJavaIdentifierPart(s.charAt(i))) { return false; } @@ -83,7 +86,7 @@ private static final class GeneratedClassLoader extends ClassLoader { super("peglib-incremental-rule-ids", parent); } - Class define(String name, byte[] bytes) { + Class< ? > define(String name, byte[] bytes) { return defineClass(name, bytes, 0, bytes.length); } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java index 79ed6c3..2911784 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java @@ -73,16 +73,20 @@ public static IncrementalParser sessionFactory(Grammar grammar, ParserConfig con * {@code false}; opt-in via {@link IncrementalParser#create(Grammar, * ParserConfig, boolean)}. */ - public static IncrementalParser sessionFactory(Grammar grammar, ParserConfig config, boolean triviaFastPathEnabled) { + public static IncrementalParser sessionFactory(Grammar grammar, + ParserConfig config, + boolean triviaFastPathEnabled) { // 0.4.0 — caller is expected to supply a validated Grammar (constructed // through {@link Grammar#grammar(java.util.List, org.pragmatica.lang.Option, // org.pragmatica.lang.Option, org.pragmatica.lang.Option, java.util.List, // java.util.List)} or via {@link GrammarParser#parse(String)}). Surface // PegEngine construction errors as IllegalArgumentException to preserve // the pre-0.4.0 contract — IncrementalParser is a construction-time API. - var parser = PegParser.fromGrammar(grammar, config).fold( - cause -> { throw new IllegalArgumentException("invalid grammar: " + cause.message()); }, - p -> p); + var parser = PegParser.fromGrammar(grammar, config) + .fold(cause -> { + throw new IllegalArgumentException("invalid grammar: " + cause.message()); + }, + p -> p); var fallback = BackReferenceScan.unsafeRules(grammar); return new SessionFactory(grammar, config, parser, fallback, triviaFastPathEnabled); } @@ -92,17 +96,35 @@ public Session initialize(String buffer, int cursorOffset) { if (buffer == null) { throw new IllegalArgumentException("buffer must not be null"); } - int clampedCursor = Math.max(0, Math.min(cursorOffset, buffer.length())); + int clampedCursor = Math.max(0, + Math.min(cursorOffset, buffer.length())); CstNode root = parseFull(buffer); return IncrementalSession.initial(this, buffer, clampedCursor, root); } - Grammar grammar() { return grammar; } - ParserConfig config() { return config; } - Parser parser() { return parser; } - Set fallbackRules() { return fallbackRules; } - RuleIdRegistry registry() { return registry; } - boolean triviaFastPathEnabled() { return triviaFastPathEnabled; } + Grammar grammar() { + return grammar; + } + + ParserConfig config() { + return config; + } + + Parser parser() { + return parser; + } + + Set fallbackRules() { + return fallbackRules; + } + + RuleIdRegistry registry() { + return registry; + } + + boolean triviaFastPathEnabled() { + return triviaFastPathEnabled; + } /** * Full-parse the buffer via the backing {@link Parser}. Surfaces errors @@ -113,8 +135,10 @@ public Session initialize(String buffer, int cursorOffset) { * diagnostics from the resulting tree's {@link CstNode.Error} nodes. */ CstNode parseFull(String buffer) { - return parser.parseCst(buffer).fold( - cause -> { throw new IllegalStateException("full parse failed: " + cause.message()); }, - node -> node); + return parser.parseCst(buffer) + .fold(cause -> { + throw new IllegalStateException("full parse failed: " + cause.message()); + }, + node -> node); } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java index 05afcb3..678771f 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java @@ -58,20 +58,22 @@ public static CstNode spliceAndShift(Deque path, var pathList = new ArrayList<>(path); // Start with the new subtree, walk up the spine replacing each ancestor. var replacement = newSubtree; - for (int i = pathList.size() - 2; i >= 0; i--) { + for (int i = pathList.size() - 2; i >= 0; i-- ) { var ancestor = pathList.get(i); var next = pathList.get(i + 1); // replacement replaces `next` among ancestor's children. - if (!(ancestor instanceof CstNode.NonTerminal nt)) { + if (! (ancestor instanceof CstNode.NonTerminal nt)) { throw new IllegalStateException("ancestor at depth " + i + " is not a NonTerminal: " + ancestor); } - var newChildren = new ArrayList(nt.children().size()); + var newChildren = new ArrayList(nt.children() + .size()); for (var child : nt.children()) { if (child == next) { newChildren.add(replacement); - } else if (child.span().startOffset() >= editEnd) { + }else if (child.span() + .startOffset() >= editEnd) { newChildren.add(shiftAll(child, delta)); - } else { + }else { newChildren.add(child); } } @@ -97,11 +99,12 @@ private static CstNode.NonTerminal rebuildNonTerminal(CstNode.NonTerminal ancest : oldEnd; var newSpan = SourceSpan.sourceSpan(oldSpan.start(), newEnd); return new CstNode.NonTerminal( - newSpan, - ancestor.rule(), - List.copyOf(newChildren), - shiftTrivia(ancestor.leadingTrivia(), delta, editEnd), - shiftTrivia(ancestor.trailingTrivia(), delta, editEnd)); + ancestor.id(), + newSpan, + ancestor.rule(), + List.copyOf(newChildren), + shiftTrivia(ancestor.leadingTrivia(), delta, editEnd), + shiftTrivia(ancestor.trailingTrivia(), delta, editEnd)); } /** @@ -117,26 +120,39 @@ public static CstNode shiftAll(CstNode node, int delta) { var span = shiftSpan(node.span(), delta); return switch (node) { case CstNode.Terminal t -> new CstNode.Terminal( - span, t.rule(), t.text(), - shiftTriviaAll(t.leadingTrivia(), delta), - shiftTriviaAll(t.trailingTrivia(), delta)); + t.id(), + span, + t.rule(), + t.text(), + shiftTriviaAll(t.leadingTrivia(), delta), + shiftTriviaAll(t.trailingTrivia(), delta)); case CstNode.Token t -> new CstNode.Token( - span, t.rule(), t.text(), - shiftTriviaAll(t.leadingTrivia(), delta), - shiftTriviaAll(t.trailingTrivia(), delta)); + t.id(), + span, + t.rule(), + t.text(), + shiftTriviaAll(t.leadingTrivia(), delta), + shiftTriviaAll(t.trailingTrivia(), delta)); case CstNode.Error e -> new CstNode.Error( - span, e.skippedText(), e.expected(), - shiftTriviaAll(e.leadingTrivia(), delta), - shiftTriviaAll(e.trailingTrivia(), delta)); + e.id(), + span, + e.skippedText(), + e.expected(), + shiftTriviaAll(e.leadingTrivia(), delta), + shiftTriviaAll(e.trailingTrivia(), delta)); case CstNode.NonTerminal nt -> { - var shifted = new ArrayList(nt.children().size()); + var shifted = new ArrayList(nt.children() + .size()); for (var child : nt.children()) { shifted.add(shiftAll(child, delta)); } yield new CstNode.NonTerminal( - span, nt.rule(), List.copyOf(shifted), - shiftTriviaAll(nt.leadingTrivia(), delta), - shiftTriviaAll(nt.trailingTrivia(), delta)); + nt.id(), + span, + nt.rule(), + List.copyOf(shifted), + shiftTriviaAll(nt.leadingTrivia(), delta), + shiftTriviaAll(nt.trailingTrivia(), delta)); } }; } @@ -151,24 +167,31 @@ private static SourceLocation shiftLoc(SourceLocation loc, int delta) { private static List shiftTrivia(List trivia, int delta, int editEnd) { if (trivia == null || trivia.isEmpty() || delta == 0) { - return trivia == null ? List.of() : trivia; + return trivia == null + ? List.of() + : trivia; } var out = new ArrayList(trivia.size()); boolean anyShift = false; for (var t : trivia) { - if (t.span().startOffset() >= editEnd) { + if (t.span() + .startOffset() >= editEnd) { out.add(shiftTriviaOne(t, delta)); anyShift = true; - } else { + }else { out.add(t); } } - return anyShift ? List.copyOf(out) : trivia; + return anyShift + ? List.copyOf(out) + : trivia; } private static List shiftTriviaAll(List trivia, int delta) { if (trivia == null || trivia.isEmpty() || delta == 0) { - return trivia == null ? List.of() : trivia; + return trivia == null + ? List.of() + : trivia; } var out = new ArrayList(trivia.size()); for (var t : trivia) { diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TriviaRedistribution.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TriviaRedistribution.java index 4d86bb1..6b25480 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TriviaRedistribution.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TriviaRedistribution.java @@ -71,7 +71,9 @@ private TriviaRedistribution() {} * tokenisation. */ public static CstNode tryTriviaOnlyEdit(CstNode root, String newBuffer, Edit edit) { - var match = findContainingTrivia(root, edit.offset(), edit.offset() + edit.oldLen()); + var match = findContainingTrivia(root, + edit.offset(), + edit.offset() + edit.oldLen()); if (match == null) { return null; } @@ -99,7 +101,6 @@ public static CstNode normalizeSplicedTrivia(CstNode newRoot, CstNode splicedSub } // ---- trivia containment detection ------------------------------------ - /** * Walk {@code node}'s subtree looking for a trivia run whose span fully * contains the half-open edit range {@code [editStart, editEnd)}. @@ -116,10 +117,10 @@ public static CstNode normalizeSplicedTrivia(CstNode newRoot, CstNode splicedSub */ public static TriviaMatch findContainingTrivia(CstNode node, int editStart, int editEnd) { return findContainingTriviaInList(node, node.leadingTrivia(), Owner.LEADING, editStart, editEnd) instanceof TriviaMatch m - ? m - : findContainingTriviaInList(node, node.trailingTrivia(), Owner.TRAILING, editStart, editEnd) instanceof TriviaMatch m2 - ? m2 - : findContainingTriviaInChildren(node, editStart, editEnd); + ? m + : findContainingTriviaInList(node, node.trailingTrivia(), Owner.TRAILING, editStart, editEnd) instanceof TriviaMatch m2 + ? m2 + : findContainingTriviaInChildren(node, editStart, editEnd); } private static TriviaMatch findContainingTriviaInList(CstNode owner, @@ -130,10 +131,12 @@ private static TriviaMatch findContainingTriviaInList(CstNode owner, if (trivia == null) { return null; } - for (int i = 0; i < trivia.size(); i++) { + for (int i = 0; i < trivia.size(); i++ ) { var t = trivia.get(i); - int a = t.span().startOffset(); - int b = t.span().endOffset(); + int a = t.span() + .startOffset(); + int b = t.span() + .endOffset(); if (a <= editStart && editEnd <= b) { return new TriviaMatch(owner, kind, i, t); } @@ -142,12 +145,14 @@ private static TriviaMatch findContainingTriviaInList(CstNode owner, } private static TriviaMatch findContainingTriviaInChildren(CstNode node, int editStart, int editEnd) { - if (!(node instanceof CstNode.NonTerminal nt)) { + if (! (node instanceof CstNode.NonTerminal nt)) { return null; } for (var child : nt.children()) { - int a = child.span().startOffset(); - int b = child.span().endOffset(); + int a = child.span() + .startOffset(); + int b = child.span() + .endOffset(); // Only descend into children whose own span (plus trivia margins) could contain the edit. // Leading trivia of a child sits before child.span().start(); trailing trivia (if any) // sits after child.span().end(). Conservative: check the child if any trivia or the @@ -171,25 +176,32 @@ private static TriviaMatch findContainingTriviaInChildren(CstNode node, int edit */ private static boolean couldContain(CstNode child, int editStart, int editEnd) { for (var t : nullToEmpty(child.leadingTrivia())) { - if (t.span().startOffset() <= editStart && editEnd <= t.span().endOffset()) { + if (t.span() + .startOffset() <= editStart && editEnd <= t.span() + .endOffset()) { return true; } } for (var t : nullToEmpty(child.trailingTrivia())) { - if (t.span().startOffset() <= editStart && editEnd <= t.span().endOffset()) { + if (t.span() + .startOffset() <= editStart && editEnd <= t.span() + .endOffset()) { return true; } } // Child's own span containing the edit: descend in case of nested trivia attachments. - return child.span().startOffset() <= editStart && editEnd <= child.span().endOffset(); + return child.span() + .startOffset() <= editStart && editEnd <= child.span() + .endOffset(); } private static List nullToEmpty(List trivia) { - return trivia == null ? List.of() : trivia; + return trivia == null + ? List.of() + : trivia; } // ---- legality of trivia-only replacement ----------------------------- - /** * Is {@code newText} a legal in-place rewrite for {@code trivia}? Pure * deletions (newText empty) within a trivia run are always legal — they @@ -237,14 +249,17 @@ private static boolean isPureDeletionSafe(Trivia trivia, Edit edit) { } private static boolean isLineCommentBoundaryUntouched(Trivia.LineComment lc, Edit edit) { - int start = lc.span().startOffset(); + int start = lc.span() + .startOffset(); // Don't allow deleting the leading "//" prefix (offsets [start, start+2]). return edit.offset() >= start + 2; } private static boolean isBlockCommentBoundaryUntouched(Trivia.BlockComment bc, Edit edit) { - int start = bc.span().startOffset(); - int end = bc.span().endOffset(); + int start = bc.span() + .startOffset(); + int end = bc.span() + .endOffset(); // Protect leading "/*" (offsets [start, start+2]) and trailing "*/" (offsets [end-2, end]). return edit.offset() >= start + 2 && edit.offset() + edit.oldLen() <= end - 2; } @@ -255,7 +270,7 @@ private static boolean isLineCommentInteriorSafe(Trivia.LineComment lc, String n } // Disallow newlines — a newline mid-line-comment terminates it and // changes tokenisation downstream. - for (int i = 0; i < newText.length(); i++) { + for (int i = 0; i < newText.length(); i++ ) { if (newText.charAt(i) == '\n' || newText.charAt(i) == '\r') { return false; } @@ -272,7 +287,7 @@ private static boolean isBlockCommentInteriorSafe(Trivia.BlockComment bc, String } private static boolean allWhitespace(String s) { - for (int i = 0; i < s.length(); i++) { + for (int i = 0; i < s.length(); i++ ) { if (!Character.isWhitespace(s.charAt(i))) { return false; } @@ -281,7 +296,6 @@ private static boolean allWhitespace(String s) { } // ---- in-place trivia rewrite ----------------------------------------- - /** * Build a new root with {@code match}'s trivia text rewritten and every * downstream offset shifted by {@code edit.delta()}. Structural copy is @@ -297,12 +311,10 @@ private static CstNode rewriteTriviaInPlace(CstNode root, private static CstNode rewriteNode(CstNode node, TriviaMatch match, String newText, Edit edit) { int delta = edit.delta(); int editEnd = edit.offset() + edit.oldLen(); - // If THIS node owns the trivia match, rewrite the trivia list in place. if (node == match.owner()) { return rebuildOwnerWithRewrittenTrivia(node, match, newText, edit); } - // Otherwise: descend into children that lie at-or-after the edit start, shifting as needed. return shiftAndDescend(node, match, newText, edit, delta, editEnd); } @@ -317,10 +329,23 @@ private static CstNode shiftAndDescend(CstNode node, var newLeading = shiftTriviaListAfter(node.leadingTrivia(), delta, editEnd); var newTrailing = shiftTriviaListAfter(node.trailingTrivia(), delta, editEnd); return switch (node) { - case CstNode.Terminal t -> new CstNode.Terminal(newSpan, t.rule(), t.text(), newLeading, newTrailing); - case CstNode.Token t -> new CstNode.Token(newSpan, t.rule(), t.text(), newLeading, newTrailing); - case CstNode.Error e -> new CstNode.Error(newSpan, e.skippedText(), e.expected(), newLeading, newTrailing); - case CstNode.NonTerminal nt -> rebuildNonTerminalChildren(nt, newSpan, newLeading, newTrailing, match, newText, edit, delta, editEnd); + case CstNode.Terminal t -> new CstNode.Terminal(t.id(), newSpan, t.rule(), t.text(), newLeading, newTrailing); + case CstNode.Token t -> new CstNode.Token(t.id(), newSpan, t.rule(), t.text(), newLeading, newTrailing); + case CstNode.Error e -> new CstNode.Error(e.id(), + newSpan, + e.skippedText(), + e.expected(), + newLeading, + newTrailing); + case CstNode.NonTerminal nt -> rebuildNonTerminalChildren(nt, + newSpan, + newLeading, + newTrailing, + match, + newText, + edit, + delta, + editEnd); }; } @@ -333,7 +358,8 @@ private static CstNode rebuildNonTerminalChildren(CstNode.NonTerminal nt, Edit edit, int delta, int editEnd) { - var newChildren = new ArrayList(nt.children().size()); + var newChildren = new ArrayList(nt.children() + .size()); for (var child : nt.children()) { // Order matters: a child whose own span starts at-or-after editEnd may // STILL own the matched trivia (its leadingTrivia sits BEFORE the @@ -341,20 +367,21 @@ private static CstNode rebuildNonTerminalChildren(CstNode.NonTerminal nt, // then the unaffected case. if (childContainsMatch(child, match)) { newChildren.add(rewriteNode(child, match, newText, edit)); - } else if (child.span().startOffset() >= editEnd) { + }else if (child.span() + .startOffset() >= editEnd) { newChildren.add(shiftAllOffsets(child, delta)); - } else { + }else { newChildren.add(child); } } - return new CstNode.NonTerminal(newSpan, nt.rule(), List.copyOf(newChildren), newLeading, newTrailing); + return new CstNode.NonTerminal(nt.id(), newSpan, nt.rule(), List.copyOf(newChildren), newLeading, newTrailing); } private static boolean childContainsMatch(CstNode child, TriviaMatch match) { if (child == match.owner()) { return true; } - if (!(child instanceof CstNode.NonTerminal nt)) { + if (! (child instanceof CstNode.NonTerminal nt)) { return false; } for (var c : nt.children()) { @@ -379,12 +406,11 @@ private static CstNode rebuildOwnerWithRewrittenTrivia(CstNode owner, Edit edit) { int delta = edit.delta(); int editEnd = edit.offset() + edit.oldLen(); - var leading = owner.leadingTrivia(); var trailing = owner.trailingTrivia(); if (match.kind() == Owner.LEADING) { leading = rewriteTriviaList(leading, match.index(), newText, edit); - } else { + }else { trailing = rewriteTriviaList(trailing, match.index(), newText, edit); } // The owner's own span: when the matched trivia sits in the owner's @@ -397,15 +423,24 @@ private static CstNode rebuildOwnerWithRewrittenTrivia(CstNode owner, // compatibility), the owner's span stays put. SourceSpan ownerSpan; if (match.kind() == Owner.LEADING && delta != 0) { - ownerSpan = SourceSpan.sourceSpan(shiftLoc(owner.span().start(), delta), shiftLoc(owner.span().end(), delta)); - } else { + ownerSpan = SourceSpan.sourceSpan(shiftLoc(owner.span() + .start(), + delta), + shiftLoc(owner.span() + .end(), + delta)); + }else { ownerSpan = owner.span(); } - return switch (owner) { - case CstNode.Terminal t -> new CstNode.Terminal(ownerSpan, t.rule(), t.text(), leading, trailing); - case CstNode.Token t -> new CstNode.Token(ownerSpan, t.rule(), t.text(), leading, trailing); - case CstNode.Error e -> new CstNode.Error(ownerSpan, e.skippedText(), e.expected(), leading, trailing); + case CstNode.Terminal t -> new CstNode.Terminal(t.id(), ownerSpan, t.rule(), t.text(), leading, trailing); + case CstNode.Token t -> new CstNode.Token(t.id(), ownerSpan, t.rule(), t.text(), leading, trailing); + case CstNode.Error e -> new CstNode.Error(e.id(), + ownerSpan, + e.skippedText(), + e.expected(), + leading, + trailing); case CstNode.NonTerminal nt -> rebuildOwnerNonTerminal(nt, ownerSpan, leading, trailing, delta, editEnd); }; } @@ -417,15 +452,17 @@ private static CstNode rebuildOwnerNonTerminal(CstNode.NonTerminal nt, int delta, int editEnd) { // Children of the owner: shift those at-or-after editEnd; leave others alone. - var newChildren = new ArrayList(nt.children().size()); + var newChildren = new ArrayList(nt.children() + .size()); for (var child : nt.children()) { - if (child.span().startOffset() >= editEnd) { + if (child.span() + .startOffset() >= editEnd) { newChildren.add(shiftAllOffsets(child, delta)); - } else { + }else { newChildren.add(child); } } - return new CstNode.NonTerminal(ownerSpan, nt.rule(), List.copyOf(newChildren), leading, trailing); + return new CstNode.NonTerminal(nt.id(), ownerSpan, nt.rule(), List.copyOf(newChildren), leading, trailing); } /** @@ -440,13 +477,13 @@ private static List rewriteTriviaList(List trivia, int index, St return List.of(); } var out = new ArrayList(trivia.size()); - for (int i = 0; i < trivia.size(); i++) { + for (int i = 0; i < trivia.size(); i++ ) { var t = trivia.get(i); if (i < index) { out.add(t); - } else if (i == index) { + }else if (i == index) { out.add(rewriteSingleTrivia(t, newText, edit)); - } else { + }else { out.add(shiftTrivia(t, edit.delta())); } } @@ -454,17 +491,22 @@ private static List rewriteTriviaList(List trivia, int index, St } private static Trivia rewriteSingleTrivia(Trivia trivia, String newText, Edit edit) { - int triviaStart = trivia.span().startOffset(); + int triviaStart = trivia.span() + .startOffset(); int relStart = edit.offset() - triviaStart; int relEnd = relStart + edit.oldLen(); var oldText = trivia.text(); var rebuilt = oldText.substring(0, relStart) + newText + oldText.substring(relEnd); var newSpan = SourceSpan.sourceSpan( - trivia.span().start(), - new SourceLocation( - trivia.span().endLine(), - trivia.span().endColumn(), - trivia.span().endOffset() + edit.delta())); + trivia.span() + .start(), + new SourceLocation( + trivia.span() + .endLine(), + trivia.span() + .endColumn(), + trivia.span() + .endOffset() + edit.delta())); return switch (trivia) { case Trivia.Whitespace _ -> new Trivia.Whitespace(newSpan, rebuilt); case Trivia.LineComment _ -> new Trivia.LineComment(newSpan, rebuilt); @@ -473,38 +515,51 @@ private static Trivia rewriteSingleTrivia(Trivia trivia, String newText, Edit ed } // ---- offset shifting (subset of TreeSplicer; duplicated to keep helpers self-contained) ----- - private static SourceSpan shiftSpanForEdit(SourceSpan span, int delta, int editEnd) { if (delta == 0) { return span; } - var start = span.startOffset() >= editEnd ? shiftLoc(span.start(), delta) : span.start(); - var end = span.endOffset() >= editEnd ? shiftLoc(span.end(), delta) : span.end(); + var start = span.startOffset() >= editEnd + ? shiftLoc(span.start(), delta) + : span.start(); + var end = span.endOffset() >= editEnd + ? shiftLoc(span.end(), delta) + : span.end(); return SourceSpan.sourceSpan(start, end); } private static List shiftTriviaListAfter(List trivia, int delta, int editEnd) { if (trivia == null || trivia.isEmpty() || delta == 0) { - return trivia == null ? List.of() : trivia; + return trivia == null + ? List.of() + : trivia; } boolean any = false; var out = new ArrayList(trivia.size()); for (var t : trivia) { - if (t.span().startOffset() >= editEnd) { + if (t.span() + .startOffset() >= editEnd) { out.add(shiftTrivia(t, delta)); any = true; - } else { + }else { out.add(t); } } - return any ? List.copyOf(out) : trivia; + return any + ? List.copyOf(out) + : trivia; } private static Trivia shiftTrivia(Trivia trivia, int delta) { if (delta == 0) { return trivia; } - var newSpan = SourceSpan.sourceSpan(shiftLoc(trivia.span().start(), delta), shiftLoc(trivia.span().end(), delta)); + var newSpan = SourceSpan.sourceSpan(shiftLoc(trivia.span() + .start(), + delta), + shiftLoc(trivia.span() + .end(), + delta)); return switch (trivia) { case Trivia.Whitespace w -> new Trivia.Whitespace(newSpan, w.text()); case Trivia.LineComment l -> new Trivia.LineComment(newSpan, l.text()); @@ -524,9 +579,11 @@ private static SourceLocation shiftLoc(SourceLocation loc, int delta) { } // ---- internal records ------------------------------------------------ - /** Whether a matched trivia sits in its owner's leading or trailing list. */ - public enum Owner { LEADING, TRAILING } + public enum Owner { + LEADING, + TRAILING + } /** Result of {@link #findContainingTrivia}: which node owns the trivia, where, and the trivia itself. */ public record TriviaMatch(CstNode owner, Owner kind, int index, Trivia trivia) {} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java index c9a6f09..3076b71 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java @@ -26,7 +26,7 @@ private IdCstNodeBuilder freshBuilder() { @Test @DisplayName("Single Terminal gets id 0") void single_terminal_gets_id_zero() { - CstNode source = new CstNode.Terminal(SPAN, "Number", "123", NO_TRIVIA, NO_TRIVIA); + CstNode source = new CstNode.Terminal(0L, SPAN, "Number", "123", NO_TRIVIA, NO_TRIVIA); var built = freshBuilder().build(source); assertThat(built).isInstanceOf(IdCstNode.Terminal.class); var terminal = (IdCstNode.Terminal) built; @@ -39,10 +39,10 @@ void single_terminal_gets_id_zero() { @Test @DisplayName("NonTerminal with three Terminal children: children 0,1,2 / parent 3 (post-order)") void nonterminal_post_order_ids() { - var c1 = new CstNode.Terminal(SPAN, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var c2 = new CstNode.Terminal(SPAN, "Number", "2", NO_TRIVIA, NO_TRIVIA); - var c3 = new CstNode.Terminal(SPAN, "Number", "3", NO_TRIVIA, NO_TRIVIA); - CstNode parent = new CstNode.NonTerminal(SPAN, "Expr", List.of(c1, c2, c3), NO_TRIVIA, NO_TRIVIA); + var c1 = new CstNode.Terminal(0L, SPAN, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var c2 = new CstNode.Terminal(0L, SPAN, "Number", "2", NO_TRIVIA, NO_TRIVIA); + var c3 = new CstNode.Terminal(0L, SPAN, "Number", "3", NO_TRIVIA, NO_TRIVIA); + CstNode parent = new CstNode.NonTerminal(0L, SPAN, "Expr", List.of(c1, c2, c3), NO_TRIVIA, NO_TRIVIA); var built = (IdCstNode.NonTerminal) freshBuilder().build(parent); @@ -56,9 +56,9 @@ void nonterminal_post_order_ids() { @Test @DisplayName("3-deep nested NonTerminal -> NonTerminal -> Terminal: 0,1,2 inside-out") void three_deep_nested_ids() { - var leaf = new CstNode.Terminal(SPAN_INNER, "Atom", "x", NO_TRIVIA, NO_TRIVIA); - var middle = new CstNode.NonTerminal(SPAN_INNER, "Inner", List.of(leaf), NO_TRIVIA, NO_TRIVIA); - CstNode root = new CstNode.NonTerminal(SPAN, "Outer", List.of(middle), NO_TRIVIA, NO_TRIVIA); + var leaf = new CstNode.Terminal(0L, SPAN_INNER, "Atom", "x", NO_TRIVIA, NO_TRIVIA); + var middle = new CstNode.NonTerminal(0L, SPAN_INNER, "Inner", List.of(leaf), NO_TRIVIA, NO_TRIVIA); + CstNode root = new CstNode.NonTerminal(0L, SPAN, "Outer", List.of(middle), NO_TRIVIA, NO_TRIVIA); var builtRoot = (IdCstNode.NonTerminal) freshBuilder().build(root); var builtMiddle = (IdCstNode.NonTerminal) builtRoot.children().get(0); @@ -72,10 +72,10 @@ void three_deep_nested_ids() { @Test @DisplayName("Mixed tree with Token and Error preserves all fields and assigns ids to every node") void mixed_variants_round_trip() { - var token = new CstNode.Token(SPAN_INNER, "Ident", "foo", NO_TRIVIA, NO_TRIVIA); - var error = new CstNode.Error(SPAN_INNER, "@@@", "expected ident", NO_TRIVIA, NO_TRIVIA); - var terminal = new CstNode.Terminal(SPAN_INNER, "Number", "1", NO_TRIVIA, NO_TRIVIA); - CstNode root = new CstNode.NonTerminal(SPAN, "Mixed", List.of(token, error, terminal), NO_TRIVIA, NO_TRIVIA); + var token = new CstNode.Token(0L, SPAN_INNER, "Ident", "foo", NO_TRIVIA, NO_TRIVIA); + var error = new CstNode.Error(0L, SPAN_INNER, "@@@", "expected ident", NO_TRIVIA, NO_TRIVIA); + var terminal = new CstNode.Terminal(0L, SPAN_INNER, "Number", "1", NO_TRIVIA, NO_TRIVIA); + CstNode root = new CstNode.NonTerminal(0L, SPAN, "Mixed", List.of(token, error, terminal), NO_TRIVIA, NO_TRIVIA); var builtRoot = (IdCstNode.NonTerminal) freshBuilder().build(root); @@ -100,7 +100,7 @@ void mixed_variants_round_trip() { void trivia_reference_identity_preserved() { var leading = List.of(new Trivia.Whitespace(SPAN_INNER, " ")); var trailing = List.of(new Trivia.LineComment(SPAN_INNER, "// done")); - CstNode source = new CstNode.Terminal(SPAN, "Number", "1", leading, trailing); + CstNode source = new CstNode.Terminal(0L, SPAN, "Number", "1", leading, trailing); var built = (IdCstNode.Terminal) freshBuilder().build(source); @@ -111,9 +111,9 @@ void trivia_reference_identity_preserved() { @Test @DisplayName("Two builds with independent counters yield equal trees (id excluded from equality)") void independent_builds_are_structurally_equal() { - var c1 = new CstNode.Terminal(SPAN_INNER, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var c2 = new CstNode.Terminal(SPAN_INNER, "Number", "2", NO_TRIVIA, NO_TRIVIA); - CstNode source = new CstNode.NonTerminal(SPAN, "Expr", List.of(c1, c2), NO_TRIVIA, NO_TRIVIA); + var c1 = new CstNode.Terminal(0L, SPAN_INNER, "Number", "1", NO_TRIVIA, NO_TRIVIA); + var c2 = new CstNode.Terminal(0L, SPAN_INNER, "Number", "2", NO_TRIVIA, NO_TRIVIA); + CstNode source = new CstNode.NonTerminal(0L, SPAN, "Expr", List.of(c1, c2), NO_TRIVIA, NO_TRIVIA); var first = freshBuilder().build(source); var second = freshBuilder().build(source); diff --git a/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/CheckMojo.java b/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/CheckMojo.java index ae43604..4d9656a 100644 --- a/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/CheckMojo.java +++ b/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/CheckMojo.java @@ -1,11 +1,5 @@ package org.pragmatica.peg.maven; -import org.apache.maven.plugin.AbstractMojo; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.MojoFailureException; -import org.apache.maven.plugins.annotations.LifecyclePhase; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.Parameter; import org.pragmatica.lang.Result; import org.pragmatica.lang.Unit; import org.pragmatica.lang.utils.Causes; @@ -16,6 +10,13 @@ import java.nio.file.Files; import java.nio.file.Path; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + /** * Check a grammar end-to-end: run the analyzer, then build a runtime parser * from the grammar and parse a minimal smoke-test input to confirm the @@ -26,7 +27,6 @@ */ @Mojo(name = "check", defaultPhase = LifecyclePhase.VERIFY, threadSafe = true) public class CheckMojo extends AbstractMojo { - @Parameter(property = "peglib.grammarFile", required = true) private File grammarFile; @@ -49,7 +49,8 @@ public void execute() throws MojoExecutionException, MojoFailureException { lint.setGrammarFile(grammarFile); lint.setFailOnWarning(failOnWarning); var report = lint.runAnalyzer(); - getLog().info(report.formatRustStyle(grammarFile.toString())); + getLog() + .info(report.formatRustStyle(grammarFile.toString())); if (report.hasErrors()) { throw new MojoFailureException("peglib:check failed — analyzer reported errors"); } @@ -58,19 +59,19 @@ public void execute() throws MojoExecutionException, MojoFailureException { } // Step 2: build runtime parser and run smoke input (if configured) as a Result pipeline. var pipeline = readGrammar(grammarFile.toPath()) - .flatMap(CheckMojo::buildParser) - .flatMap(this::runSmoke); - if (pipeline instanceof Result.Failure failure) { + .flatMap(CheckMojo::buildParser) + .flatMap(this::runSmoke); + if (pipeline instanceof Result.Failure< ? > failure) { throw new MojoFailureException(failure.cause() .message()); } - getLog().info("peglib:check OK for " + grammarFile); + getLog() + .info("peglib:check OK for " + grammarFile); } private static Result buildParser(String grammarText) { return PegParser.fromGrammar(grammarText) - .mapError(c -> Causes.cause("peglib:check failed — parser build failed: " - + c.message())); + .mapError(c -> Causes.cause("peglib:check failed — parser build failed: " + c.message())); } private Result runSmoke(Parser parser) { @@ -78,14 +79,12 @@ private Result runSmoke(Parser parser) { return Result.unitResult(); } return parser.parseCst(smokeInput) - .mapError(c -> Causes.cause("peglib:check failed — smoke parse failed: " - + c.message())) + .mapError(c -> Causes.cause("peglib:check failed — smoke parse failed: " + c.message())) .mapToUnit(); } private static Result readGrammar(Path path) { - return Result.lift(t -> Causes.cause("Failed to read grammar: " + path + " — " - + t.getMessage()), + return Result.lift(t -> Causes.cause("Failed to read grammar: " + path + " — " + t.getMessage()), () -> Files.readString(path)); } diff --git a/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/GenerateMojo.java b/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/GenerateMojo.java index a10cd98..3ebf0d9 100644 --- a/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/GenerateMojo.java +++ b/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/GenerateMojo.java @@ -1,11 +1,5 @@ package org.pragmatica.peg.maven; -import org.apache.maven.plugin.AbstractMojo; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.MojoFailureException; -import org.apache.maven.plugins.annotations.LifecyclePhase; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.Parameter; import org.pragmatica.lang.Result; import org.pragmatica.lang.utils.Causes; import org.pragmatica.peg.PegParser; @@ -16,6 +10,13 @@ import java.nio.file.Files; import java.nio.file.Path; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + /** * Generate a standalone PEG parser Java source file from a grammar. * @@ -24,7 +25,6 @@ */ @Mojo(name = "generate", defaultPhase = LifecyclePhase.GENERATE_SOURCES, threadSafe = true) public class GenerateMojo extends AbstractMojo { - @Parameter(property = "peglib.grammarFile", required = true) private File grammarFile; @@ -52,22 +52,24 @@ public void execute() throws MojoExecutionException, MojoFailureException { } var targetFile = targetSourceFile(); if (isUpToDate(targetFile)) { - getLog().info("peglib:generate skipped (up-to-date): " + targetFile); + getLog() + .info("peglib:generate skipped (up-to-date): " + targetFile); return; } var generated = Result.all(parseErrorReporting(errorReporting), readGrammar(grammarFile.toPath())) .flatMap(this::generateSource); - if (generated instanceof Result.Failure failure) { + if (generated instanceof Result.Failure< ? > failure) { throw new MojoFailureException(failure.cause() .message()); } var write = writeSource(targetFile, generated.unwrap()); - if (write instanceof Result.Failure failure) { + if (write instanceof Result.Failure< ? > failure) { throw new MojoExecutionException(failure.cause() .message()); } - getLog().info("peglib:generate wrote " + targetFile); + getLog() + .info("peglib:generate wrote " + targetFile); } private Result generateSource(ErrorReporting reporting, String grammarText) { @@ -75,20 +77,17 @@ private Result generateSource(ErrorReporting reporting, String grammarTe } private static Result parseErrorReporting(String value) { - return Result.lift(t -> Causes.cause("Invalid errorReporting: " + value - + " (expected BASIC or ADVANCED)"), + return Result.lift(t -> Causes.cause("Invalid errorReporting: " + value + " (expected BASIC or ADVANCED)"), () -> ErrorReporting.valueOf(value)); } private static Result readGrammar(Path path) { - return Result.lift(t -> Causes.cause("Failed to read grammar: " + path + " — " - + t.getMessage()), + return Result.lift(t -> Causes.cause("Failed to read grammar: " + path + " — " + t.getMessage()), () -> Files.readString(path)); } private static Result writeSource(Path targetFile, String source) { - return Result.lift(t -> Causes.cause("Failed to write generated source: " - + targetFile + " — " + t.getMessage()), + return Result.lift(t -> Causes.cause("Failed to write generated source: " + targetFile + " — " + t.getMessage()), () -> writeSourceUnchecked(targetFile, source)); } diff --git a/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/LintMojo.java b/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/LintMojo.java index 2f5c989..de1465c 100644 --- a/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/LintMojo.java +++ b/peglib-maven-plugin/src/main/java/org/pragmatica/peg/maven/LintMojo.java @@ -1,11 +1,5 @@ package org.pragmatica.peg.maven; -import org.apache.maven.plugin.AbstractMojo; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.MojoFailureException; -import org.apache.maven.plugins.annotations.LifecyclePhase; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.Parameter; import org.pragmatica.lang.Result; import org.pragmatica.lang.utils.Causes; import org.pragmatica.peg.analyzer.Analyzer; @@ -17,6 +11,13 @@ import java.nio.file.Files; import java.nio.file.Path; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + /** * Run the grammar analyzer against a grammar file. Fails the build when any * ERROR-level findings are produced. Warnings and info findings are logged @@ -24,7 +25,6 @@ */ @Mojo(name = "lint", defaultPhase = LifecyclePhase.VALIDATE, threadSafe = true) public class LintMojo extends AbstractMojo { - @Parameter(property = "peglib.grammarFile", required = true) private File grammarFile; @@ -39,7 +39,8 @@ public class LintMojo extends AbstractMojo { @Override public void execute() throws MojoExecutionException, MojoFailureException { var report = runAnalyzer(); - getLog().info(report.formatRustStyle(grammarFile.toString())); + getLog() + .info(report.formatRustStyle(grammarFile.toString())); if (report.hasErrors()) { throw new MojoFailureException("peglib:lint produced errors — see log above"); } @@ -66,9 +67,9 @@ AnalyzerReport runAnalyzer() throws MojoExecutionException, MojoFailureException // directly. Lint targets standalone grammar files, so we don't run the // resolver here. var pipeline = readGrammar(grammarFile.toPath()) - .flatMap(LintMojo::parseGrammar) - .map(Analyzer::analyze); - if (pipeline instanceof Result.Failure failure) { + .flatMap(LintMojo::parseGrammar) + .map(Analyzer::analyze); + if (pipeline instanceof Result.Failure< ? > failure) { throw new MojoFailureException(failure.cause() .message()); } @@ -81,8 +82,7 @@ private static Result parseGrammar(String text) { } private static Result readGrammar(Path path) { - return Result.lift(t -> Causes.cause("Failed to read grammar: " + path + " — " - + t.getMessage()), + return Result.lift(t -> Causes.cause("Failed to read grammar: " + path + " — " + t.getMessage()), () -> Files.readString(path)); } } diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/ParseTracer.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/ParseTracer.java index 5867431..f177323 100644 --- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/ParseTracer.java +++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/ParseTracer.java @@ -48,7 +48,7 @@ public long elapsedNanos() { } public void recordRuleEnter(String rule, int offset) { - ruleEntries++; + ruleEntries++ ; records.add(TraceRecord.traceRecord(TraceRecord.EventKind.RULE_ENTER, rule, offset, elapsedNanos(), "")); } @@ -61,27 +61,27 @@ public void recordRuleFailure(String rule, int offset) { } public void recordCacheHit(String rule, int offset) { - cacheHits++; + cacheHits++ ; records.add(TraceRecord.traceRecord(TraceRecord.EventKind.CACHE_HIT, rule, offset, elapsedNanos(), "")); } public void recordCacheMiss(String rule, int offset) { - cacheMisses++; + cacheMisses++ ; records.add(TraceRecord.traceRecord(TraceRecord.EventKind.CACHE_MISS, rule, offset, elapsedNanos(), "")); } public void recordCachePut(String rule, int offset) { - cachePuts++; + cachePuts++ ; records.add(TraceRecord.traceRecord(TraceRecord.EventKind.CACHE_PUT, rule, offset, elapsedNanos(), "")); } public void recordCutFired(String rule, int offset) { - cutsFired++; + cutsFired++ ; records.add(TraceRecord.traceRecord(TraceRecord.EventKind.CUT_FIRED, rule, offset, elapsedNanos(), "cut")); } public void note(String detail) { - records.add(TraceRecord.traceRecord(TraceRecord.EventKind.NOTE, "", -1, elapsedNanos(), detail)); + records.add(TraceRecord.traceRecord(TraceRecord.EventKind.NOTE, "", - 1, elapsedNanos(), detail)); } public List records() { @@ -128,9 +128,12 @@ private final class WalkState { int trivia; void visit(CstNode node) { - nodes++; - trivia += node.leadingTrivia().size() + node.trailingTrivia().size(); - int offset = node.span().startOffset(); + nodes++ ; + trivia += node.leadingTrivia() + .size() + node.trailingTrivia() + .size(); + int offset = node.span() + .startOffset(); switch (node) { case CstNode.NonTerminal nt -> { recordRuleEnter(nt.rule(), offset); @@ -181,11 +184,18 @@ public String pretty() { var sb = new StringBuilder(); sb.append("trace (" + records.size() + " events)\n"); sb.append(String.format(" rule entries: %d, cache hits: %d, misses: %d, puts: %d, cuts fired: %d%n", - ruleEntries, cacheHits, cacheMisses, cachePuts, cutsFired)); + ruleEntries, + cacheHits, + cacheMisses, + cachePuts, + cutsFired)); for (var rec : records) { sb.append(String.format(" %-14s %-30s @%-5d +%dus %s%n", rec.kind(), - rec.rule().isEmpty() ? "-" : rec.rule(), + rec.rule() + .isEmpty() + ? "-" + : rec.rule(), rec.offset(), rec.elapsedNanos() / 1000L, rec.detail())); @@ -198,7 +208,9 @@ public String pretty() { * need the count without constructing a WalkResult. */ public static int countTrivia(CstNode root) { - int count = root.leadingTrivia().size() + root.trailingTrivia().size(); + int count = root.leadingTrivia() + .size() + root.trailingTrivia() + .size(); if (root instanceof CstNode.NonTerminal nt) { for (var child : nt.children()) { count += countTrivia(child); diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundEngine.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundEngine.java index ebe883d..1a02103 100644 --- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundEngine.java +++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundEngine.java @@ -25,7 +25,8 @@ private PlaygroundEngine() {} * the playground surfaces need. */ public static Result run(ParseRequest request) { - return PegParser.fromGrammar(request.grammar(), buildConfig(request)) + return PegParser.fromGrammar(request.grammar(), + buildConfig(request)) .map(parser -> executeParse(parser, request)); } @@ -33,10 +34,15 @@ private static ParseOutcome executeParse(Parser parser, ParseRequest request) { var tracer = ParseTracer.start(); var parseResult = parseWithRecovery(parser, request); var nodeOption = parseResult.node(); - int nodeCount = nodeOption.map(ParseTracer::countNodes).or(0); - int triviaCount = nodeOption.map(ParseTracer::countTrivia).or(0); + int nodeCount = nodeOption.map(ParseTracer::countNodes) + .or(0); + int triviaCount = nodeOption.map(ParseTracer::countTrivia) + .or(0); nodeOption.onPresent(tracer::walkCst); - var stats = tracer.stats(nodeCount, triviaCount, parseResult.diagnostics().size()); + var stats = tracer.stats(nodeCount, + triviaCount, + parseResult.diagnostics() + .size()); return new ParseOutcome(nodeOption, parseResult.diagnostics(), stats, tracer, parseResult.source()); } @@ -79,7 +85,6 @@ public record ParseOutcome(Option node, Stats stats, ParseTracer tracer, String source) { - public boolean hasNode() { return node.isPresent(); } diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundRepl.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundRepl.java index 7a63502..9e71405 100644 --- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundRepl.java +++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundRepl.java @@ -49,7 +49,7 @@ public final class PlaygroundRepl { private RecoveryStrategy recovery = RecoveryStrategy.BASIC; private Option startRule = Option.none(); private String grammarCache = ""; - private long grammarMtime = -1L; + private long grammarMtime = - 1L; public PlaygroundRepl(Path grammarPath, BufferedReader reader, PrintStream out, boolean trace) { this.grammarPath = grammarPath; @@ -79,7 +79,7 @@ public static void main(String[] args) throws IOException { return; } boolean trace = false; - for (int i = 1; i < args.length; i++) { + for (int i = 1; i < args.length; i++ ) { if ("--trace".equals(args[i])) { trace = true; } @@ -128,19 +128,23 @@ boolean handleCommand(String line) throws IOException { private boolean handleMetaCommand(String line) throws IOException { String[] parts = line.split("\\s+", 2); String cmd = parts[0]; - String arg = parts.length > 1 ? parts[1].trim() : ""; + String arg = parts.length > 1 + ? parts[1].trim() + : ""; switch (cmd) { - case ":quit", ":q", ":exit" -> { + case":quit", ":q", ":exit" -> { return true; } - case ":help" -> printHelp(); - case ":trace" -> trace = parseOnOff(arg, trace); - case ":packrat" -> packrat = parseOnOff(arg, packrat); - case ":trivia" -> captureTrivia = parseOnOff(arg, captureTrivia); - case ":recovery" -> recovery = parseRecovery(arg, recovery); - case ":start" -> startRule = arg.isEmpty() ? Option.none() : Option.some(arg); - case ":reload" -> forceReload(); - case ":status" -> printStatus(); + case":help" -> printHelp(); + case":trace" -> trace = parseOnOff(arg, trace); + case":packrat" -> packrat = parseOnOff(arg, packrat); + case":trivia" -> captureTrivia = parseOnOff(arg, captureTrivia); + case":recovery" -> recovery = parseRecovery(arg, recovery); + case":start" -> startRule = arg.isEmpty() + ? Option.none() + : Option.some(arg); + case":reload" -> forceReload(); + case":status" -> printStatus(); default -> out.println("unknown command: " + cmd + " (try :help)"); } return false; @@ -162,7 +166,11 @@ private void printHelp() { private void printStatus() { out.println(String.format("grammar: %s (mtime=%d, %d chars)", grammarPath, grammarMtime, grammarCache.length())); out.println(String.format("packrat=%s trivia=%s recovery=%s trace=%s start=%s", - packrat, captureTrivia, recovery, trace, startRule.or(""))); + packrat, + captureTrivia, + recovery, + trace, + startRule.or(""))); } private static boolean parseOnOff(String arg, boolean current) { @@ -170,8 +178,8 @@ private static boolean parseOnOff(String arg, boolean current) { return !current; } return switch (arg.toLowerCase(Locale.ROOT)) { - case "on", "true", "yes", "1" -> true; - case "off", "false", "no", "0" -> false; + case"on", "true", "yes", "1" -> true; + case"off", "false", "no", "0" -> false; default -> current; }; } @@ -180,7 +188,7 @@ private static RecoveryStrategy parseRecovery(String arg, RecoveryStrategy curre if (arg.isEmpty()) { return current; } - try { + try{ return RecoveryStrategy.valueOf(arg.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException ex) { return current; @@ -188,12 +196,13 @@ private static RecoveryStrategy parseRecovery(String arg, RecoveryStrategy curre } private void forceReload() throws IOException { - grammarMtime = -1L; + grammarMtime = - 1L; loadGrammarIfChanged(); } private void loadGrammarIfChanged() throws IOException { - long mtime = Files.getLastModifiedTime(grammarPath).to(TimeUnit.MILLISECONDS); + long mtime = Files.getLastModifiedTime(grammarPath) + .to(TimeUnit.MILLISECONDS); if (mtime == grammarMtime) { return; } @@ -215,7 +224,9 @@ private void reportOutcome(ParseOutcome outcome) { return; } var stats = outcome.stats(); - String status = outcome.hasErrors() ? "FAIL" : "OK"; + String status = outcome.hasErrors() + ? "FAIL" + : "OK"; out.println(String.format("%s nodes=%d trivia=%d %.3f ms", status, stats.nodeCount(), @@ -227,7 +238,8 @@ private void reportOutcome(ParseOutcome outcome) { } } if (trace) { - out.print(outcome.tracer().pretty()); + out.print(outcome.tracer() + .pretty()); } } } diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundServer.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundServer.java index 6325c0d..a365674 100644 --- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundServer.java +++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/PlaygroundServer.java @@ -1,7 +1,5 @@ package org.pragmatica.peg.playground; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; import org.pragmatica.lang.Cause; import org.pragmatica.lang.Option; import org.pragmatica.lang.Result; @@ -22,6 +20,9 @@ import java.util.Map; import java.util.regex.Pattern; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + /** * Embedded HTTP server for the peglib playground. Binds to localhost on the * configured port, serves the static SPA under {@code /}, and accepts JSON @@ -36,8 +37,10 @@ public final class PlaygroundServer { private static final int DEFAULT_PORT = 8080; private static final int STATIC_READ_BUFFER = 4096; + /** Max size of an inbound request body (1 MiB). Larger bodies are rejected with HTTP 413. */ private static final int MAX_REQUEST_BODY_BYTES = 1024 * 1024; + /** Allow-list for static-asset paths after slash normalization. */ private static final Pattern STATIC_PATH_ALLOWLIST = Pattern.compile("^/[A-Za-z0-9._/-]*$"); @@ -62,7 +65,8 @@ public static void main(String[] args) throws IOException { var server = start(port); System.out.println("peglib playground: http://localhost:" + server.port()); System.out.println("press Ctrl-C to stop"); - Runtime.getRuntime().addShutdownHook(new Thread(server::stop)); + Runtime.getRuntime() + .addShutdownHook(new Thread(server::stop)); } /** @@ -76,7 +80,9 @@ public static PlaygroundServer start(int port) throws IOException { server.createContext("/", PlaygroundServer::handleStatic); server.setExecutor(null); server.start(); - return new PlaygroundServer(server, server.getAddress().getPort()); + return new PlaygroundServer(server, + server.getAddress() + .getPort()); } public int port() { @@ -100,9 +106,12 @@ private static void handleParse(HttpExchange exchange) throws IOException { // the whole payload. byte[] bytes = in.readNBytes(MAX_REQUEST_BODY_BYTES + 1); if (bytes.length > MAX_REQUEST_BODY_BYTES) { - sendJson(exchange, 413, - Map.of("error", "payload too large", - "detail", "request body exceeds " + MAX_REQUEST_BODY_BYTES + " bytes")); + sendJson(exchange, + 413, + Map.of("error", + "payload too large", + "detail", + "request body exceeds " + MAX_REQUEST_BODY_BYTES + " bytes")); return; } body = new String(bytes, StandardCharsets.UTF_8); @@ -135,8 +144,12 @@ private static Map grammarErrorPayload(String message) { private static Map buildResponse(ParseOutcome outcome) { var payload = new LinkedHashMap(); - payload.put("ok", outcome.hasNode() && !outcome.hasErrors()); - payload.put("tree", outcome.node().fold(() -> null, node -> node)); + payload.put("ok", + outcome.hasNode() && !outcome.hasErrors()); + payload.put("tree", + outcome.node() + .fold(() -> null, + node -> node)); payload.put("diagnostics", outcome.diagnostics()); payload.put("stats", outcome.stats()); return payload; @@ -149,7 +162,8 @@ private static Map buildResponse(ParseOutcome outcome) { * the same monadic channel. */ static Result parseRequestBody(String body) { - return Result.lift(BadRequest::new, () -> JsonDecoder.decodeObject(body)) + return Result.lift(BadRequest::new, + () -> JsonDecoder.decodeObject(body)) .flatMap(PlaygroundServer::buildRequest); } @@ -159,13 +173,13 @@ private static Result buildRequest(Map obj) { return new BadRequest("grammar field is required").result(); } return Result.success(new ParseRequest( - grammar, - stringField(obj, "input", ""), - optionalString(obj, "startRule"), - booleanField(obj, "packrat", true), - parseRecovery(stringField(obj, "recovery", "BASIC")), - booleanField(obj, "trivia", true), - "ast".equalsIgnoreCase(stringField(obj, "mode", "cst")))); + grammar, + stringField(obj, "input", ""), + optionalString(obj, "startRule"), + booleanField(obj, "packrat", true), + parseRecovery(stringField(obj, "recovery", "BASIC")), + booleanField(obj, "trivia", true), + "ast".equalsIgnoreCase(stringField(obj, "mode", "cst")))); } private static Map badRequestPayload(Result failed) { @@ -176,7 +190,10 @@ private static Map badRequestPayload(Result failed /** Adapter-boundary cause for parse-request decoding/validation failures. */ record BadRequest(String message) implements Cause { BadRequest(Throwable t) { - this(t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage()); + this(t.getMessage() == null + ? t.getClass() + .getSimpleName() + : t.getMessage()); } } @@ -184,7 +201,7 @@ private static RecoveryStrategy parseRecovery(String raw) { if (raw == null) { return RecoveryStrategy.BASIC; } - try { + try{ return RecoveryStrategy.valueOf(raw.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException ex) { return RecoveryStrategy.BASIC; @@ -193,7 +210,9 @@ private static RecoveryStrategy parseRecovery(String raw) { private static String stringField(Map obj, String key, String fallback) { Object value = obj.get(key); - return value instanceof String s ? s : fallback; + return value instanceof String s + ? s + : fallback; } private static boolean booleanField(Map obj, String key, boolean fallback) { @@ -232,7 +251,9 @@ private static void handleStatic(HttpExchange exchange) throws IOException { return; } addSecurityHeaders(exchange); - exchange.getResponseHeaders().add("Content-Type", contentType(safePath)); + exchange.getResponseHeaders() + .add("Content-Type", + contentType(safePath)); exchange.sendResponseHeaders(200, body.length); try (var out = exchange.getResponseBody()) { out.write(body); @@ -250,7 +271,7 @@ static String sanitizeStaticPath(String rawPath) { if (rawPath == null || rawPath.isEmpty() || rawPath.equals("/")) { return "/index.html"; } - for (int i = 0; i < rawPath.length(); i++) { + for (int i = 0; i < rawPath.length(); i++ ) { char c = rawPath.charAt(i); if (c < 0x20 || c == '\\') { return null; @@ -263,7 +284,8 @@ static String sanitizeStaticPath(String rawPath) { return null; } } - if (!STATIC_PATH_ALLOWLIST.matcher(collapsed).matches()) { + if (!STATIC_PATH_ALLOWLIST.matcher(collapsed) + .matches()) { return null; } return collapsed; @@ -308,9 +330,11 @@ private static String contentType(String path) { // === response helpers === private static void sendJson(HttpExchange exchange, int status, Object payload) throws IOException { - byte[] body = JsonEncoder.encode(payload).getBytes(StandardCharsets.UTF_8); + byte[] body = JsonEncoder.encode(payload) + .getBytes(StandardCharsets.UTF_8); addSecurityHeaders(exchange); - exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8"); + exchange.getResponseHeaders() + .add("Content-Type", "application/json; charset=utf-8"); exchange.sendResponseHeaders(status, body.length); try (var out = exchange.getResponseBody()) { out.write(body); @@ -320,7 +344,8 @@ private static void sendJson(HttpExchange exchange, int status, Object payload) private static void sendPlain(HttpExchange exchange, int status, String message) throws IOException { byte[] body = message.getBytes(StandardCharsets.UTF_8); addSecurityHeaders(exchange); - exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); + exchange.getResponseHeaders() + .add("Content-Type", "text/plain; charset=utf-8"); exchange.sendResponseHeaders(status, body.length); try (var out = exchange.getResponseBody()) { out.write(body); @@ -328,9 +353,9 @@ private static void sendPlain(HttpExchange exchange, int status, String message) } private static int parsePort(String[] args) { - for (int i = 0; i < args.length; i++) { + for (int i = 0; i < args.length; i++ ) { if ("--port".equals(args[i]) && i + 1 < args.length) { - try { + try{ return Integer.parseInt(args[i + 1]); } catch (NumberFormatException ex) { System.err.println("invalid port, using default " + DEFAULT_PORT); diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/Stats.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/Stats.java index 8537c7c..9f4f2f0 100644 --- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/Stats.java +++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/Stats.java @@ -1,5 +1,4 @@ package org.pragmatica.peg.playground; - /** * Per-parse statistics returned alongside the CST / diagnostics by the * playground server and REPL. Cheaply computable from the tracer event @@ -24,7 +23,6 @@ public record Stats(long timeMicros, int cachePuts, int cutsFired, int diagnosticCount) { - public static Stats empty() { return new Stats(0L, 0, 0, 0, 0, 0, 0, 0, 0); } diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/TraceRecord.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/TraceRecord.java index 58bd785..b6f2c50 100644 --- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/TraceRecord.java +++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/TraceRecord.java @@ -1,5 +1,4 @@ package org.pragmatica.peg.playground; - /** * Single tracer event recorded by {@link ParseTracer} during a parse. * Events are append-only and form a chronological log of how the engine @@ -20,7 +19,6 @@ public record TraceRecord(EventKind kind, int offset, long elapsedNanos, String detail) { - public enum EventKind { RULE_ENTER, RULE_SUCCESS, @@ -37,10 +35,10 @@ public static TraceRecord traceRecord(EventKind kind, int offset, long elapsedNanos, String detail) { - return new TraceRecord(kind, - rule == null ? "" : rule, - offset, - elapsedNanos, - detail == null ? "" : detail); + return new TraceRecord(kind, rule == null + ? "" + : rule, offset, elapsedNanos, detail == null + ? "" + : detail); } } diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonDecoder.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonDecoder.java index 342a74e..b22c5d2 100644 --- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonDecoder.java +++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonDecoder.java @@ -35,7 +35,7 @@ public static Object decode(String text) { @SuppressWarnings("unchecked") public static Map decodeObject(String text) { var decoded = decode(text); - if (decoded instanceof Map map) { + if (decoded instanceof Map< ? , ? > map) { return (Map) map; } return new LinkedHashMap<>(); @@ -48,11 +48,11 @@ private Object parseValue() { } char c = input.charAt(pos); return switch (c) { - case '{' -> parseObject(); - case '[' -> parseArray(); - case '"' -> parseString(); - case 't', 'f' -> parseBoolean(); - case 'n' -> parseNull(); + case'{' -> parseObject(); + case'[' -> parseArray(); + case'"' -> parseString(); + case't', 'f' -> parseBoolean(); + case'n' -> parseNull(); default -> parseNumber(); }; } @@ -62,7 +62,7 @@ private Map parseObject() { var result = new LinkedHashMap(); skipWs(); if (peek() == '}') { - pos++; + pos++ ; return result; } while (true) { @@ -75,11 +75,11 @@ private Map parseObject() { skipWs(); char nc = peek(); if (nc == ',') { - pos++; + pos++ ; continue; } if (nc == '}') { - pos++; + pos++ ; return result; } throw new IllegalArgumentException("expected ',' or '}' at offset " + pos); @@ -91,7 +91,7 @@ private List parseArray() { var result = new ArrayList<>(); skipWs(); if (peek() == ']') { - pos++; + pos++ ; return result; } while (true) { @@ -100,11 +100,11 @@ private List parseArray() { skipWs(); char nc = peek(); if (nc == ',') { - pos++; + pos++ ; continue; } if (nc == ']') { - pos++; + pos++ ; return result; } throw new IllegalArgumentException("expected ',' or ']' at offset " + pos); @@ -115,7 +115,7 @@ private String parseString() { expect('"'); var sb = new StringBuilder(); while (pos < input.length()) { - char c = input.charAt(pos++); + char c = input.charAt(pos++ ); if (c == '"') { return sb.toString(); } @@ -123,17 +123,17 @@ private String parseString() { if (pos >= input.length()) { throw new IllegalArgumentException("unterminated escape in string"); } - char esc = input.charAt(pos++); + char esc = input.charAt(pos++ ); switch (esc) { - case '"' -> sb.append('"'); - case '\\' -> sb.append('\\'); - case '/' -> sb.append('/'); - case 'b' -> sb.append('\b'); - case 'f' -> sb.append('\f'); - case 'n' -> sb.append('\n'); - case 'r' -> sb.append('\r'); - case 't' -> sb.append('\t'); - case 'u' -> { + case'"' -> sb.append('"'); + case'\\' -> sb.append('\\'); + case'/' -> sb.append('/'); + case'b' -> sb.append('\b'); + case'f' -> sb.append('\f'); + case'n' -> sb.append('\n'); + case'r' -> sb.append('\r'); + case't' -> sb.append('\t'); + case'u' -> { if (pos + 4 > input.length()) { throw new IllegalArgumentException("bad unicode escape"); } @@ -143,7 +143,7 @@ private String parseString() { } default -> throw new IllegalArgumentException("bad escape: \\" + esc); } - } else { + }else { sb.append(c); } } @@ -173,17 +173,17 @@ private Object parseNull() { private Object parseNumber() { int start = pos; if (peek() == '-') { - pos++; + pos++ ; } boolean isFloat = false; while (pos < input.length()) { char c = input.charAt(pos); if (Character.isDigit(c)) { - pos++; - } else if (c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') { + pos++ ; + }else if (c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') { isFloat = true; - pos++; - } else { + pos++ ; + }else { break; } } @@ -201,7 +201,7 @@ private void expect(char c) { if (pos >= input.length() || input.charAt(pos) != c) { throw new IllegalArgumentException("expected '" + c + "' at offset " + pos); } - pos++; + pos++ ; } private char peek() { @@ -215,8 +215,8 @@ private void skipWs() { while (pos < input.length()) { char c = input.charAt(pos); if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { - pos++; - } else { + pos++ ; + }else { break; } } diff --git a/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonEncoder.java b/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonEncoder.java index 5d500ff..aceabcd 100644 --- a/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonEncoder.java +++ b/peglib-playground/src/main/java/org/pragmatica/peg/playground/internal/JsonEncoder.java @@ -44,13 +44,13 @@ private static void write(StringBuilder sb, Object value) { case Trivia t -> writeTrivia(sb, t); case Diagnostic d -> writeDiagnostic(sb, d); case Stats s -> writeStats(sb, s); - case Map map -> writeObject(sb, map); - case List list -> writeArray(sb, list); + case Map< ? , ? > map -> writeObject(sb, map); + case List< ? > list -> writeArray(sb, list); default -> writeString(sb, value.toString()); } } - private static void writeObject(StringBuilder sb, Map map) { + private static void writeObject(StringBuilder sb, Map< ? , ? > map) { sb.append('{'); boolean first = true; for (var entry : map.entrySet()) { @@ -58,14 +58,15 @@ private static void writeObject(StringBuilder sb, Map map) { sb.append(','); } first = false; - writeString(sb, String.valueOf(entry.getKey())); + writeString(sb, + String.valueOf(entry.getKey())); sb.append(':'); write(sb, entry.getValue()); } sb.append('}'); } - private static void writeArray(StringBuilder sb, List list) { + private static void writeArray(StringBuilder sb, List< ? > list) { sb.append('['); boolean first = true; for (var item : list) { @@ -84,15 +85,25 @@ private static void writeNode(StringBuilder sb, CstNode node) { writeString(sb, nodeKind(node)); sb.append(",\"rule\":"); writeString(sb, node.rule()); - sb.append(",\"start\":").append(node.span().startOffset()); - sb.append(",\"end\":").append(node.span().endOffset()); - sb.append(",\"line\":").append(node.span().startLine()); - sb.append(",\"column\":").append(node.span().startColumn()); - if (!node.leadingTrivia().isEmpty()) { + sb.append(",\"start\":") + .append(node.span() + .startOffset()); + sb.append(",\"end\":") + .append(node.span() + .endOffset()); + sb.append(",\"line\":") + .append(node.span() + .startLine()); + sb.append(",\"column\":") + .append(node.span() + .startColumn()); + if (!node.leadingTrivia() + .isEmpty()) { sb.append(",\"leadingTrivia\":"); writeTriviaList(sb, node.leadingTrivia()); } - if (!node.trailingTrivia().isEmpty()) { + if (!node.trailingTrivia() + .isEmpty()) { sb.append(",\"trailingTrivia\":"); writeTriviaList(sb, node.trailingTrivia()); } @@ -153,8 +164,12 @@ private static void writeTrivia(StringBuilder sb, Trivia trivia) { sb.append('{'); sb.append("\"kind\":"); writeString(sb, ParseTracer.triviaKind(trivia)); - sb.append(",\"start\":").append(trivia.span().startOffset()); - sb.append(",\"end\":").append(trivia.span().endOffset()); + sb.append(",\"start\":") + .append(trivia.span() + .startOffset()); + sb.append(",\"end\":") + .append(trivia.span() + .endOffset()); sb.append(",\"text\":"); writeString(sb, trivia.text()); sb.append('}'); @@ -163,18 +178,32 @@ private static void writeTrivia(StringBuilder sb, Trivia trivia) { private static void writeDiagnostic(StringBuilder sb, Diagnostic diag) { sb.append('{'); sb.append("\"severity\":"); - writeString(sb, diag.severity().display()); + writeString(sb, + diag.severity() + .display()); sb.append(",\"message\":"); writeString(sb, diag.message()); - sb.append(",\"line\":").append(diag.span().startLine()); - sb.append(",\"column\":").append(diag.span().startColumn()); - sb.append(",\"start\":").append(diag.span().startOffset()); - sb.append(",\"end\":").append(diag.span().endOffset()); - if (diag.tag().isPresent()) { + sb.append(",\"line\":") + .append(diag.span() + .startLine()); + sb.append(",\"column\":") + .append(diag.span() + .startColumn()); + sb.append(",\"start\":") + .append(diag.span() + .startOffset()); + sb.append(",\"end\":") + .append(diag.span() + .endOffset()); + if (diag.tag() + .isPresent()) { sb.append(",\"tag\":"); - writeString(sb, diag.tag().unwrap()); + writeString(sb, + diag.tag() + .unwrap()); } - if (!diag.notes().isEmpty()) { + if (!diag.notes() + .isEmpty()) { sb.append(",\"notes\":["); boolean first = true; for (var note : diag.notes()) { @@ -191,34 +220,43 @@ private static void writeDiagnostic(StringBuilder sb, Diagnostic diag) { private static void writeStats(StringBuilder sb, Stats stats) { sb.append('{'); - sb.append("\"timeMicros\":").append(stats.timeMicros()); - sb.append(",\"nodeCount\":").append(stats.nodeCount()); - sb.append(",\"triviaCount\":").append(stats.triviaCount()); - sb.append(",\"ruleEntries\":").append(stats.ruleEntries()); - sb.append(",\"cacheHits\":").append(stats.cacheHits()); - sb.append(",\"cacheMisses\":").append(stats.cacheMisses()); - sb.append(",\"cachePuts\":").append(stats.cachePuts()); - sb.append(",\"cutsFired\":").append(stats.cutsFired()); - sb.append(",\"diagnosticCount\":").append(stats.diagnosticCount()); + sb.append("\"timeMicros\":") + .append(stats.timeMicros()); + sb.append(",\"nodeCount\":") + .append(stats.nodeCount()); + sb.append(",\"triviaCount\":") + .append(stats.triviaCount()); + sb.append(",\"ruleEntries\":") + .append(stats.ruleEntries()); + sb.append(",\"cacheHits\":") + .append(stats.cacheHits()); + sb.append(",\"cacheMisses\":") + .append(stats.cacheMisses()); + sb.append(",\"cachePuts\":") + .append(stats.cachePuts()); + sb.append(",\"cutsFired\":") + .append(stats.cutsFired()); + sb.append(",\"diagnosticCount\":") + .append(stats.diagnosticCount()); sb.append('}'); } private static void writeString(StringBuilder sb, String s) { sb.append('"'); - for (int i = 0; i < s.length(); i++) { + for (int i = 0; i < s.length(); i++ ) { char c = s.charAt(i); switch (c) { - case '"' -> sb.append("\\\""); - case '\\' -> sb.append("\\\\"); - case '\n' -> sb.append("\\n"); - case '\r' -> sb.append("\\r"); - case '\t' -> sb.append("\\t"); - case '\b' -> sb.append("\\b"); - case '\f' -> sb.append("\\f"); + case'"' -> sb.append("\\\""); + case'\\' -> sb.append("\\\\"); + case'\n' -> sb.append("\\n"); + case'\r' -> sb.append("\\r"); + case'\t' -> sb.append("\\t"); + case'\b' -> sb.append("\\b"); + case'\f' -> sb.append("\\f"); default -> { if (c < 0x20) { sb.append(String.format("\\u%04x", (int) c)); - } else { + }else { sb.append(c); } } From 39e11f9c841ab7c2d5ae99acca628ce06bbe3e3e Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 12:53:29 +0200 Subject: [PATCH 12/46] =?UTF-8?q?feat:=20phase=201.5/1.6=20=E2=80=94=20Nod?= =?UTF-8?q?eIndex=20LongLongMap=20+=20Path=20D=20applyIncremental=20+=20to?= =?UTF-8?q?mbstone=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pragmatica/peg/parser/ParsingContext.java | 24 ++- .../org/pragmatica/peg/parser/PegEngine.java | 31 ++- .../LinearProbingLongLongMap.java | 27 ++- .../internal/IncrementalSession.java | 89 ++++++-- .../internal/LinearProbingLongLongMap.java | 199 ++++++++++++++++++ .../peg/incremental/internal/LongLongMap.java | 52 +++++ .../peg/incremental/internal/NodeIndex.java | 151 +++++++++++-- .../incremental/internal/SessionFactory.java | 32 ++- .../peg/incremental/internal/TreeSplicer.java | 59 +++++- .../experimental/LongLongMapTest.java | 24 +++ .../incremental/internal/LongLongMapTest.java | 89 ++++++++ 11 files changed, 718 insertions(+), 59 deletions(-) create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/LinearProbingLongLongMap.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/LongLongMap.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/LongLongMapTest.java diff --git a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java index 38dd9fc..a984978 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParsingContext.java @@ -83,7 +83,10 @@ public final class ParsingContext { // backtracked alternative's override does not leak forward. private Option pendingFailureRecoveryOverride = Option.none(); - private ParsingContext(String input, Grammar grammar, ParserConfig config) { + private ParsingContext(String input, Grammar grammar, ParserConfig config, IdGenerator idGen) { + if (idGen == null) { + throw new IllegalArgumentException("idGen must not be null"); + } this.input = input; this.grammar = grammar; this.config = config; @@ -95,7 +98,7 @@ private ParsingContext(String input, Grammar grammar, ParserConfig config) { : Option.none(); this.captures = new HashMap<>(); this.diagnostics = new ArrayList<>(); - this.idGen = new IdGenerator.PerSessionCounter(); + this.idGen = idGen; this.pos = 0; this.line = 1; this.column = 1; @@ -107,8 +110,23 @@ private ParsingContext(String input, Grammar grammar, ParserConfig config) { this.recoveryStartPos = Option.none(); } + /** + * Default factory: allocates a fresh per-session ID counter. Used by + * one-shot parses ({@link PegEngine#parseCst}, etc.). + */ public static ParsingContext create(String input, Grammar grammar, ParserConfig config) { - return new ParsingContext(input, grammar, config); + return new ParsingContext(input, grammar, config, new IdGenerator.PerSessionCounter()); + } + + /** + * Phase 1.5 (v0.5.0): Session-aware factory. {@link IncrementalSession} owns + * a single {@link IdGenerator.PerSessionCounter} for the lifetime of a + * Session lineage and threads it through every reparse so node IDs remain + * stable across edits — a hard precondition for the Path D optimized + * {@code NodeIndex.applyIncremental}. + */ + public static ParsingContext create(String input, Grammar grammar, ParserConfig config, IdGenerator idGen) { + return new ParsingContext(input, grammar, config, idGen); } // === Position Management === diff --git a/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java b/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java index 3b92788..599888e 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/parser/PegEngine.java @@ -17,6 +17,7 @@ import org.pragmatica.peg.grammar.analysis.FirstCharAnalysis; import org.pragmatica.peg.tree.AstNode; import org.pragmatica.peg.tree.CstNode; +import org.pragmatica.peg.tree.IdGenerator; import org.pragmatica.peg.tree.SourceLocation; import org.pragmatica.peg.tree.SourceSpan; import org.pragmatica.peg.tree.Trivia; @@ -249,12 +250,24 @@ public Result parseCst(String input) { @Override public Result parseCst(String input, String startRule) { + return parseCst(input, startRule, new IdGenerator.PerSessionCounter()); + } + + /** + * Phase 1.5 (v0.5.0): caller-supplied id-generator overload. Used by + * {@code IncrementalSession} so node IDs stay stable across reparses + * within a Session lineage (precondition for Path D's optimized + * {@code NodeIndex.applyIncremental}). Plain {@link Parser#parseCst} + * callers continue to allocate a fresh per-call counter via the + * no-idGen overload above. + */ + public Result parseCst(String input, String startRule, IdGenerator idGen) { var ruleOpt = lookupRule(startRule); if (ruleOpt.isEmpty()) { return new ParseError.SemanticError( SourceLocation.START, "Unknown rule: " + startRule).result(); } - var ctx = ParsingContext.create(input, grammar, config); + var ctx = ParsingContext.create(input, grammar, config, idGen); ctx.setSuggestionVocabulary(suggestionVocabulary); var result = parseRule(ctx, ruleOpt.unwrap()); if (result.isFailure()) { @@ -382,6 +395,20 @@ public ParseResultWithDiagnostics parseCstWithDiagnostics(String input, String s */ @Override public Result parseRuleAt(Class< ? extends RuleId> ruleId, String input, int offset) { + return parseRuleAt(ruleId, input, offset, new IdGenerator.PerSessionCounter()); + } + + /** + * Phase 1.5 (v0.5.0): caller-supplied id-generator overload of + * {@link Parser#parseRuleAt(Class, String, int)}. Used by + * {@code IncrementalSession} during incremental reparse so the spliced + * subtree's node IDs come from the same counter as the rest of the + * session's tree (Path D precondition). + */ + public Result parseRuleAt(Class< ? extends RuleId> ruleId, + String input, + int offset, + IdGenerator idGen) { if (ruleId == null) { return new ParseError.SemanticError( SourceLocation.START, "Rule id class is null").result(); @@ -400,7 +427,7 @@ public Result parseRuleAt(Class< ? extends RuleId> ruleId, String return new ParseError.SemanticError( SourceLocation.START, "Unknown rule for class " + ruleId.getSimpleName() + ": " + ruleName).result(); } - var ctx = ParsingContext.create(input, grammar, config); + var ctx = ParsingContext.create(input, grammar, config, idGen); ctx.setSuggestionVocabulary(suggestionVocabulary); ctx.restoreLocation(computeLocation(input, offset)); var result = parseRule(ctx, ruleOpt.unwrap()); diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java index 1ccc7b3..ca58f55 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java @@ -40,6 +40,7 @@ public final class LinearProbingLongLongMap implements LongLongMap { private long[] values; private byte[] state; private int size; + private int tombstones; private int threshold; private int mask; @@ -56,6 +57,7 @@ public LinearProbingLongLongMap(int initialCapacity) { this.values = new long[capacity]; this.state = new byte[capacity]; this.size = 0; + this.tombstones = 0; this.mask = capacity - 1; this.threshold = (int)(capacity * LOAD_FACTOR); } @@ -67,15 +69,26 @@ public void put(long key, long value) { while (true) { byte s = state[slot]; if (s == EMPTY) { - int target = firstTombstone >= 0 + boolean reusedTombstone = firstTombstone >= 0; + int target = reusedTombstone ? firstTombstone : slot; keys[target] = key; values[target] = value; state[target] = OCCUPIED; - size++ ; + size++; + if (reusedTombstone) { + tombstones--; + } if (size > threshold) { resize(state.length<< 1); + } else if (size + tombstones > threshold) { + // Tombstones alone are crowding the table — rehash at the + // current capacity to clear them and keep probe chains short. + // Without this, repeated put/remove cycles can saturate the + // table with TOMBSTONE+OCCUPIED slots and cause the put + // probe loop (which only stops at EMPTY) to spin forever. + resize(state.length); } return; } @@ -130,7 +143,8 @@ public void remove(long key) { } if (s == OCCUPIED && keys[slot] == key) { state[slot] = TOMBSTONE; - size-- ; + size--; + tombstones++; return; } slot = (slot + 1) & mask; @@ -146,6 +160,7 @@ public int size() { public void clear() { java.util.Arrays.fill(state, EMPTY); size = 0; + tombstones = 0; } @Override @@ -156,12 +171,13 @@ public LongLongMap copy() { System.arraycopy(this.values, 0, copy.values, 0, this.values.length); System.arraycopy(this.state, 0, copy.state, 0, this.state.length); copy.size = this.size; + copy.tombstones = this.tombstones; return copy; } @Override public void forEachEntry(EntryVisitor visitor) { - for (int i = 0; i < state.length; i++ ) { + for (int i = 0; i < state.length; i++) { if (state[i] == OCCUPIED) { long oldValue = values[i]; long newValue = visitor.visit(keys[i], oldValue); @@ -186,7 +202,8 @@ private void resize(int newCapacity) { this.mask = newCapacity - 1; this.threshold = (int)(newCapacity * LOAD_FACTOR); this.size = 0; - for (int i = 0; i < oldState.length; i++ ) { + this.tombstones = 0; + for (int i = 0; i < oldState.length; i++) { if (oldState[i] == OCCUPIED) { put(oldKeys[i], oldValues[i]); } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java index babbdfc..7d52983 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java @@ -4,7 +4,11 @@ import org.pragmatica.peg.incremental.Edit; import org.pragmatica.peg.incremental.Session; import org.pragmatica.peg.incremental.Stats; +import org.pragmatica.peg.parser.PegEngine; import org.pragmatica.peg.tree.CstNode; +import org.pragmatica.peg.tree.IdGenerator; + +import java.util.List; /** * Package-private {@link Session} implementation carrying the SPEC §5.1 @@ -36,13 +40,18 @@ record IncrementalSession( int cursor, CstNode enclosingNode, NodeIndex index, + IdGenerator idGen, Stats stats) implements Session { /** Build the initial session after a fresh full parse. */ - static IncrementalSession initial(SessionFactory factory, String text, int cursor, CstNode root) { + static IncrementalSession initial(SessionFactory factory, + String text, + int cursor, + CstNode root, + IdGenerator idGen) { var index = NodeIndex.build(root); var enclosing = index.smallestContaining(cursor) .or(root); - return new IncrementalSession(factory, text, root, cursor, enclosing, index, Stats.INITIAL); + return new IncrementalSession(factory, text, root, cursor, enclosing, index, idGen, Stats.INITIAL); } @Override @@ -88,6 +97,7 @@ public Session edit(Edit edit) { newCursor, nextEnclosing, nextIndex, + idGen, nextStats); } } @@ -109,13 +119,13 @@ public Session moveCursor(int newOffset) { } var newEnclosing = index.smallestContainingFrom(enclosingNode, clamped) .or(root); - return new IncrementalSession(factory, text, root, clamped, newEnclosing, index, stats); + return new IncrementalSession(factory, text, root, clamped, newEnclosing, index, idGen, stats); } @Override public Session reparseAll() { long t0 = System.nanoTime(); - var fresh = factory.parseFull(text); + var fresh = factory.parseFull(text, idGen); var freshIndex = NodeIndex.build(fresh); var enclosing = freshIndex.smallestContaining(cursor) .or(fresh); @@ -126,11 +136,11 @@ public Session reparseAll() { NodeIndex.flatten(fresh) .size(), System.nanoTime() - t0); - return new IncrementalSession(factory, text, fresh, cursor, enclosing, freshIndex, nextStats); + return new IncrementalSession(factory, text, fresh, cursor, enclosing, freshIndex, idGen, nextStats); } private Session fallback(String newText, int newCursor, long t0) { - var fresh = factory.parseFull(newText); + var fresh = factory.parseFull(newText, idGen); var freshIndex = NodeIndex.build(fresh); var enclosing = freshIndex.smallestContaining(newCursor) .or(fresh); @@ -141,18 +151,28 @@ private Session fallback(String newText, int newCursor, long t0) { NodeIndex.flatten(fresh) .size(), System.nanoTime() - t0); - return new IncrementalSession(factory, newText, fresh, newCursor, enclosing, freshIndex, nextStats); + return new IncrementalSession(factory, newText, fresh, newCursor, enclosing, freshIndex, idGen, nextStats); } /** - * Apply a successful incremental reparse: normalise trivia, rebuild the + * Apply a successful incremental reparse: normalise trivia, update the * NodeIndex, and return the next session snapshot. Extracted so the * caller in {@link #edit(Edit)} stays a single Result-style branch. + * + *

Phase 1.6 (v0.5.0): Path D — calls + * {@link NodeIndex#applyIncremental(CstNode, java.util.List, java.util.List)} + * instead of {@link NodeIndex#build(CstNode)}. Cost drops from O(N) to + * O(oldPivotSize + newPivotSize). The receiver index is invalidated; we + * use only the returned instance. + * + *

Trivia-redistribution caveat. When + * {@link TriviaRedistribution#normalizeSplicedTrivia} mutates the spliced + * subtree (currently a no-op for the leading-trivia direction; the seam + * exists for v2.5+), the {@code newPath} we computed before normalisation + * may carry stale record references for the pivot. We fall back to a full + * {@link NodeIndex#build} on that path so structural sharing isn't broken. */ private Session applyIncremental(IncrementalResult incremental, String newText, int newCursor, long t0) { - // v2: trivia-aware splice normalization (currently a no-op for the - // leading-trivia direction since parseRuleAt already attaches - // trivia per 0.2.4 attribution; the seam exists for v2.5+). var normalized = TriviaRedistribution.normalizeSplicedTrivia( incremental.newRoot, incremental.spliced); var nextStats = new Stats( @@ -162,10 +182,21 @@ private Session applyIncremental(IncrementalResult incremental, String newText, NodeIndex.flatten(incremental.spliced) .size(), System.nanoTime() - t0); - var nextIndex = NodeIndex.build(normalized); + NodeIndex nextIndex; + if (normalized == incremental.newRoot && incremental.oldPath != null && incremental.newPath != null) { + // Path D fast-path: trivia normalisation was a no-op (the common + // case today) — apply the optimised O(oldPivotSize + newPivotSize) + // index update. + nextIndex = index.applyIncremental(normalized, incremental.oldPath, incremental.newPath); + }else { + // Trivia normalisation mutated the tree, OR the pivot was the root + // (newPath == null path-was-root branch in buildIncrementalResult). + // Fall back to a full rebuild — correct, just not Path-D-fast. + nextIndex = NodeIndex.build(normalized); + } var nextEnclosing = nextIndex.smallestContaining(newCursor) .or(normalized); - return new IncrementalSession(factory, newText, normalized, newCursor, nextEnclosing, nextIndex, nextStats); + return new IncrementalSession(factory, newText, normalized, newCursor, nextEnclosing, nextIndex, idGen, nextStats); } /** @@ -214,11 +245,14 @@ private IncrementalResult buildIncrementalResult(CstNode.NonTerminal nt, int delta) { var path = index.pathTo(nt); if (path.isEmpty()) { - // pivot == root — reparsed subtree replaces root wholesale. - return new IncrementalResult(reparsedNode, reparsedNode, nt.rule()); + // pivot == root — reparsed subtree replaces root wholesale. No + // path information for Path D; the caller falls back to + // NodeIndex.build via the null-newPath branch in applyIncremental. + return new IncrementalResult(reparsedNode, reparsedNode, nt.rule(), null, null); } - var newRoot = TreeSplicer.spliceAndShift(path, nt, reparsedNode, editEnd, delta); - return new IncrementalResult(newRoot, reparsedNode, nt.rule()); + var oldPath = List.copyOf(path); + var splice = TreeSplicer.spliceAndShift(path, nt, reparsedNode, editEnd, delta); + return new IncrementalResult(splice.newRoot(), reparsedNode, nt.rule(), oldPath, splice.newPath()); } /** @@ -259,8 +293,12 @@ private Option reparseAt(CstNode.NonTerminal nt, String newText, int de .offset() + delta; var ruleId = factory.registry() .classFor(nt.rule()); - var partial = factory.parser() - .parseRuleAt(ruleId, newText, startOffset); + // Phase 1.5 (v0.5.0): route through the id-aware overload so the + // spliced subtree's CstNode ids come from THIS session's counter, + // not a fresh per-call one. Path D's NodeIndex.applyIncremental + // requires every node in the lineage to share the same id space. + var engine = (PegEngine) factory.parser(); + var partial = engine.parseRuleAt(ruleId, newText, startOffset, idGen); return partial.option() .filter(p -> p.node() .span() @@ -297,6 +335,15 @@ private static int shiftCursor(int oldCursor, Edit edit) { return oldCursor + edit.delta(); } - /** Result of a successful incremental reparse: the new root + pivot. */ - private record IncrementalResult(CstNode newRoot, CstNode spliced, String ruleName) {} + /** + * Result of a successful incremental reparse: the new root + pivot, plus + * the {@code oldPath} and {@code newPath} bookkeeping needed by Path D's + * {@link NodeIndex#applyIncremental}. {@code oldPath} and {@code newPath} + * are {@code null} when the pivot equals the root (no spine to splice). + */ + private record IncrementalResult(CstNode newRoot, + CstNode spliced, + String ruleName, + List oldPath, + List newPath) {} } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/LinearProbingLongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/LinearProbingLongLongMap.java new file mode 100644 index 0000000..ec12870 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/LinearProbingLongLongMap.java @@ -0,0 +1,199 @@ +package org.pragmatica.peg.incremental.internal; +/** + * Open-addressing, linear-probing implementation of {@link LongLongMap}. + * + *

Capacity is always a power of two so the slot index can be computed via a + * bitmask. Resize at load factor 0.75 doubles the table. + * + *

Slot occupancy is encoded as a single {@code byte[]} ({@link #EMPTY}, + * {@link #OCCUPIED}, {@link #TOMBSTONE}) rather than two parallel + * {@code boolean[]}s. This keeps each probe step to one byte fetch + + * branch instead of two array reads, and halves the metadata footprint + * (1 byte/slot vs 2 bytes/slot for two parallel boolean arrays — the JVM + * stores a {@code boolean[]} element as a byte). + * + *

Probing rules: + *

    + *
  • {@link #put}: stop at the first empty slot or matching key; the first + * tombstone seen on the probe is remembered and reused as the + * insertion target if the key is not already present further on. + *
  • {@link #get}, {@link #containsKey}, {@link #remove}: walk past + * tombstones, stop at empty. + *
+ * + *

Promoted to production from {@code experimental.LinearProbingLongLongMap} + * in Phase 1.5 (release 0.5.0). + * + * @since 0.5.0 + */ +public final class LinearProbingLongLongMap implements LongLongMap { + /** Default backing-table capacity used by {@link #LinearProbingLongLongMap()}. */ + private static final int DEFAULT_CAPACITY = 16; + + private static final int MIN_CAPACITY = 4; + + /** Resize when {@code size > capacity * 0.75}. */ + private static final double LOAD_FACTOR = 0.75; + + private static final byte EMPTY = 0; + private static final byte OCCUPIED = 1; + private static final byte TOMBSTONE = 2; + + private long[] keys; + private long[] values; + private byte[] state; + private int size; + private int tombstones; + private int threshold; + private int mask; + + public LinearProbingLongLongMap() { + this(DEFAULT_CAPACITY); + } + + public LinearProbingLongLongMap(int initialCapacity) { + if (initialCapacity < 0) { + throw new IllegalArgumentException("initialCapacity must be non-negative: " + initialCapacity); + } + int capacity = roundUpToPowerOfTwo(Math.max(initialCapacity, MIN_CAPACITY)); + this.keys = new long[capacity]; + this.values = new long[capacity]; + this.state = new byte[capacity]; + this.size = 0; + this.tombstones = 0; + this.mask = capacity - 1; + this.threshold = (int)(capacity * LOAD_FACTOR); + } + + @Override + public void put(long key, long value) { + int slot = slotFor(key); + int firstTombstone = - 1; + while (true) { + byte s = state[slot]; + if (s == EMPTY) { + boolean reusedTombstone = firstTombstone >= 0; + int target = reusedTombstone + ? firstTombstone + : slot; + keys[target] = key; + values[target] = value; + state[target] = OCCUPIED; + size++; + if (reusedTombstone) { + tombstones--; + } + if (size > threshold) { + resize(state.length<< 1); + } else if (size + tombstones > threshold) { + // Tombstones alone are crowding the table — rehash at the + // current capacity to clear them and keep probe chains short. + // Without this, repeated put/remove cycles can saturate the + // table with TOMBSTONE+OCCUPIED slots and cause the put + // probe loop (which only stops at EMPTY) to spin forever. + resize(state.length); + } + return; + } + if (s == OCCUPIED && keys[slot] == key) { + values[slot] = value; + return; + } + if (s == TOMBSTONE && firstTombstone < 0) { + firstTombstone = slot; + } + slot = (slot + 1) & mask; + } + } + + @Override + public long get(long key) { + int slot = slotFor(key); + while (true) { + byte s = state[slot]; + if (s == EMPTY) { + return MISSING; + } + if (s == OCCUPIED && keys[slot] == key) { + return values[slot]; + } + slot = (slot + 1) & mask; + } + } + + @Override + public boolean containsKey(long key) { + int slot = slotFor(key); + while (true) { + byte s = state[slot]; + if (s == EMPTY) { + return false; + } + if (s == OCCUPIED && keys[slot] == key) { + return true; + } + slot = (slot + 1) & mask; + } + } + + @Override + public void remove(long key) { + int slot = slotFor(key); + while (true) { + byte s = state[slot]; + if (s == EMPTY) { + return; + } + if (s == OCCUPIED && keys[slot] == key) { + state[slot] = TOMBSTONE; + size--; + tombstones++; + return; + } + slot = (slot + 1) & mask; + } + } + + @Override + public int size() { + return size; + } + + @Override + public void clear() { + java.util.Arrays.fill(state, EMPTY); + size = 0; + tombstones = 0; + } + + private int slotFor(long key) { + return Long.hashCode(key) & mask; + } + + private void resize(int newCapacity) { + long[] oldKeys = this.keys; + long[] oldValues = this.values; + byte[] oldState = this.state; + this.keys = new long[newCapacity]; + this.values = new long[newCapacity]; + this.state = new byte[newCapacity]; + this.mask = newCapacity - 1; + this.threshold = (int)(newCapacity * LOAD_FACTOR); + this.size = 0; + this.tombstones = 0; + for (int i = 0; i < oldState.length; i++) { + if (oldState[i] == OCCUPIED) { + put(oldKeys[i], oldValues[i]); + } + } + } + + private static int roundUpToPowerOfTwo(int value) { + // Smallest power of two >= value, with a sane lower bound. + int n = MIN_CAPACITY; + while (n < value) { + n <<= 1; + } + return n; + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/LongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/LongLongMap.java new file mode 100644 index 0000000..b50fc7d --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/LongLongMap.java @@ -0,0 +1,52 @@ +package org.pragmatica.peg.incremental.internal; +/** + * Primitive-keyed, primitive-valued map ({@code long → long}) used as the + * backing store for {@link NodeIndex} since v0.5.0 + * (per {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A). + * + *

Promoted to production from {@code experimental.LongLongMap} in Phase 1.5. + * The experimental copy stays in place as the prove-out reference; this + * production copy is the one wired into {@link NodeIndex}. + * + *

Sentinel. {@link #MISSING} is returned by {@link #get} + * for absent keys. Callers must not store {@code Long.MIN_VALUE} as a value; + * the {@code NodeIndex} use case (mapping child-id → parent-id, where IDs + * come from {@code IdGenerator.PerSessionCounter} and start at 0) honours + * this naturally. + * + *

Thread-safety. Implementations are not thread-safe. + * + * @since 0.5.0 + */ +public sealed interface LongLongMap permits LinearProbingLongLongMap { + /** + * Returned by {@link #get(long)} for keys that are not present in the + * map. Callers must never use {@code Long.MIN_VALUE} as a stored value. + */ + long MISSING = Long.MIN_VALUE; + + /** + * Insert or overwrite the value for {@code key}. {@link #size()} grows + * when {@code key} is new and stays the same on overwrite. + */ + void put(long key, long value); + + /** + * Value associated with {@code key}, or {@link #MISSING} when absent. + */ + long get(long key); + + /** {@code true} iff {@code key} is present. */ + boolean containsKey(long key); + + /** + * Remove the entry for {@code key} if present. No-op when absent. + */ + void remove(long key); + + /** Number of live entries. */ + int size(); + + /** Discard every entry. Capacity is preserved by the implementation. */ + void clear(); +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java index 34390db..d61d218 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java @@ -4,8 +4,9 @@ import org.pragmatica.peg.tree.CstNode; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; -import java.util.IdentityHashMap; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -16,38 +17,50 @@ *

    *
  • {@link #parentOf(CstNode)} — O(1) parent lookup.
  • *
  • {@link #smallestContaining(int)} — locate the smallest node whose - * span contains a given offset (used when a {@link Session} has no - * warm enclosing-node pointer yet).
  • + * span contains a given offset (used when a {@link + * org.pragmatica.peg.incremental.Session Session} has no warm + * enclosing-node pointer yet). *
  • {@link #smallestContainingFrom(CstNode, int)} — climb outward from * a hot pointer until a node containing {@code offset} is found, then * descend to the smallest containing child. O(depth) for adjacent * moves.
  • *
* - *

The index is keyed by {@link System#identityHashCode identity}. peglib - * CST records are value objects and two structurally equal subtrees may be - * equal by {@code .equals}; identity-based mapping is required to keep - * parent links unambiguous. + *

Phase 1.5 (v0.5.0): the index is keyed by the stable {@code long id} + * carried on each {@link CstNode} record (Phase 1.2 invariant) using a + * primitive-keyed {@link LongLongMap}. The id→node lookup is held in a boxed + * {@code HashMap}; promoting that to a primitive + * {@code long → object} map is a future micro-optimisation. + * + *

Phase 1.6 (v0.5.0): Path D's optimised {@link #applyIncremental} replaces + * the previous {@link #build}-on-every-edit hot path. Cost drops from + * {@code O(N)} to {@code O(oldPivotSize + newPivotSize)} — independent of N + * and of tree shape — by trusting the stable-ancestor-id invariant + * established by {@link TreeSplicer#spliceAndShift}. * * @since 0.3.1 */ public final class NodeIndex { private final CstNode root; - private final Map parents; + private final LongLongMap parents; + private final Map nodesById; - private NodeIndex(CstNode root, Map parents) { + private NodeIndex(CstNode root, LongLongMap parents, Map nodesById) { this.root = root; this.parents = parents; + this.nodesById = nodesById; } /** * Build a fresh index over {@code root}. O(n) in the node count. */ public static NodeIndex build(CstNode root) { - int expectedSize = countDescendants(root); - var parents = new IdentityHashMap(expectedSize); - indexChildren(root, parents); - return new NodeIndex(root, parents); + int expectedSize = countDescendants(root) + 1; + var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); + var nodesById = new HashMap(expectedSize * 2); + nodesById.put(root.id(), root); + indexChildren(root, parents, nodesById); + return new NodeIndex(root, parents, nodesById); } private static int countDescendants(CstNode node) { @@ -60,15 +73,90 @@ private static int countDescendants(CstNode node) { return count; } - private static void indexChildren(CstNode node, Map parents) { + private static void indexChildren(CstNode node, LongLongMap parents, Map nodesById) { + if (node instanceof CstNode.NonTerminal nt) { + for (var child : nt.children()) { + parents.put(child.id(), nt.id()); + nodesById.put(child.id(), child); + indexChildren(child, parents, nodesById); + } + } + } + + private static void flattenDescendantsInto(CstNode node, List out) { if (node instanceof CstNode.NonTerminal nt) { for (var child : nt.children()) { - parents.put(child, nt); - indexChildren(child, parents); + out.add(child); + flattenDescendantsInto(child, out); } } } + /** + * Phase 1.6 (v0.5.0) — Path D optimised splice update. + * + *

Mutates the receiver's {@code parents} map in place and returns a NEW + * {@code NodeIndex} sharing that same map. The receiver becomes + * invalid after the call. Callers MUST use the returned instance and + * discard {@code this}. + * + *

Cost: {@code O(oldPivotSize + newPivotSize)}. Only the wholesale-replaced + * subtree (oldPivot) and the newly inserted subtree (newPivot) need touch + * the map. Spine ancestors keep their stable ids ({@link + * TreeSplicer#spliceAndShift} reuses {@code oldAncestor.id()} on rebuild), + * so their parent-map entries remain valid; right-of-edit subtrees that + * {@link TreeSplicer#shiftAll} rebuilt also keep every node's id, so their + * internal parent-map entries are unaffected. + * + * @param newRoot root of the post-edit tree (must be {@code newPath.get(0)}) + * @param oldPath {@code root → oldPivot} chain in the pre-edit tree + * (size ≥ 1) + * @param newPath {@code newRoot → newPivot} chain in the post-edit tree + * (size ≥ 1; ancestors carry stable ids matching + * {@code oldPath}) + * @return a new index reflecting the post-edit tree; the receiver is + * invalidated + */ + public NodeIndex applyIncremental(CstNode newRoot, List oldPath, List newPath) { + if (oldPath == null || oldPath.isEmpty()) { + throw new IllegalArgumentException("oldPath must contain at least the old pivot"); + } + if (newPath == null || newPath.isEmpty()) { + throw new IllegalArgumentException("newPath must contain at least the new pivot"); + } + var oldPivot = oldPath.get(oldPath.size() - 1); + var newPivot = newPath.get(newPath.size() - 1); + // Step 1 — remove the old pivot's descendants (wholesale-replaced subtree). + // Their ids are dead: newPivot has fresh ids from the parser's id-gen. + var oldPivotDescendants = new ArrayList(); + flattenDescendantsInto(oldPivot, oldPivotDescendants); + for (var d : oldPivotDescendants) { + parents.remove(d.id()); + nodesById.remove(d.id()); + } + // Step 2 — remove the old pivot's own up-pointer; newPivot has a fresh id. + parents.remove(oldPivot.id()); + nodesById.remove(oldPivot.id()); + // Step 3 — insert the new pivot's subtree internal links. + nodesById.put(newPivot.id(), newPivot); + indexChildren(newPivot, parents, nodesById); + // Step 4 — wire newPivot to its parent on the new spine. When the pivot + // is itself the root, there is no parent entry to write. + if (newPath.size() >= 2) { + var newPivotParent = newPath.get(newPath.size() - 2); + parents.put(newPivot.id(), newPivotParent.id()); + } + // Step 5 — refresh the spine ancestors' record references in nodesById + // so subsequent lookups resolve to the post-splice records (their ids + // are stable, but the records themselves are rebuilt). We DO NOT touch + // the parents map for these — every ancestor's id is unchanged, so the + // up-pointer entries already reflect the correct logical parent. + for (var ancestor : newPath) { + nodesById.put(ancestor.id(), ancestor); + } + return new NodeIndex(newRoot, parents, nodesById); + } + /** Root of the CST this index was built over. */ public CstNode root() { return root; @@ -79,7 +167,14 @@ public CstNode root() { * is the root (or not present in this index). */ public Option parentOf(CstNode node) { - return Option.option(parents.get(node)); + if (node == null) { + return Option.none(); + } + long parentId = parents.get(node.id()); + if (parentId == LongLongMap.MISSING) { + return Option.none(); + } + return Option.option(nodesById.get(parentId)); } /** @@ -103,12 +198,23 @@ public Option smallestContaining(int offset) { * back to {@link #smallestContaining(int)} from the root. */ public Option smallestContainingFrom(CstNode start, int offset) { - if (start == null || !parents.containsKey(start) && start != root) { + if (start == null) { + return smallestContaining(offset); + } + // start may legitimately BE the root (no parent entry in the map). Check + // both conditions: present as a key in the parents map OR equal to root. + boolean knownToIndex = start == root || parents.containsKey(start.id()); + if (!knownToIndex) { return smallestContaining(offset); } var cursor = start; while (cursor != null && !contains(cursor, offset)) { - cursor = parents.get(cursor); + long parentId = parents.get(cursor.id()); + if (parentId == LongLongMap.MISSING) { + cursor = null; + }else { + cursor = nodesById.get(parentId); + } } if (cursor == null) { return Option.none(); @@ -130,7 +236,12 @@ public Deque pathTo(CstNode node) { if (cursor == root) { return stack; } - cursor = parents.get(cursor); + long parentId = parents.get(cursor.id()); + if (parentId == LongLongMap.MISSING) { + cursor = null; + }else { + cursor = nodesById.get(parentId); + } } stack.clear(); return stack; diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java index 2911784..11b22ee 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java @@ -7,7 +7,9 @@ import org.pragmatica.peg.incremental.Session; import org.pragmatica.peg.parser.Parser; import org.pragmatica.peg.parser.ParserConfig; +import org.pragmatica.peg.parser.PegEngine; import org.pragmatica.peg.tree.CstNode; +import org.pragmatica.peg.tree.IdGenerator; import java.util.Set; @@ -98,8 +100,12 @@ public Session initialize(String buffer, int cursorOffset) { } int clampedCursor = Math.max(0, Math.min(cursorOffset, buffer.length())); - CstNode root = parseFull(buffer); - return IncrementalSession.initial(this, buffer, clampedCursor, root); + // Phase 1.5 (v0.5.0): allocate ONE IdGenerator per Session lineage; thread + // it through every reparse so node IDs stay stable across edits. This is + // the precondition for Path D's optimized NodeIndex.applyIncremental. + var idGen = new IdGenerator.PerSessionCounter(); + CstNode root = parseFull(buffer, idGen); + return IncrementalSession.initial(this, buffer, clampedCursor, root, idGen); } Grammar grammar() { @@ -141,4 +147,26 @@ CstNode parseFull(String buffer) { }, node -> node); } + + /** + * Phase 1.5 (v0.5.0): Session-aware full-parse that uses the supplied + * {@link IdGenerator}. Routes through {@link PegEngine}'s id-aware overload + * so node IDs come from the Session's counter rather than a fresh per-call + * one. Surfaces errors identically to {@link #parseFull(String)}. + */ + CstNode parseFull(String buffer, IdGenerator idGen) { + var startRule = grammar.effectiveStartRule(); + if (startRule.isEmpty()) { + throw new IllegalStateException("full parse failed: No start rule defined in grammar"); + } + var engine = (PegEngine) parser; + return engine.parseCst(buffer, + startRule.unwrap() + .name(), + idGen) + .fold(cause -> { + throw new IllegalStateException("full parse failed: " + cause.message()); + }, + node -> node); + } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java index 678771f..c4e9c40 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/TreeSplicer.java @@ -32,11 +32,46 @@ * CstHash (which is what parity checks) hashes offsets only, so the shifted * locations do not affect correctness. * + *

Phase 1.4 (v0.5.0): when rebuilding ancestors on the spine, this splicer + * reuses each old ancestor's {@code id} instead of allocating + * a fresh one. Path D's {@link NodeIndex#applyIncremental} relies on this — + * stable ancestor IDs let the index skip the {@code O(N)} sibling-rewire that + * dominates flat-tree edits. + * + *

Phase 1.6 (v0.5.0): {@link #spliceAndShift} returns a {@link SpliceResult} + * that exposes (a) the new root and (b) the {@code root → newPivot} path with + * stable ancestor ids. The Path D {@code applyIncremental} algorithm consumes + * both. + * + *

Right-of-edit handling. {@link #shiftAll} preserves the + * source node's {@code id} on every rebuilt record (Phase 1.2/1.4 invariant). + * Consequence: the {@link NodeIndex} parents-map entries inside any + * right-of-edit subtree remain valid across the splice — every + * {@code (childId → parentId)} pair stays correct because both ids are + * preserved. {@link #spliceAndShift} therefore does NOT need to expose the + * shifted subtrees as separate bookkeeping; Path D's + * {@code applyIncremental} only touches the old-pivot subtree (dead) and the + * new-pivot subtree (fresh ids). This is the central correctness argument + * behind the {@code O(oldPivotSize + newPivotSize)} cost claim. + * * @since 0.3.1 */ public final class TreeSplicer { private TreeSplicer() {} + /** + * Phase 1.6 (v0.5.0): the bookkeeping returned by {@link #spliceAndShift}. + * + *

    + *
  • {@link #newRoot} — root of the post-splice tree.
  • + *
  • {@link #newPath} — {@code newRoot → newPivot}, inclusive. Indices + * {@code [0, size-2]} carry stable ids matching the corresponding + * {@code oldPath} entries (Phase 1.4 invariant); the last element is + * the supplied {@code newSubtree}.
  • + *
+ */ + public record SpliceResult(CstNode newRoot, List newPath) {} + /** * Build a new root by replacing {@code oldSubtree} with {@code newSubtree} * along the {@code path} spine. {@code path} must start at the root and @@ -47,15 +82,18 @@ private TreeSplicer() {} * shifted by {@code delta}. Nodes unrelated to the spine but outside the * shifted region are kept by reference — structural sharing (SPEC §4.2). */ - public static CstNode spliceAndShift(Deque path, - CstNode oldSubtree, - CstNode newSubtree, - int editEnd, - int delta) { + public static SpliceResult spliceAndShift(Deque path, + CstNode oldSubtree, + CstNode newSubtree, + int editEnd, + int delta) { if (path.isEmpty() || path.peekLast() != oldSubtree) { throw new IllegalArgumentException("path must terminate at oldSubtree"); } var pathList = new ArrayList<>(path); + // newPath is built leaf-first then reversed. + var reversedNewPath = new ArrayList(pathList.size()); + reversedNewPath.add(newSubtree); // Start with the new subtree, walk up the spine replacing each ancestor. var replacement = newSubtree; for (int i = pathList.size() - 2; i >= 0; i-- ) { @@ -78,8 +116,13 @@ public static CstNode spliceAndShift(Deque path, } } replacement = rebuildNonTerminal(nt, newChildren, delta, editEnd); + reversedNewPath.add(replacement); } - return replacement; + var newPath = new ArrayList(reversedNewPath.size()); + for (int i = reversedNewPath.size() - 1; i >= 0; i-- ) { + newPath.add(reversedNewPath.get(i)); + } + return new SpliceResult(replacement, List.copyOf(newPath)); } /** @@ -112,6 +155,10 @@ private static CstNode.NonTerminal rebuildNonTerminal(CstNode.NonTerminal ancest * {@code delta} (start and end both shift by the same amount — this is a * wholesale move, not a resize). Used when a subtree sits entirely to * the right of the edit region. + * + *

Phase 1.4 (v0.5.0): every rebuilt record reuses the source node's + * {@code id}. Path D's stable-id invariant requires it — even when the + * record itself is fresh, the logical node identity carries through. */ public static CstNode shiftAll(CstNode node, int delta) { if (delta == 0) { diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java index 04c0840..0eee155 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java @@ -2,9 +2,11 @@ import java.util.HashMap; import java.util.Random; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import static org.assertj.core.api.Assertions.assertThat; @@ -220,6 +222,28 @@ void hash_collision_stress() { assertThat(map.get(32L)).isEqualTo(12345L); } + @Test + @DisplayName("Tombstone accumulation does NOT hang put (regression: Phase 1.5)") + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void doesNotHangOnTombstoneAccumulation() { + // Pattern: insert N keys, remove half, insert another N. Without the + // tombstone-aware resize, this hangs (the second insert phase finds + // no EMPTY slot once tombstones + occupied saturate the table). + var map = new LinearProbingLongLongMap(16); + // small table to stress + int n = 200; + for (int i = 0; i < n; i++) { + map.put(i, i); + } + for (int i = 0; i < n; i += 2) { + map.remove(i); + } + for (int i = n; i < 2 * n; i++) { + map.put(i, i); + } + assertThat(map.size()).isEqualTo(n + n / 2); + } + @Test @DisplayName("Negative keys behave like any other long") void negative_keys_supported() { diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/LongLongMapTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/LongLongMapTest.java new file mode 100644 index 0000000..0303f3e --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/LongLongMapTest.java @@ -0,0 +1,89 @@ +package org.pragmatica.peg.incremental.internal; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Production-side tests for {@link LinearProbingLongLongMap}. The experimental + * sibling has the broader battery; this file exists primarily to lock in the + * tombstone-accumulation regression that hung {@code IncrementalTriviaParityTest} + * after the Phase 1.5 promotion of the map into {@link NodeIndex}. + */ +final class LongLongMapTest { + @Test + @DisplayName("put then get returns the stored value") + void put_then_get() { + LongLongMap map = new LinearProbingLongLongMap(); + map.put(5L, 100L); + assertThat(map.get(5L)).isEqualTo(100L); + assertThat(map.containsKey(5L)).isTrue(); + assertThat(map.size()).isEqualTo(1); + } + + @Test + @DisplayName("remove then re-put same key returns new value") + void remove_then_put_same_key() { + LongLongMap map = new LinearProbingLongLongMap(); + map.put(7L, 70L); + map.remove(7L); + map.put(7L, 700L); + assertThat(map.get(7L)).isEqualTo(700L); + assertThat(map.size()).isEqualTo(1); + } + + @Test + @DisplayName("Tombstone accumulation does NOT hang put (regression: Phase 1.5)") + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void doesNotHangOnTombstoneAccumulation() { + // Pattern: insert N keys, remove half, insert another N. Without the + // tombstone-aware resize, this hangs (the second insert phase finds + // no EMPTY slot once tombstones + occupied saturate the table and the + // put-probe loop can only stop at EMPTY). + var map = new LinearProbingLongLongMap(16); + // small table to stress + int n = 200; + for (int i = 0; i < n; i++) { + map.put(i, i); + } + for (int i = 0; i < n; i += 2) { + map.remove(i); + } + for (int i = n; i < 2 * n; i++) { + map.put(i, i); + } + assertThat(map.size()).isEqualTo(n + n / 2); + // n surviving + n/2 from second batch + // And every survivor remains retrievable. + for (int i = 1; i < n; i += 2) { + assertThat(map.get(i)).isEqualTo(i); + } + for (int i = n; i < 2 * n; i++) { + assertThat(map.get(i)).isEqualTo(i); + } + } + + @Test + @DisplayName("Long alternating put/remove stress does not hang and stays consistent") + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void alternating_put_remove_stress() { + var map = new LinearProbingLongLongMap(16); + // Repeatedly fill + drain to age the tombstone count well past threshold. + for (int cycle = 0; cycle < 50; cycle++) { + for (int i = 0; i < 100; i++) { + map.put(i, (long) cycle * 1000 + i); + } + for (int i = 0; i < 100; i++) { + map.remove(i); + } + } + assertThat(map.size()).isZero(); + // Map remains usable after the storm. + map.put(42L, 42L); + assertThat(map.get(42L)).isEqualTo(42L); + } +} From 65a719f9013d53a894ab0875e2b19171a1d46191 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 13:45:36 +0200 Subject: [PATCH 13/46] =?UTF-8?q?fix:=20phase=201.7=20=E2=80=94=20refresh?= =?UTF-8?q?=20nodesById=20after=20shiftAll=20+=20bench=20exception=20diagn?= =?UTF-8?q?ostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bench/IncrementalSessionBench.java | 67 ++++++++++++++++++- .../peg/incremental/internal/NodeIndex.java | 58 ++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java index 7cf6d3d..32263ff 100644 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java @@ -9,7 +9,10 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Random; /** @@ -110,16 +113,54 @@ public static void main(String[] args) throws Exception { // not interpreter cost. We do a small throwaway session. warmJit(parser, fixtureSource); - var regimeB = runRegime(parser, fixtureSource, plan, /*moveCursor=*/true); - var regimeA = runRegime(parser, fixtureSource, plan, /*moveCursor=*/false); + var regimeBExc = new ArrayList(); + var regimeAExc = new ArrayList(); + var regimeB = runRegime(parser, fixtureSource, plan, /*moveCursor=*/true, regimeBExc); + var regimeA = runRegime(parser, fixtureSource, plan, /*moveCursor=*/false, regimeAExc); printReport(fixtureSource, regimeB, regimeA); + System.out.println(); + System.out.println("=== EXCEPTION DIAGNOSTICS ==="); + printExceptionSummary("Regime B (cursor-moved)", regimeBExc); + System.out.println(); + printExceptionSummary("Regime A (cursor-pinned)", regimeAExc); + } + + private record ExcReport(int editIndex, int offset, int oldLen, int newLen, String klass, String message, String topFrame, String fullStack) {} + + private static void printExceptionSummary(String label, List exceptions) { + System.out.printf("%s: %d exceptions caught%n", label, exceptions.size()); + if (exceptions.isEmpty()) { + return; + } + // Group by class+message+topFrame + var grouped = new LinkedHashMap>(); + for (var e : exceptions) { + String key = e.klass() + " | " + e.message() + " | " + e.topFrame(); + grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(e); + } + // Sort by frequency + var entries = new ArrayList<>(grouped.entrySet()); + entries.sort((a, b) -> Integer.compare(b.getValue().size(), a.getValue().size())); + System.out.println("Distinct exception groups:"); + for (var e : entries) { + System.out.printf(" [%d edits] %s%n", e.getValue().size(), e.getKey()); + } + // Print full stack of dominant + if (!entries.isEmpty()) { + var dom = entries.get(0).getValue().get(0); + System.out.println(); + System.out.println("Dominant exception full stack (first occurrence):"); + System.out.printf(" edit#%d offset=%d oldLen=%d newLen=%d%n", dom.editIndex(), dom.offset(), dom.oldLen(), dom.newLen()); + System.out.println(dom.fullStack()); + } } // -------- Regime driver ---------------------------------------------------------------- private static Measurement[] runRegime( - IncrementalParser parser, String fixtureSource, List plan, boolean moveCursor) { + IncrementalParser parser, String fixtureSource, List plan, boolean moveCursor, + List excOut) { var session = parser.initialize(fixtureSource, 0); int prevFallbacks = session.stats().fullReparseCount(); var out = new Measurement[plan.size()]; @@ -155,6 +196,26 @@ private static Measurement[] runRegime( } catch (RuntimeException ex) { out[i] = new Measurement(ce.cls(), -1L, false, true, edit.offset(), edit.oldLen(), edit.newText().length(), "", 0); + if (excOut != null) { + var sb = new StringBuilder(); + var trace = ex.getStackTrace(); + int n = Math.min(10, trace.length); + for (int k = 0; k < n; k++) { + sb.append(" at ").append(trace[k]).append('\n'); + } + if (ex.getCause() != null) { + sb.append(" Caused by: ").append(ex.getCause().getClass().getName()) + .append(": ").append(ex.getCause().getMessage()).append('\n'); + var ctrace = ex.getCause().getStackTrace(); + int cn = Math.min(5, ctrace.length); + for (int k = 0; k < cn; k++) { + sb.append(" at ").append(ctrace[k]).append('\n'); + } + } + String topFrame = trace.length > 0 ? trace[0].toString() : ""; + excOut.add(new ExcReport(i, edit.offset(), edit.oldLen(), edit.newText().length(), + ex.getClass().getName(), String.valueOf(ex.getMessage()), topFrame, sb.toString())); + } } } return out; diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java index d61d218..911cd2b 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java @@ -154,9 +154,67 @@ public NodeIndex applyIncremental(CstNode newRoot, List oldPath, List nodesById) { + if (! (spineAncestor instanceof CstNode.NonTerminal nt)) { + return; + } + for (var child : nt.children()) { + if (child == spineChild) { + continue; + } + // Whether {@link TreeSplicer} kept this child by reference (left + // of edit) or deep-copied it via {@link TreeSplicer#shiftAll} (at + // or after edit), refreshing the nodesById entry to point at the + // current record is always correct: same id, equal-or-shifted + // span. Walk the subtree to refresh descendants likewise. + refreshAllNodesById(child, nodesById); + } + } + + private static void refreshAllNodesById(CstNode node, Map nodesById) { + nodesById.put(node.id(), node); + if (node instanceof CstNode.NonTerminal nt) { + for (var child : nt.children()) { + refreshAllNodesById(child, nodesById); + } + } + } + /** Root of the CST this index was built over. */ public CstNode root() { return root; From 43baaf8162f33459101fc7c8195f088a59ea50f5 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 13:48:49 +0200 Subject: [PATCH 14/46] =?UTF-8?q?docs:=20phase=201=20results=20=E2=80=94?= =?UTF-8?q?=20production=20migration=20complete,=20point=20HANDOVER=20at?= =?UTF-8?q?=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 30 +++++++++- docs/HANDOVER.md | 66 ++++++++++++++------- docs/incremental/PHASE-1-RESULTS.md | 90 +++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 23 deletions(-) create mode 100644 docs/incremental/PHASE-1-RESULTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ec317..e3eb007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,35 @@ Sandbox prototype of Lever A (stable IDs + LongLongMap NodeIndex) lands additive - **Trivia-bearing edits** — calculator grammar with `%whitespace` + comments, three representative edits, incremental update equivalent to full rebuild (spec §8 Q4 gate). - **Perf** — JMH bench: 38× speedup at 100 nodes, 47× at 1000, 67× at 10000. Well above the 5× gate threshold; consistent with spec §2's projected 300× per-edit reduction. See [`docs/bench-results/phase0-spike-results.md`](docs/bench-results/phase0-spike-results.md) and [`docs/incremental/PHASE-0-RESULTS.md`](docs/incremental/PHASE-0-RESULTS.md). -Phases 1–5 (production migration of all 5 modules) is the next-session entry point. +### Phase 1 prove-out (2026-05-07) + +Path A (offset decoupling from records) — RED. 1.10-1.29× speedup on flat-tree mid-buffer edits; not worth the cross-cutting refactor. See [`docs/bench-results/phase1-spanindex-results.md`](docs/bench-results/phase1-spanindex-results.md). + +Path D (stable-id ancestor preservation) — GREEN. **96-604× speedup** on flat-tree mid-buffer edits with absolute time flat across N (~25-40 ns), confirming genuine O(δ) scaling. The fix: TreeSplicer reuses old ancestor IDs across splices; applyIncremental skips ancestor-rewiring as a result. See [`docs/bench-results/path-d-results.md`](docs/bench-results/path-d-results.md) and [`docs/incremental/PHASE-1-PROVE-OUT.md`](docs/incremental/PHASE-1-PROVE-OUT.md). + +### Phase 1 production migration (2026-05-07) + +**BREAKING.** `CstNode` records gain `long id` as the first record component on all four variants (`Terminal`, `NonTerminal`, `Token`, `Error`). `equals`/`hashCode` overridden to exclude `id` per spec §7 R1 — structural equality preserved (`RoundTripTest` baselines unaffected). New `org.pragmatica.peg.tree.IdGenerator` interface + `PerSessionCounter` impl; threaded through `ParsingContext`, `PegEngine`, `ParserGenerator` emission templates. Generated parsers now contain inline `IdGenerator` (preserves the v0.4.2 standalone-parser invariant — no FQCN dep on peglib runtime). + +`NodeIndex` internals switch from `IdentityHashMap` to `LongLongMap` (hand-rolled linear-probing, promoted from sandbox to `internal/`). Includes a tombstone-saturation fix: resize triggers on `size + tombstones > threshold`; same-capacity rehash drains tombstones without growing the table. + +`IncrementalSession.applyIncremental` adopts Path D's optimized algorithm (steps 1 and 3 from spec §2 deleted because ancestor IDs are stable). Step 6 added: refresh `nodesById` for right-of-edit subtrees that `TreeSplicer.shiftAll` deep-copies — those subtrees retain stable IDs but their records carry post-edit shifted spans, so the lookup map needs a refresh to avoid stale-span pivot errors. + +Bench numbers vs 0.4.3 baseline on the 1900-LOC Java fixture (Regime B, cursor-moved-to-edit, same RNG seed `0xBEEFCAFE`): + +| Metric | 0.4.3 | 0.5.0 (post-1.7) | Change | +|---|---:|---:|---:| +| Median | 10.8 ms | 5.6 ms | **-48% (1.9× faster)** | +| p95 | 22.4 ms | 14.6 ms | **-35%** | +| p99 | 53.3 ms | 138.8 ms | +160% (large-pivot tail) | +| Max | 98.6 ms | 390.8 ms | +297% (large-pivot tail) | +| % under 16 ms | 91.5% | 95.9% | **+4.4 pp** | + +Median + p95 + frame-budget hit rate clearly improved. p99/max regressed for large-pivot edits where `TreeSplicer.shiftAll` deep-copies thousands of right-of-edit records — an accepted trade vs Path A's much-larger-scope refactor. Pivot-selection improvements (spec Lever B) deferred to Phase 2. + +See [`docs/incremental/PHASE-1-RESULTS.md`](docs/incremental/PHASE-1-RESULTS.md) for full sub-phase summary and bench caveats. + +Phase 2 (Lever B top-down pivot search) is the next-session entry point. ## [0.4.3] - 2026-05-06 diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index e15de02..8ddb382 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -270,40 +270,62 @@ Regen via: ## 11. Recommended next session -**Phase 0 spike landed GO on 2026-05-07.** All three GO/NO-GO gates green; Phase 1 (production migration of Lever A) is the next-session entry. Read `docs/incremental/PHASE-0-RESULTS.md` for the full verdict + bench numbers (38–67× speedup confirmed) before starting. +**Phase 1 production migration of Path D landed on 2026-05-07.** Median 1.9× faster than 0.4.3, p95 35% better, frame-budget hit rate +4.4pp. p99/max regressed on large-pivot edits — accepted trade vs Path A's much-larger-scope refactor. Read `docs/incremental/PHASE-1-RESULTS.md` for the full verdict + bench numbers + caveats before starting Phase 2. -State of `release-0.5.0` branch (8 commits past `1619604` chore — local only, not pushed): +State of `release-0.5.0` branch (15 commits past `1619604` chore — local only, not pushed): -- `d00eaa1` Phase 0a — `IdGenerator` + `LongLongMap` foundation -- `f0696a1` Phase 0b — sandbox `IdCstNode` + `IdCstNodeBuilder` -- `849b4ba` Phase 0c — sandbox `IdNodeIndex` with O(splicedSize+depth) incremental update -- `9b55253` Phase 0d.1 — `IdTreeSplicer` + identity-invariant + trivia-edit gates -- `a2dd8ac` Phase 0d.2 — JMH `Phase0SpikeBench` + results note (38-67× balanced-tree) -- `a8c6efe` Phase 0e — GO verdict doc + CHANGELOG entry +**Phase 0 — spike (sandbox):** +- `d00eaa1` 0a — `IdGenerator` + `LongLongMap` foundation +- `f0696a1` 0b — sandbox `IdCstNode` + `IdCstNodeBuilder` +- `849b4ba` 0c — sandbox `IdNodeIndex` with O(splicedSize+depth) incremental update +- `9b55253` 0d.1 — `IdTreeSplicer` + identity-invariant + trivia-edit gates +- `a2dd8ac` 0d.2 — JMH `Phase0SpikeBench` + results note (38-67× balanced) +- `a8c6efe` 0e — GO verdict doc + CHANGELOG entry + +**Phase 1 — prove-out + production migration:** - `8f844eb` Path A prove-out — SpanIndex / offset decoupling (RED, 1.10-1.29×) - `8b27dd6` Path D prove-out — stable-id ancestor preservation (GREEN, 96-604×) +- `4043ddc` docs — phase 1 prove-out summary +- `2443779` 1.2 — production CstNode gains long id (BREAKING) +- `39e11f9` 1.5/1.6 — NodeIndex LongLongMap + Path D applyIncremental + tombstone fix +- `65a719f` 1.7 — refresh nodesById after shiftAll + bench exception diagnostics + +Tests: 993 green (699 core + 196 incremental + 66 formatter + 5 maven-plugin + 27 playground). IncrementalParityTest 22×100 green throughout. 0 skipped, 0 failures, 0 errors. + +### Phase 2 — Lever B top-down pivot search (next session) + +Per spec §3 — should dissolve the §6.2 lever-1 puzzle into a 30-line method now that Phase 1's stable IDs make descent-from-root cheap (O(depth × branching) via id-keyed `nodesById` lookups instead of O(N) tree walks). + +Two concrete motivators from Phase 1.7's bench data (per `PHASE-1-RESULTS.md` §"Bench caveats"): -All work additive in `peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/` and the parallel test/jmh directories. 897-test production surface untouched throughout. Local incremental count: 190 (was 100, +90 sandbox tests). 699 core unchanged. +1. **Cursor-aware regime asymmetry, 57 edits.** Regime A (cursor-pinned) reports 622 successful edits; Regime B (cursor-moved-to-edit) reports 565. The 57-edit gap likely reflects cases where the warm-pointer pivot search picks a slightly different (less safe) pivot in Regime B. Lever B's top-down descent + safe-pivot heuristic should dissolve this. -### Phase 1 — production migration (path D, next session) +2. **Large-pivot p99 tail.** The p99/max regression (138 ms / 391 ms vs 0.4.3's 53 ms / 99 ms) localizes to edits where the boundary algorithm chose a 2k-7k-node interior pivot. `TreeSplicer.shiftAll` then deep-copies all right-of-edit descendants. Lever B's smarter pivot selection should pick smaller pivots more often, reducing the tail. -The Phase 1 prove-out (`docs/incremental/PHASE-1-PROVE-OUT.md`) discovered that the spec §2 algorithm degrades to O(N) on flat trees (e.g., a method body with 1000 statements). Path A (offset decoupling) was prove-out RED — 1.10-1.29× speedup, far short of the 5× gate. **Path D (stable-id ancestor preservation) is GREEN** — 96× at 1000 nodes, 604× at 10000 nodes, with absolute time *flat* across N (~25-40 ns) confirming genuine O(δ) scaling. +Phase 2 deliverables (per spec §6 Phase 2, ~3 days): +- `IncrementalSession.findBoundaryCandidate` replaced with top-down descent from root using `NodeIndex.nodesById` (O(depth × branching)) +- Safe-pivot detection added to grammar analysis (rules whose `parseRuleAt` provably matches full reparse — rules starting with unambiguous terminals like Block `{`, Stmt mixed delim) +- `BackReferenceFallbackTest.edit_triggers_full_reparse` — currently passes via warm-pointer cursor-spine accident; verify it still passes via the descent + safe-pivot exclusion +- IncrementalParityTest stays green at 22×100 +- Re-run IncrementalSessionBench; gate on Regime B success count reaching parity with Regime A AND p99 falling below 100 ms -The fix is small: when `TreeSplicer.spliceAndShift` builds new ancestor records, reuse `oldAncestor.id()` instead of allocating a fresh one. Then `applyIncremental` skips the redundant ancestor rewiring (steps 1 and 3 of spec §2 disappear — only oldPivot's descendants and newPivot's subtree need touching). +### Bench harness improvement (high priority alongside Phase 2) -**Critical:** splicer + index migrate as a unit. The bench's middle column (`stableIdNoOpt` = stable splicer + original `applyIncremental`) matches productionStyle within noise. The optimized `applyIncremental` that *trusts* ID stability is what realizes the gain. +`IncrementalSessionBench` produces edit sequences that occasionally corrupt buffer syntax over time. 398 of 435 Regime B exceptions in Phase 1.7 are the same syntax error from a recurring corruption (per `PHASE-1-RESULTS.md` §"Bench caveats" item 1). Phase 2 should: +- Gate edit application on parse validity before committing the edit to the bench's cumulative buffer +- Or, treat fall-through-to-fallback as a soft outcome (record but don't count as skipped) -Phase 1 sub-phases (per `PHASE-1-PROVE-OUT.md` §"Phase 1 production migration plan"): -1. **1.2** — add `long id` field to production `CstNode` variants; override equals/hashCode to exclude id (spec §7 R1). Migrate hundreds of pattern-match call sites across all 5 modules. -2. **1.3** — thread `IdGenerator` through `PegEngine` + `ParserGenerator` emission templates. -3. **1.4** — modify `TreeSplicer.spliceAndShift` to reuse ancestor IDs (one-line behavioral change). -4. **1.5** — switch `NodeIndex` from `IdentityHashMap` to `LongLongMap`. -5. **1.6** — replace `IncrementalSession.applyIncremental` with Path D's optimized algorithm (step 1 and step 3 deleted). -6. **1.7** — verify parity (897 + IncrementalParityTest 22×100) + bench against 0.4.3 baseline on the 1900-LOC fixture. +### Items now superseded + +§6.2 lever-1 puzzle — fully resolved by Path D's algorithm + stable IDs. The historical "lever-1 cursor-overshoot fix needs 5-10 days of correctness analysis" is replaced by the Phase 0/1 work. The latent bug in `tryIncrementalReparse` (only checks pivot for `fallbackRules` membership, not ancestors) is still present and should be addressed in Phase 2 alongside the pivot algorithm rework. + +§6.4 unsafe-generator work — out of scope. The 0.5.0 design doesn't need it. -**Estimated 3-5 days focused engineering.** Smaller than the spec's original 1-week estimate because Path A is shelved and Path D is a much smaller change. +Do NOT pursue further allocation reduction in the 0.4.x interpreter — see prior HANDOVER guidance. + +--- -After Phase 1 lands, proceed to Phase 2 (Lever B top-down pivot — should dissolve the §6.2 lever-1 puzzle into a 30-line method per spec §3). +**Last updated:** 2026-05-07, after Phase 1 production migration landed. Path D's stable-id-ancestor architecture is in place and validated against the 0.4.3 baseline. Branch is local-only at 15 commits past chore; recommend pushing as a public Phase 1 marker before Phase 2 work begins. ### Items now superseded diff --git a/docs/incremental/PHASE-1-RESULTS.md b/docs/incremental/PHASE-1-RESULTS.md new file mode 100644 index 0000000..83a5459 --- /dev/null +++ b/docs/incremental/PHASE-1-RESULTS.md @@ -0,0 +1,90 @@ +# Phase 1 — Production Migration Results + +**Date:** 2026-05-07 +**Branch:** release-0.5.0 (commits `2443779` through `65a719f`) +**Spec:** [`ARCHITECTURE-0.5.0.md`](ARCHITECTURE-0.5.0.md), [`PHASE-1-PROVE-OUT.md`](PHASE-1-PROVE-OUT.md), [`PHASE-0-RESULTS.md`](PHASE-0-RESULTS.md) + +## Verdict + +**Phase 1 production migration of Path D is complete.** All 993 tests green across 5 modules (699 core + 196 incremental + 66 formatter + 5 maven-plugin + 27 playground). IncrementalParityTest 22×100 stays green throughout. `IncrementalSessionBench` shows **median 1.9× faster than 0.4.3, p95 35% better, frame-budget hit rate +4.4pp**, validating the Path D architecture on the real 1900-LOC fixture. + +The win is in the typical case. Large-pivot edits (Block/RecordBody ≥ 2k nodes) regressed at p99/max — an acceptable trade we entered Phase 1 with eyes open, having shelved Path A's much-larger-scope offset decoupling at the prove-out gate. + +## Sub-phase summary + +| Sub-phase | Commit | Deliverable | Status | +|-----------|--------|-------------|:------:| +| 1.2 | `2443779` | Production `CstNode` gains `long id`; equals/hashCode override (spec §7 R1); 91 call sites swept across all 5 modules; new `IdGenerator` in `peglib-core/tree`; threaded through `ParsingContext`, `PegEngine`, `ParserGenerator` emission templates | ✅ | +| 1.4 | `2443779` (side-effect) | `TreeSplicer.rebuildNonTerminal` reuses `oldAncestor.id()` (line 102) — Path D's stable-id ancestor preservation lands as a side-effect of 1.2's mechanical sweep | ✅ | +| 1.5/1.6 | `39e11f9` | `LinearProbingLongLongMap` + `LongLongMap` promoted from sandbox to `internal/`; production `NodeIndex` switches to LongLongMap-keyed parents map; `IncrementalSession.applyIncremental` adopts Path D's optimized algorithm; `LongLongMap` tombstone-saturation fix (resize trigger now `size + tombstones > threshold`) | ✅ | +| 1.7 | `65a719f` | `NodeIndex.applyIncremental` Step 6: refresh `nodesById` for right-of-edit subtrees that `TreeSplicer.shiftAll` deep-copied (preserves stable IDs but replaces records with shifted spans); bench exception diagnostics added to `IncrementalSessionBench` | ✅ | + +## Bench results — IncrementalSessionBench, Regime B (cursor-moved-to-edit) + +The realistic editor regime: cursor moved to the edit offset before each edit. Same RNG seed (`0xBEEFCAFE`) and same 1000-edit sequence as 0.4.3. + +| Metric | 0.4.3 baseline (HANDOVER §5) | 0.5.0 post-1.7 | Change | +|---|---:|---:|---:| +| Median | 10.8 ms | **5.6 ms** | **-48% (1.9× faster)** ✅ | +| p95 | 22.4 ms | **14.6 ms** | **-35%** ✅ | +| p99 | 53.3 ms | 138.8 ms | **+160% regression** ⚠️ | +| Max | 98.6 ms | 390.8 ms | **+297% regression** ⚠️ | +| % under 16 ms (frame budget) | 91.5% | **95.9%** | **+4.4 percentage points** ✅ | +| Successful incremental edits (Regime B) | ~915 (implied) | 565 | see "Bench caveats" | + +### Per-class medians (Regime B) + +| Class | Count | Median | p95 | p99 | Max | +|---|---:|---:|---:|---:|---:| +| single-char | 392 | 5.6 | 11.3 | 138.8 | 390.8 | +| word | 150 | 5.5 | 14.6 | 266.0 | 280.5 | +| line | 18 | 5.8 | 136.2 | 136.2 | 136.2 | +| block | 5 | 5.7 | 87.4 | 87.4 | 87.4 | +| ALL | 565 | 5.6 | 14.6 | 138.8 | 390.8 | + +### Top outliers (Regime B) + +The p99/max regression localizes to large-pivot edits where the boundary algorithm chose a high-fanout interior pivot. `TreeSplicer.shiftAll` deep-copies all right-of-edit descendants (with fresh records but stable IDs); Step 6's `nodesById` refresh then walks them. Total cost is bounded by the right-of-edit subtree size: + +| Latency | Class | pivotRule | pivotNodes | +|---:|---|---|---:| +| 390.8 ms | single-char | Block | 7065 | +| 303.9 ms | single-char | Block | 2211 | +| 280.5 ms | word | RecordBody | 7652 | +| 266.0 ms | word | RecordBody | 7652 | +| 240.2 ms | single-char | RecordBody | 7652 | +| 138.8 ms | single-char | Block | 2185 | + +Pivot selection itself was not changed in Phase 1 — that's Lever B (Phase 2 of the spec). The same edits in Regime A (cursor-pinned) hit `CompilationUnit` (90k+ nodes) but cap at ~120 ms because the algorithm degenerates more cleanly there. The Regime B outliers above 100 ms are all cases where the boundary algorithm picked a non-trivially-sized interior pivot AND the right-of-edit deep-copy + nodesById refresh dominated. + +## Bench caveats + +1. **Edit-plan corruption tail.** 398 of 435 Regime B exceptions are the same `IllegalStateException: full parse failed: Unexpected 'D' at 119:110` from `SessionFactory.parseFull`. Once an early edit breaks the buffer at line 119, every subsequent edit's full-reparse fallback hits the same error. This is a pre-existing bench-edit-plan issue (the random-edit generator can produce sequences that corrupt Java syntax over time); not a Phase 1 regression. The 0.4.3 baseline numbers in HANDOVER §5 don't include comparable exception-count data, so the absolute success count of 565 vs 0.4.3's implied ~915 may be a Phase-1-introduced shift OR the same bench flaw with a different seed/path. Phase 2's bench harness should gate edits on parse validity before committing them. + +2. **Cursor-aware vs cursor-pinned asymmetry, 57 edits.** Regime A (cursor-pinned) reports 622 successes; Regime B reports 565. The 57-edit gap means a small set of edits succeed when the cursor stays at offset 0 but fail when the cursor is moved to the edit point. Most likely cause: the warm-pointer pivot search in `IncrementalSession.tryIncrementalReparse` selects a slightly different pivot in Regime B that occasionally falls outside the safe-pivot set. Defer to Phase 2's pivot-algorithm rework (Lever B). + +3. **JMH not used here.** `IncrementalSessionBench` is a single-pass standalone main, not a JMH-warmup-controlled bench. Numbers are JIT-stable but lack the multi-fork variance bound that JMH provides. `IncrementalParityTest` 22×100 is the correctness regression net. + +## Items that didn't ship in Phase 1 + +- **Lever B (top-down pivot search).** Per spec §3, this dissolves the lever-1 puzzle into a 30-line method. Phase 2 entry point. The 57-edit Regime-asymmetry above and the large-pivot p99 regression are the strongest current motivators — both suggest pivot selection is the next bottleneck. +- **Lever C (peglib-rt unification).** Per spec §4. Multi-day refactor extracting per-Expression parse helpers into a new `peglib-rt` module. Defer until Phase 2 lands and a new perf bench can baseline against the resulting cleaner architecture. +- **Lever D (Cursor split).** Per spec §5. Smaller change; can land alongside Phase 2 or as a 0.5.x patch. +- **Bench harness fixes.** Edit-plan validity gating + JMH-style multi-fork wrapper. Should land in Phase 2. + +## Test state + +| Module | Tests | Failures | Errors | Skipped | +|---|---:|---:|---:|---:| +| peglib-core | 699 | 0 | 0 | 0 | +| peglib-incremental | 196 | 0 | 0 | 0 | +| peglib-formatter | 66 | 0 | 0 | 0 | +| peglib-maven-plugin | 5 | 0 | 0 | 0 | +| peglib-playground | 27 | 0 | 0 | 0 | +| **Aggregate** | **993** | **0** | **0** | **0** | + +`IncrementalParityTest` 22×100 (re-enabled in 0.3.5, still passing): all green. `IncrementalTriviaParityTest` 22 cases: all green. + +## Recommendation + +Push `release-0.5.0` to remote as a public Phase 1 marker before any Phase 2 work begins. The Phase 1 commits represent a substantial architectural change (CstNode shape, NodeIndex internals, applyIncremental algorithm) that downstream consumers — and any future `git bisect` for regressions — should be able to reference. Tagging `v0.5.0-alpha.1` (or similar) is also reasonable; canonical release per HANDOVER §7.3 happens at the end of Phase 5 with the full 0.5.0 surface. From e038e4f52d1d534cb406bb1bc3fcdd7e5fcc0399 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 14:28:35 +0200 Subject: [PATCH 15/46] =?UTF-8?q?exp:=20phase=202=20lever=20B=20=E2=80=94?= =?UTF-8?q?=20rolled=20back=20wiring,=20kept=20SafePivotAnalyzer=20+=20sma?= =?UTF-8?q?llestEnclosing=20as=20dormant=20infra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peg/incremental/internal/NodeIndex.java | 60 ++++++ .../internal/SafePivotAnalyzer.java | 122 +++++++++++ .../incremental/internal/SessionFactory.java | 17 +- .../incremental/internal/NodeIndexTest.java | 196 ++++++++++++++++++ .../internal/SafePivotAnalyzerTest.java | 179 ++++++++++++++++ 5 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SafePivotAnalyzer.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/NodeIndexTest.java create mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/SafePivotAnalyzerTest.java diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java index 911cd2b..287664e 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java @@ -247,6 +247,66 @@ public Option smallestContaining(int offset) { return Option.some(descendTo(root, offset)); } + /** + * Phase 2 (v0.5.0) — Lever B: top-down descent to the smallest node whose + * span strictly contains the half-open edit range + * {@code [editStart, editEnd]} in the pre-edit buffer. + * + *

Replaces the warm-pointer walk-up from + * {@link IncrementalSession#findBoundaryCandidate} for incremental boundary + * selection. With Phase 1's stable IDs and id-keyed {@code nodesById}, + * descent-from-root is O(depth × branching) — a few microseconds even on + * tens-of-thousands-of-node trees. Top-down is also REGIME-INSENSITIVE: a + * cursor pinned at offset 0 picks the same pivot as one moved to the edit + * site, eliminating Phase 1.7's Regime A/B asymmetry. + * + *

Algorithm: start at root; at each level look for the (single) child + * whose span strictly contains the edit range; descend if found, stop if + * not. The stopping point is either: + *

    + *
  • a leaf (Terminal/Token/Error) entirely containing the edit, or
  • + *
  • an interior node whose children straddle the edit boundary (no + * single child contains it). That node is the correct pivot — the + * smallest structural region affected.
  • + *
+ * + *

Boundary semantics match {@link #contains(CstNode, int)}: offsets are + * inclusive on both ends. A zero-length insertion at offset X is treated + * as inside any node whose {@code start <= X <= end}. + * + *

Returns {@link Option#none()} when the root itself does not contain + * {@code [editStart, editEnd]} (e.g., append past EOF). Callers handle + * the empty case by falling back to a full reparse. + */ + public Option smallestEnclosing(int editStart, int editEnd) { + if (root == null) { + return Option.none(); + } + var rootSpan = root.span(); + if (rootSpan.startOffset() > editStart || rootSpan.endOffset() < editEnd) { + return Option.none(); + } + var current = root; + while (current instanceof CstNode.NonTerminal nt) { + var next = pickStrictlyContainingChild(nt, editStart, editEnd); + if (next == null) { + break; + } + current = next; + } + return Option.some(current); + } + + private static CstNode pickStrictlyContainingChild(CstNode.NonTerminal nt, int editStart, int editEnd) { + for (var child : nt.children()) { + var span = child.span(); + if (span.startOffset() <= editStart && editEnd <= span.endOffset()) { + return child; + } + } + return null; + } + /** * Climb from {@code start} up through parents until a node containing * {@code offset} is found, then descend to the smallest containing diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SafePivotAnalyzer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SafePivotAnalyzer.java new file mode 100644 index 0000000..ec29dab --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SafePivotAnalyzer.java @@ -0,0 +1,122 @@ +package org.pragmatica.peg.incremental.internal; + +import org.pragmatica.peg.grammar.Expression; +import org.pragmatica.peg.grammar.Grammar; + +import java.util.HashSet; +import java.util.Set; + +/** + * Phase 2 (v0.5.0) — Lever B static analysis: determine which grammar rules are + * safe to use as a pivot for incremental {@code parseRuleAt} reparse. + * + *

A rule is "safe" only when re-entering it at an absolute buffer offset — + * with a freshly allocated parser context — produces the same parse it would + * produce as part of the full parse. That holds when: + *

    + *
  1. The rule does NOT depend on captures that may be set outside its own + * span. {@link BackReferenceScan#unsafeRules(Grammar)} already excludes + * any rule that (transitively) uses a {@link Expression.BackReference}.
  2. + *
  3. The rule has an UNAMBIGUOUS LITERAL prefix — its first consuming + * expression is a non-empty {@link Expression.Literal}. This guards + * against accidentally pivoting on a rule whose entry could match anywhere + * (e.g., a rule that begins with another rule reference or a quantifier + * that consumes nothing).
  4. + *
+ * + *

This analyzer mirrors {@link BackReferenceScan} in shape: a single static + * entry point that takes a {@link Grammar} and returns an immutable + * {@link Set} of rule names. The result is computed once per session-factory + * and stored on {@link SessionFactory}. + * + *

Conservatism

+ * + *

False negatives (excluding a rule that would in fact be safe) cost + * performance — the boundary algorithm has to walk one level higher, possibly + * to the root, which then triggers full reparse. False positives (admitting a + * rule that is actually unsafe) cost correctness — the spliced subtree may + * disagree with the oracle and parity tests will catch it. This analyzer + * deliberately biases toward conservative: only an unambiguous, non-empty + * leading {@link Expression.Literal} qualifies. Choices, quantifiers, + * character classes, and rule references at the front are all rejected. + * + * @since 0.5.0 + */ +public final class SafePivotAnalyzer { + private SafePivotAnalyzer() {} + + /** + * Compute the set of rule names that are safe to use as incremental + * reparse pivots in {@code grammar}. A rule is safe iff: + *

    + *
  1. it is NOT in {@link BackReferenceScan#unsafeRules(Grammar)}, AND
  2. + *
  3. its expression begins with an UNAMBIGUOUS, NON-EMPTY literal + * prefix — see {@link #hasUnambiguousLiteralPrefix(Expression)}.
  4. + *
+ * + *

Phase 2 (v0.5.0) — Lever B: the literal-prefix gate is the + * structural correctness guarantee for isolated reparse. PEG ordered + * choice is context-sensitive: a deeper rule reparsed at an absolute + * offset can succeed with the same end-offset but match a DIFFERENT + * alternative than the same rule embedded in a larger context. A + * non-empty literal at the front anchors the rule's identity — when the + * input at the offset matches the literal, isolated parse and contextual + * parse necessarily agree on which alternative to take. + * + *

Rules that fail the literal-prefix test (start with a rule + * reference, character class, choice, or quantifier) are excluded. The + * boundary algorithm then walks UP the spine to find a safe ancestor; + * this trades pivot depth for parity — the resulting pivot is sometimes + * larger (more {@link TreeSplicer#shiftAll} work) but is provably + * isolated-parse-equivalent to the contextual parse. + * + *

Empirically (Phase 2 IncrementalSessionBench / IncrementalParityTest): + * permissive variants that admit non-literal-prefixed rules pick smaller + * but context-sensitive pivots, breaking + * {@link org.pragmatica.peg.incremental.IncrementalParityTest}. The strict + * literal-prefix gate is the only filter we have validated to keep parity. + */ + public static Set safePivotRules(Grammar grammar) { + var unsafe = BackReferenceScan.unsafeRules(grammar); + var result = new HashSet(); + for (var rule : grammar.rules()) { + if (unsafe.contains(rule.name())) { + continue; + } + if (hasUnambiguousLiteralPrefix(rule.expression())) { + result.add(rule.name()); + } + } + return Set.copyOf(result); + } + + /** + * {@code true} iff walking the leftmost path of {@code expr} bottoms out at + * a non-empty {@link Expression.Literal}. Transparent wrappers ({@link + * Expression.Group}, {@link Expression.TokenBoundary}, {@link + * Expression.Capture}, {@link Expression.CaptureScope}) are unwrapped; + * {@link Expression.Sequence} recurses into its first element. Everything + * else — choices, quantifiers (which may consume nothing), references, + * character classes, predicates — fails the test. + */ + private static boolean hasUnambiguousLiteralPrefix(Expression expr) { + return switch (expr) { + case Expression.Literal lit -> !lit.text().isEmpty(); + case Expression.Sequence seq -> !seq.elements().isEmpty() + && hasUnambiguousLiteralPrefix(seq.elements().get(0)); + case Expression.Group grp -> hasUnambiguousLiteralPrefix(grp.expression()); + case Expression.TokenBoundary tb -> hasUnambiguousLiteralPrefix(tb.expression()); + case Expression.Capture cap -> hasUnambiguousLiteralPrefix(cap.expression()); + case Expression.CaptureScope cs -> hasUnambiguousLiteralPrefix(cs.expression()); + // Conservative-no for everything else: + // - Reference, CharClass, Any: ambiguous start (could match at any rule) + // - Choice: alternatives may not all start with the same literal + // - Optional, ZeroOrMore: rule could consume nothing + // - OneOrMore, Repetition: bounds permit non-literal inner expressions + // - And, Not: predicates do not consume input themselves + // - Ignore: wraps something whose match is discarded; treat as ambiguous + // - BackReference, Dictionary, Cut: not literal-prefixed in the strict sense + default -> false; + }; + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java index 11b22ee..74be300 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java @@ -35,6 +35,7 @@ public final class SessionFactory implements IncrementalParser { private final ParserConfig config; private final Parser parser; private final Set fallbackRules; + private final Set safePivotRules; private final RuleIdRegistry registry; private final boolean triviaFastPathEnabled; @@ -42,11 +43,13 @@ private SessionFactory(Grammar grammar, ParserConfig config, Parser parser, Set fallbackRules, + Set safePivotRules, boolean triviaFastPathEnabled) { this.grammar = grammar; this.config = config; this.parser = parser; this.fallbackRules = fallbackRules; + this.safePivotRules = safePivotRules; this.registry = new RuleIdRegistry(); this.triviaFastPathEnabled = triviaFastPathEnabled; } @@ -90,7 +93,8 @@ public static IncrementalParser sessionFactory(Grammar grammar, }, p -> p); var fallback = BackReferenceScan.unsafeRules(grammar); - return new SessionFactory(grammar, config, parser, fallback, triviaFastPathEnabled); + var safePivots = SafePivotAnalyzer.safePivotRules(grammar); + return new SessionFactory(grammar, config, parser, fallback, safePivots, triviaFastPathEnabled); } @Override @@ -124,6 +128,17 @@ Set fallbackRules() { return fallbackRules; } + /** + * Phase 2 (v0.5.0) — Lever B: rule names that are safe to use as + * incremental {@code parseRuleAt} pivots. See + * {@link SafePivotAnalyzer} for the criterion. The set is computed once + * per factory and reused for the lifetime of every {@link Session} this + * factory produces. + */ + Set safePivotRules() { + return safePivotRules; + } + RuleIdRegistry registry() { return registry; } diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/NodeIndexTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/NodeIndexTest.java new file mode 100644 index 0000000..cf707d0 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/NodeIndexTest.java @@ -0,0 +1,196 @@ +package org.pragmatica.peg.incremental.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.tree.CstNode; +import org.pragmatica.peg.tree.SourceSpan; +import org.pragmatica.peg.tree.Trivia; + +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link NodeIndex#smallestEnclosing(int, int)} — Phase 2 (v0.5.0) + * Lever B top-down boundary descent. Builds tiny synthetic CSTs by hand so the + * test asserts the descent contract directly, independent of the grammar parser + * or {@code parseRuleAt} machinery. + * + *

Tree shape used by most cases: + *

{@code
+ *   root  [0..30]      "Block"
+ *     a   [0..10]      "Stmt"
+ *       a1 [0..5]      "Token"  (Terminal)
+ *       a2 [5..10]     "Token"  (Terminal)
+ *     b   [10..20]     "Stmt"
+ *       b1 [10..15]    "Token"
+ *       b2 [15..20]    "Token"
+ *     c   [20..30]     "Stmt"
+ *       c1 [20..25]    "Token"
+ *       c2 [25..30]    "Token"
+ * }
+ */ +final class NodeIndexTest { + private final AtomicLong ids = new AtomicLong(1); + + private CstNode terminal(int start, int end, String rule, String text) { + return new CstNode.Terminal( + ids.getAndIncrement(), + span(start, end), + rule, + text, + List.of(), + List.of()); + } + + private CstNode nonTerminal(int start, int end, String rule, List children) { + return new CstNode.NonTerminal( + ids.getAndIncrement(), + span(start, end), + rule, + children, + List.of(), + List.of()); + } + + private static SourceSpan span(int start, int end) { + return new SourceSpan(1, start + 1, start, 1, end + 1, end); + } + + private CstNode buildSampleTree() { + var a1 = terminal(0, 5, "Token", "aaaaa"); + var a2 = terminal(5, 10, "Token", "bbbbb"); + var a = nonTerminal(0, 10, "Stmt", List.of(a1, a2)); + var b1 = terminal(10, 15, "Token", "ccccc"); + var b2 = terminal(15, 20, "Token", "ddddd"); + var b = nonTerminal(10, 20, "Stmt", List.of(b1, b2)); + var c1 = terminal(20, 25, "Token", "eeeee"); + var c2 = terminal(25, 30, "Token", "fffff"); + var c = nonTerminal(20, 30, "Stmt", List.of(c1, c2)); + return nonTerminal(0, 30, "Block", List.of(a, b, c)); + } + + @Nested + @DisplayName("smallestEnclosing") + class SmallestEnclosing { + + @Test + @DisplayName("returns deepest leaf when edit fits inside one terminal") + void editFullyInsideLeaf() { + var root = buildSampleTree(); + var index = NodeIndex.build(root); + + // Edit [12..14] is inside b1 [10..15]. + var pivot = index.smallestEnclosing(12, 14); + + assertThat(pivot.isPresent()).isTrue(); + var node = pivot.unwrap(); + assertThat(node.span().startOffset()).isEqualTo(10); + assertThat(node.span().endOffset()).isEqualTo(15); + assertThat(node.rule()).isEqualTo("Token"); + } + + @Test + @DisplayName("returns parent when edit straddles two children") + void editStraddlesChildBoundary() { + var root = buildSampleTree(); + var index = NodeIndex.build(root); + + // Edit [3..7] straddles a1 [0..5] and a2 [5..10] — both inside a. + // No single child of a contains [3..7], so a itself is the pivot. + var pivot = index.smallestEnclosing(3, 7); + + assertThat(pivot.isPresent()).isTrue(); + var node = pivot.unwrap(); + assertThat(node.span().startOffset()).isEqualTo(0); + assertThat(node.span().endOffset()).isEqualTo(10); + assertThat(node.rule()).isEqualTo("Stmt"); + } + + @Test + @DisplayName("returns root when edit straddles two top-level children") + void editStraddlesTopLevelBoundary() { + var root = buildSampleTree(); + var index = NodeIndex.build(root); + + // Edit [8..22] straddles a [0..10], b [10..20], and c [20..30] — + // no single root child contains it, so root is the pivot. + var pivot = index.smallestEnclosing(8, 22); + + assertThat(pivot.isPresent()).isTrue(); + var node = pivot.unwrap(); + assertThat(node.span().startOffset()).isEqualTo(0); + assertThat(node.span().endOffset()).isEqualTo(30); + assertThat(node.rule()).isEqualTo("Block"); + } + + @Test + @DisplayName("zero-length insertion at an interior boundary picks adjacent child") + void zeroLengthInsertionAtBoundary() { + var root = buildSampleTree(); + var index = NodeIndex.build(root); + + // Zero-length insert at offset 5 — boundary between a1 [0..5] and a2 [5..10]. + // contains() is inclusive on both ends, so the FIRST child that + // satisfies start <= 5 <= end wins → a1 is selected over a2. + var pivot = index.smallestEnclosing(5, 5); + + assertThat(pivot.isPresent()).isTrue(); + var node = pivot.unwrap(); + assertThat(node.span().startOffset()).isEqualTo(0); + assertThat(node.span().endOffset()).isEqualTo(5); + } + + @Test + @DisplayName("returns none when edit exceeds root span (append past EOF)") + void editExceedsRoot() { + var root = buildSampleTree(); + var index = NodeIndex.build(root); + + // Edit [25..40] extends beyond root.endOffset() = 30 — root cannot + // contain it, so descent is impossible. + var pivot = index.smallestEnclosing(25, 40); + + assertThat(pivot.isEmpty()).isTrue(); + } + + @Test + @DisplayName("returns none when edit starts before root span") + void editBeforeRoot() { + // Build a tree whose root does NOT start at offset 0. + var t = terminal(10, 20, "Token", "xxxxxxxxxx"); + var root = nonTerminal(10, 20, "Wrap", List.of(t)); + var index = NodeIndex.build(root); + + var pivot = index.smallestEnclosing(5, 8); + + assertThat(pivot.isEmpty()).isTrue(); + } + + @Test + @DisplayName("returns root when root is a leaf and contains the edit") + void rootIsLeaf() { + var root = terminal(0, 10, "Atom", "0123456789"); + var index = NodeIndex.build(root); + + var pivot = index.smallestEnclosing(2, 8); + + assertThat(pivot.isPresent()).isTrue(); + assertThat(pivot.unwrap()).isSameAs(root); + } + + @Test + @DisplayName("edit at root-aligned full span returns root") + void editEqualsRootSpan() { + var root = buildSampleTree(); + var index = NodeIndex.build(root); + + var pivot = index.smallestEnclosing(0, 30); + + assertThat(pivot.isPresent()).isTrue(); + assertThat(pivot.unwrap().rule()).isEqualTo("Block"); + } + } +} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/SafePivotAnalyzerTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/SafePivotAnalyzerTest.java new file mode 100644 index 0000000..c284408 --- /dev/null +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/internal/SafePivotAnalyzerTest.java @@ -0,0 +1,179 @@ +package org.pragmatica.peg.incremental.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.pragmatica.peg.grammar.Grammar; +import org.pragmatica.peg.grammar.GrammarParser; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link SafePivotAnalyzer}. Phase 2 (v0.5.0) Lever B + * static-analysis pass that decides which grammar rules are safe to use as + * incremental {@code parseRuleAt} pivots. + * + *

{@link SafePivotAnalyzer#safePivotRules(Grammar)} requires both: + *

    + *
  • NOT in {@link BackReferenceScan#unsafeRules(Grammar)}, and
  • + *
  • has an unambiguous, non-empty literal prefix.
  • + *
+ * + *

The literal-prefix requirement is the structural correctness gate + * against PEG context-sensitivity (see + * {@link org.pragmatica.peg.incremental.IncrementalParityTest}). + */ +final class SafePivotAnalyzerTest { + + private static Grammar grammar(String text) { + return GrammarParser.parse(text).fold( + cause -> { throw new IllegalStateException("grammar parse failed: " + cause.message()); }, + g -> g); + } + + @Test + @DisplayName("rule starting with a single-char literal is safe") + void singleCharLiteralPrefixIsSafe() { + var g = grammar(""" + Block <- '{' Stmt* '}' + Stmt <- 'x' ';' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + assertThat(safe).contains("Block", "Stmt"); + } + + @Test + @DisplayName("rule starting with a multi-char literal is safe") + void multiCharLiteralPrefixIsSafe() { + var g = grammar(""" + Decl <- 'class' Name + Name <- 'X' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + assertThat(safe).contains("Decl"); + } + + @Test + @DisplayName("rule starting with parenthesised literal is safe") + void parenthesisedLiteralPrefixIsSafe() { + var g = grammar(""" + Args <- ('(' Inner ')') + Inner <- '0' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + assertThat(safe).contains("Args"); + } + + @Test + @DisplayName("rule starting with a Reference (rule call) is unsafe") + void referencePrefixIsUnsafe() { + var g = grammar(""" + Stmt <- Ident ';' + Ident <- 'x' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + // Ident is safe (literal prefix). Stmt is NOT — starts with rule reference. + assertThat(safe).contains("Ident"); + assertThat(safe).doesNotContain("Stmt"); + } + + @Test + @DisplayName("rule starting with a CharClass is unsafe") + void charClassPrefixIsUnsafe() { + var g = grammar(""" + Letter <- [a-z] + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + assertThat(safe).doesNotContain("Letter"); + } + + @Test + @DisplayName("rule starting with an Optional is unsafe (could consume nothing)") + void optionalPrefixIsUnsafe() { + var g = grammar(""" + Maybe <- '+'? 'x' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + assertThat(safe).doesNotContain("Maybe"); + } + + @Test + @DisplayName("rule starting with ZeroOrMore is unsafe") + void zeroOrMorePrefixIsUnsafe() { + var g = grammar(""" + Stars <- '*'* 'end' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + assertThat(safe).doesNotContain("Stars"); + } + + @Test + @DisplayName("rule starting with OneOrMore is conservatively unsafe") + void oneOrMorePrefixIsConservativelyUnsafe() { + var g = grammar(""" + Pluses <- '+'+ 'x' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + // OneOrMore is conservatively rejected — its inner is literal but the + // quantifier itself is not a literal-prefix structure. + assertThat(safe).doesNotContain("Pluses"); + } + + @Test + @DisplayName("rule starting with Choice is unsafe") + void choicePrefixIsUnsafe() { + var g = grammar(""" + Either <- ('a' / 'b') 'x' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + // The first element of the Sequence is a Choice — conservative no. + assertThat(safe).doesNotContain("Either"); + } + + @Test + @DisplayName("rule with TokenBoundary wrapping a literal is safe") + void tokenBoundaryWrappingLiteralIsSafe() { + var g = grammar(""" + Capture <- < 'foo' > + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + assertThat(safe).contains("Capture"); + } + + @Test + @DisplayName("rule using back-reference is excluded even with literal prefix") + void backRefRuleIsExcluded() { + var g = grammar(""" + Tag <- '<' $name '>' Body '' + Body <- (!'<' .)* + Ident <- < [a-zA-Z]+ > + %whitespace <- [ \\t\\n]* + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + // Tag has '<' literal prefix BUT uses back-ref → must be excluded. + assertThat(safe).doesNotContain("Tag"); + } + + @Test + @DisplayName("singleton grammar with literal prefix yields singleton safe set") + void singletonGrammarYieldsSingletonSafeSet() { + var g = grammar(""" + R <- 'x' + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + assertThat(safe).containsExactly("R"); + } + + @Test + @DisplayName("transitively unsafe rules are excluded even with literal prefix") + void transitivelyUnsafeIsExcluded() { + var g = grammar(""" + Wrapper <- '[' Tag ']' + Tag <- '<' $name '>' Body '' + Body <- (!'<' .)* + Ident <- < [a-zA-Z]+ > + """); + var safe = SafePivotAnalyzer.safePivotRules(g); + // Wrapper transitively depends on Tag (back-ref), so transitively unsafe. + assertThat(safe).doesNotContain("Wrapper"); + } +} From 0ea98afc2d9b55e63209df433b5ae67505671304 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 14:51:01 +0200 Subject: [PATCH 16/46] fix: bench post-edit validation eliminates buffer-corruption exceptions --- .../bench/IncrementalSessionBench.java | 407 ++++++++++++------ 1 file changed, 268 insertions(+), 139 deletions(-) diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java index 32263ff..e292599 100644 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java @@ -1,18 +1,19 @@ package org.pragmatica.peg.incremental.bench; +import org.pragmatica.peg.PegParser; import org.pragmatica.peg.grammar.Grammar; import org.pragmatica.peg.grammar.GrammarParser; import org.pragmatica.peg.incremental.Edit; import org.pragmatica.peg.incremental.IncrementalParser; import org.pragmatica.peg.incremental.Session; +import org.pragmatica.peg.parser.Parser; +import org.pragmatica.peg.parser.ParserConfig; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.Random; /** @@ -61,7 +62,6 @@ * @since 0.4.4 */ public final class IncrementalSessionBench { - private static final String GRAMMAR_RESOURCE = "/java25.peg"; private static final String FIXTURE_RESOURCE = "/perf-corpus/large/FactoryClassGenerator.java.txt"; @@ -71,7 +71,11 @@ public final class IncrementalSessionBench { private static final double FRAME_BUDGET_MS = 16.0; /** Edit shape, used for per-class bucketing in the report. */ - private enum EditClass { SINGLE_CHAR, WORD, LINE, BLOCK; + private enum EditClass { + SINGLE_CHAR, + WORD, + LINE, + BLOCK; String label() { return switch (this) { case SINGLE_CHAR -> "single-char"; @@ -85,84 +89,129 @@ String label() { /** A pre-classified edit, generated up-front so generation cost is excluded from timing. */ private record ClassifiedEdit(Edit edit, EditClass cls) {} + /** + * Reason an edit was not counted as a normal applied measurement. + * + *

    + *
  • {@code APPLIED} — edit ran and was accepted (post-edit buffer parses).
  • + *
  • {@code OUT_OF_BOUNDS} — edit offsets exceeded current buffer; not attempted.
  • + *
  • {@code INVALIDATED} — pre-edit validation found the prospective buffer + * unparseable; edit skipped, session reference unchanged.
  • + *
  • {@code EXCEPTION} — {@code session.edit} itself threw (should be + * unreachable post-validation, kept as safety net).
  • + *
+ */ + private enum SkipReason { + APPLIED, + OUT_OF_BOUNDS, + INVALIDATED, + EXCEPTION + } + /** Per-edit measurement record. {@code -1} latency means the edit was skipped. */ - private record Measurement( - EditClass cls, - long latencyNs, - boolean wasFallback, - boolean skipped, - int editOffset, - int editOldLen, - int editNewLen, - String pivotRule, - int pivotNodeCount) {} + private record Measurement(EditClass cls, + long latencyNs, + boolean wasFallback, + boolean skipped, + SkipReason skipReason, + int editOffset, + int editOldLen, + int editNewLen, + String pivotRule, + int pivotNodeCount) {} public static void main(String[] args) throws Exception { var grammarText = loadResource(GRAMMAR_RESOURCE); var fixtureSource = loadResource(FIXTURE_RESOURCE); - var grammar = GrammarParser.parse(grammarText).fold( - cause -> { throw new IllegalStateException("grammar parse failed: " + cause.message()); }, - g -> g); + var grammar = GrammarParser.parse(grammarText) + .fold(cause -> { + throw new IllegalStateException("grammar parse failed: " + cause.message()); + }, + g -> g); var parser = IncrementalParser.create(grammar); - + // Validator parser: same grammar, same config — used for post-edit + // full-parse validation so the bench's session never advances onto a + // syntactically invalid buffer. See class doc and Step 6 plan. + var validator = PegParser.fromGrammar(grammar, ParserConfig.DEFAULT) + .fold(cause -> { + throw new IllegalStateException("validator parser construction failed: " + cause.message()); + }, + p -> p); // Generate the edit plan once. The same plan drives both regimes so // the comparison is apples-to-apples. var plan = generateEditPlan(fixtureSource, EDIT_COUNT, RNG_SEED); - // Warm the JIT a bit so first-edit numbers reflect steady-state JIT, // not interpreter cost. We do a small throwaway session. warmJit(parser, fixtureSource); - + long t0 = System.nanoTime(); var regimeBExc = new ArrayList(); var regimeAExc = new ArrayList(); - var regimeB = runRegime(parser, fixtureSource, plan, /*moveCursor=*/true, regimeBExc); - var regimeA = runRegime(parser, fixtureSource, plan, /*moveCursor=*/false, regimeAExc); - - printReport(fixtureSource, regimeB, regimeA); + var regimeB = runRegime(parser, validator, fixtureSource, plan, true, regimeBExc); + var regimeA = runRegime(parser, validator, fixtureSource, plan, false, regimeAExc); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + System.out.println("Validation: post-edit full-parse on every accepted edit (~+2x bench wallclock vs unvalidated)"); System.out.println(); - System.out.println("=== EXCEPTION DIAGNOSTICS ==="); - printExceptionSummary("Regime B (cursor-moved)", regimeBExc); + printReport(fixtureSource, regimeB, regimeA); System.out.println(); - printExceptionSummary("Regime A (cursor-pinned)", regimeAExc); + System.out.println("=== POST-EDIT VALIDATION SUMMARY ==="); + printRegimeSkipSummary("Regime B (cursor-moved)", regimeB, regimeBExc); + printRegimeSkipSummary("Regime A (cursor-pinned)", regimeA, regimeAExc); + System.out.printf("Total wallclock (both regimes): %d ms%n", elapsedMs); } - private record ExcReport(int editIndex, int offset, int oldLen, int newLen, String klass, String message, String topFrame, String fullStack) {} + private record ExcReport(int editIndex, + int offset, + int oldLen, + int newLen, + String klass, + String message, + String topFrame) {} - private static void printExceptionSummary(String label, List exceptions) { - System.out.printf("%s: %d exceptions caught%n", label, exceptions.size()); - if (exceptions.isEmpty()) { - return; - } - // Group by class+message+topFrame - var grouped = new LinkedHashMap>(); - for (var e : exceptions) { - String key = e.klass() + " | " + e.message() + " | " + e.topFrame(); - grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(e); - } - // Sort by frequency - var entries = new ArrayList<>(grouped.entrySet()); - entries.sort((a, b) -> Integer.compare(b.getValue().size(), a.getValue().size())); - System.out.println("Distinct exception groups:"); - for (var e : entries) { - System.out.printf(" [%d edits] %s%n", e.getValue().size(), e.getKey()); + private static void printRegimeSkipSummary(String label, Measurement[] ms, List exceptions) { + long applied = 0, oob = 0, invalidated = 0, exc = 0; + for (var m : ms) { + switch (m.skipReason()) { + case APPLIED -> applied++; + case OUT_OF_BOUNDS -> oob++; + case INVALIDATED -> invalidated++; + case EXCEPTION -> exc++; + } } - // Print full stack of dominant - if (!entries.isEmpty()) { - var dom = entries.get(0).getValue().get(0); - System.out.println(); - System.out.println("Dominant exception full stack (first occurrence):"); - System.out.printf(" edit#%d offset=%d oldLen=%d newLen=%d%n", dom.editIndex(), dom.offset(), dom.oldLen(), dom.newLen()); - System.out.println(dom.fullStack()); + System.out.printf("%s: %d applied, %d invalidated, %d out-of-bounds, %d exceptions%n", + label, + applied, + invalidated, + oob, + exc); + if (!exceptions.isEmpty()) { + // Group by class+message+topFrame; show only the dominant entry. + var grouped = new LinkedHashMap(); + for (var e : exceptions) { + String key = e.klass() + " | " + e.message() + " | " + e.topFrame(); + grouped.merge(key, 1, Integer::sum); + } + String dominantKey = null; + int dominantCount = 0; + for (var entry : grouped.entrySet()) { + if (entry.getValue() > dominantCount) { + dominantCount = entry.getValue(); + dominantKey = entry.getKey(); + } + } + System.out.printf(" dominant exception (%d/%d): %s%n", dominantCount, exceptions.size(), dominantKey); } } // -------- Regime driver ---------------------------------------------------------------- - - private static Measurement[] runRegime( - IncrementalParser parser, String fixtureSource, List plan, boolean moveCursor, - List excOut) { + private static Measurement[] runRegime(IncrementalParser parser, + Parser validator, + String fixtureSource, + List plan, + boolean moveCursor, + List excOut) { var session = parser.initialize(fixtureSource, 0); - int prevFallbacks = session.stats().fullReparseCount(); + int prevFallbacks = session.stats() + .fullReparseCount(); var out = new Measurement[plan.size()]; for (int i = 0; i < plan.size(); i++) { var ce = plan.get(i); @@ -170,71 +219,139 @@ private static Measurement[] runRegime( // Clamp the edit to the current buffer: the plan was generated against // the initial text, so by the time we replay against the live session // text the offsets and lengths may drift. Skip edits that cannot fit. - int textLen = session.text().length(); + String currentText = session.text(); + int textLen = currentText.length(); if (edit.offset() > textLen || edit.offset() + edit.oldLen() > textLen) { - out[i] = new Measurement(ce.cls(), -1L, false, true, - edit.offset(), edit.oldLen(), edit.newText().length(), "", 0); + out[i] = new Measurement(ce.cls(), + - 1L, + false, + true, + SkipReason.OUT_OF_BOUNDS, + edit.offset(), + edit.oldLen(), + edit.newText() + .length(), + "", + 0); continue; } - try { + // PRE-edit validation: apply the edit to the text in isolation + // (no parser involvement) and full-parse the result. If parsing + // fails, the random-edit generator produced a syntactically + // invalid mutation (stray quote, unbalanced brace, etc.). Skip + // the edit so session.edit never sees an invalid buffer — that + // path's parseFull would throw IllegalStateException. Validation + // runs OUTSIDE the timed region. See Step 6 plan. + String prospectiveText = applyEditToText(currentText, edit); + if (validator.parseCst(prospectiveText) + .isFailure()) { + out[i] = new Measurement(ce.cls(), + - 1L, + false, + true, + SkipReason.INVALIDATED, + edit.offset(), + edit.oldLen(), + edit.newText() + .length(), + "", + 0); + continue; + } + Session next; + long latencyNs; + try{ long t0 = System.nanoTime(); - Session next; if (moveCursor) { - next = session.moveCursor(edit.offset()).edit(edit); + next = session.moveCursor(edit.offset()) + .edit(edit); } else { next = session.edit(edit); } long t1 = System.nanoTime(); - var stats = next.stats(); - int fallbacks = stats.fullReparseCount(); - boolean wasFallback = fallbacks > prevFallbacks; - prevFallbacks = fallbacks; - out[i] = new Measurement(ce.cls(), t1 - t0, wasFallback, false, - edit.offset(), edit.oldLen(), edit.newText().length(), - stats.lastReparsedRule(), stats.lastReparsedNodeCount()); - session = next; + latencyNs = t1 - t0; } catch (RuntimeException ex) { - out[i] = new Measurement(ce.cls(), -1L, false, true, - edit.offset(), edit.oldLen(), edit.newText().length(), "", 0); - if (excOut != null) { - var sb = new StringBuilder(); - var trace = ex.getStackTrace(); - int n = Math.min(10, trace.length); - for (int k = 0; k < n; k++) { - sb.append(" at ").append(trace[k]).append('\n'); - } - if (ex.getCause() != null) { - sb.append(" Caused by: ").append(ex.getCause().getClass().getName()) - .append(": ").append(ex.getCause().getMessage()).append('\n'); - var ctrace = ex.getCause().getStackTrace(); - int cn = Math.min(5, ctrace.length); - for (int k = 0; k < cn; k++) { - sb.append(" at ").append(ctrace[k]).append('\n'); - } - } - String topFrame = trace.length > 0 ? trace[0].toString() : ""; - excOut.add(new ExcReport(i, edit.offset(), edit.oldLen(), edit.newText().length(), - ex.getClass().getName(), String.valueOf(ex.getMessage()), topFrame, sb.toString())); - } + out[i] = new Measurement(ce.cls(), + - 1L, + false, + true, + SkipReason.EXCEPTION, + edit.offset(), + edit.oldLen(), + edit.newText() + .length(), + "", + 0); + recordException(excOut, i, edit, ex); + continue; } + var stats = next.stats(); + int fallbacks = stats.fullReparseCount(); + boolean wasFallback = fallbacks > prevFallbacks; + prevFallbacks = fallbacks; + out[i] = new Measurement(ce.cls(), + latencyNs, + wasFallback, + false, + SkipReason.APPLIED, + edit.offset(), + edit.oldLen(), + edit.newText() + .length(), + stats.lastReparsedRule(), + stats.lastReparsedNodeCount()); + session = next; } return out; } + /** + * Apply {@code edit} to {@code text} as a pure string transform — used + * for pre-edit validation. Mirrors the buffer mutation that + * {@code IncrementalSession.applyEdit} performs internally. + */ + private static String applyEditToText(String text, Edit edit) { + var sb = new StringBuilder(text.length() + edit.delta()); + sb.append(text, 0, edit.offset()); + sb.append(edit.newText()); + sb.append(text, + edit.offset() + edit.oldLen(), + text.length()); + return sb.toString(); + } + + private static void recordException(List excOut, int i, Edit edit, RuntimeException ex) { + if (excOut == null) { + return; + } + var trace = ex.getStackTrace(); + String topFrame = trace.length > 0 + ? trace[0].toString() + : ""; + excOut.add(new ExcReport(i, + edit.offset(), + edit.oldLen(), + edit.newText() + .length(), + ex.getClass() + .getName(), + String.valueOf(ex.getMessage()), + topFrame)); + } + private static void warmJit(IncrementalParser parser, String fixtureSource) { var s = parser.initialize(fixtureSource, fixtureSource.length() / 2); for (int i = 0; i < 20; i++) { - int off = (i * 37 + 100) % s.text().length(); - try { - s = s.moveCursor(off).edit(new Edit(off, 0, "x")); - } catch (RuntimeException ignored) { - // warm-up best-effort - } + int off = (i * 37 + 100) % s.text() + .length(); + try{ + s = s.moveCursor(off) + .edit(new Edit(off, 0, "x")); + } catch (RuntimeException ignored) {} } } // -------- Edit plan generation --------------------------------------------------------- - /** * Generate a deterministic edit plan against the initial fixture text. * Edit positions cluster near the previous edit (70% within ±100 chars, @@ -321,7 +438,8 @@ private static ClassifiedEdit drawEdit(Random rng, int off, int textLen) { var block = new StringBuilder(); int lines = 2 + rng.nextInt(4); for (int i = 0; i < lines; i++) { - block.append(randomLine(rng)).append('\n'); + block.append(randomLine(rng)) + .append('\n'); } int safeOff = Math.min(off, textLen); return new ClassifiedEdit(new Edit(safeOff, 0, block.toString()), EditClass.BLOCK); @@ -340,12 +458,7 @@ private static char randomAlnum(Random rng) { return ALNUM.charAt(rng.nextInt(ALNUM.length())); } - private static final String[] WORDS = { - "class", "interface", "enum", "void", "int", "String", "var", "public", - "private", "static", "final", "return", "new", "true", "false", "null", - "if", "else", "while", "for", "foo", "bar", "baz", "x", "y", "z", - "value", "name", "list", "map" - }; + private static final String[] WORDS = {"class", "interface", "enum", "void", "int", "String", "var", "public", "private", "static", "final", "return", "new", "true", "false", "null", "if", "else", "while", "for", "foo", "bar", "baz", "x", "y", "z", "value", "name", "list", "map"}; private static String randomWord(Random rng) { return WORDS[rng.nextInt(WORDS.length)]; @@ -356,15 +469,14 @@ private static String randomLine(Random rng) { } // -------- Reporting -------------------------------------------------------------------- - private static void printReport(String fixtureSource, Measurement[] regimeB, Measurement[] regimeA) { System.out.println("=== IncrementalSessionBench ==="); int loc = countLines(fixtureSource); System.out.printf("Fixture: large/FactoryClassGenerator.java.txt (%d LOC, %d chars)%n", - loc, fixtureSource.length()); + loc, + fixtureSource.length()); System.out.printf("Edits: %d (RNG seed 0x%X)%n", EDIT_COUNT, RNG_SEED); System.out.println(); - System.out.println("REGIME B (cursor-moved-to-edit)"); printRegime(regimeB); System.out.println(); @@ -390,42 +502,60 @@ private static void printOutliers(Measurement[] ms) { } nonSkipped.sort((a, b) -> Long.compare(b.latencyNs(), a.latencyNs())); System.out.printf(" %-5s %-9s %-12s %-9s %-7s %-7s %-22s %-9s %s%n", - "rank", "latency", "class", "offset", "oldLen", "newLen", "pivotRule", "pivotNodes", "fallback"); + "rank", + "latency", + "class", + "offset", + "oldLen", + "newLen", + "pivotRule", + "pivotNodes", + "fallback"); int n = Math.min(10, nonSkipped.size()); for (int i = 0; i < n; i++) { var m = nonSkipped.get(i); - String pivot = m.pivotRule() == null || m.pivotRule().isEmpty() ? "-" : m.pivotRule(); + String pivot = m.pivotRule() == null || m.pivotRule() + .isEmpty() + ? "-" + : m.pivotRule(); if (pivot.length() > 22) { pivot = pivot.substring(0, 21) + "…"; } System.out.printf(" %-5d %-9s %-12s %-9d %-7d %-7d %-22s %-9d %s%n", - i + 1, - fmtMs(m.latencyNs()), - m.cls().label(), - m.editOffset(), - m.editOldLen(), - m.editNewLen(), - pivot, - m.pivotNodeCount(), - m.wasFallback()); + i + 1, + fmtMs(m.latencyNs()), + m.cls() + .label(), + m.editOffset(), + m.editOldLen(), + m.editNewLen(), + pivot, + m.pivotNodeCount(), + m.wasFallback()); } } private static void printRegime(Measurement[] ms) { System.out.printf(" %-15s %-7s %-8s %-8s %-8s %-8s %s%n", - "Class", "Count", "Median", "p95", "p99", "Max", "Fallbacks"); + "Class", + "Count", + "Median", + "p95", + "p99", + "Max", + "Fallbacks"); for (var cls : EditClass.values()) { printClassRow(cls.label(), filterByClass(ms, cls)); } printClassRow("ALL", filterAll(ms)); - // Cold vs warm split (across all classes, all non-skipped edits) long[] cold = sliceLatencies(ms, 0, COLD_PREFIX); long[] warm = sliceLatencies(ms, COLD_PREFIX, ms.length); long underBudget = countUnderBudget(ms); long total = countNonSkipped(ms); - double underPct = total == 0 ? 0.0 : (100.0 * underBudget / total); - + double underPct = total == 0 + ? 0.0 + : (100.0 * underBudget / total); System.out.println(); System.out.printf(" Cold (first %d edits) median: %s%n", COLD_PREFIX, fmtMs(median(cold))); System.out.printf(" Warm (edits %d-%d) median: %s%n", COLD_PREFIX + 1, ms.length, fmtMs(median(warm))); @@ -437,18 +567,17 @@ private static void printClassRow(String label, Measurement[] ms) { long fallbacks = countFallbacks(ms); long count = latencies.length; if (count == 0) { - System.out.printf(" %-15s %-7d %-8s %-8s %-8s %-8s %d%n", - label, 0, "-", "-", "-", "-", fallbacks); + System.out.printf(" %-15s %-7d %-8s %-8s %-8s %-8s %d%n", label, 0, "-", "-", "-", "-", fallbacks); return; } System.out.printf(" %-15s %-7d %-8s %-8s %-8s %-8s %d%n", - label, - count, - fmtMs(median(latencies)), - fmtMs(percentile(latencies, 95)), - fmtMs(percentile(latencies, 99)), - fmtMs(max(latencies)), - fallbacks); + label, + count, + fmtMs(median(latencies)), + fmtMs(percentile(latencies, 95)), + fmtMs(percentile(latencies, 99)), + fmtMs(max(latencies)), + fallbacks); } private static void printDelta(Measurement[] regimeB, Measurement[] regimeA) { @@ -468,13 +597,13 @@ private static void printDeltaRow(String label, Measurement[] b, Measurement[] a } double bMs = ns2ms(median(bl)); double aMs = ns2ms(median(al)); - String ratio = aMs <= 0 ? "-" : String.format("%.2fx", bMs / aMs); - System.out.printf(" %-15s %-12s %-12s %s%n", - label, fmtMs(median(bl)), fmtMs(median(al)), ratio); + String ratio = aMs <= 0 + ? "-" + : String.format("%.2fx", bMs / aMs); + System.out.printf(" %-15s %-12s %-12s %s%n", label, fmtMs(median(bl)), fmtMs(median(al)), ratio); } // -------- Helpers ---------------------------------------------------------------------- - private static Measurement[] filterByClass(Measurement[] ms, EditClass cls) { var out = new ArrayList(); for (var m : ms) { @@ -522,7 +651,7 @@ private static long countFallbacks(Measurement[] ms) { } private static long countUnderBudget(Measurement[] ms) { - long budgetNs = (long) (FRAME_BUDGET_MS * 1_000_000.0); + long budgetNs = (long)(FRAME_BUDGET_MS * 1_000_000.0); long c = 0; for (var m : ms) { if (!m.skipped() && m.latencyNs() >= 0 && m.latencyNs() < budgetNs) { From 4ad5824862e55d7456c21d4dbd2fa7dc5e4f2872 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 15:05:29 +0200 Subject: [PATCH 17/46] refactor: parseFull returns Result; Session.parseSuccessful() replaces edit-throws --- .../bench/IncrementalSessionBench.java | 168 ++++------------- .../pragmatica/peg/incremental/Session.java | 33 ++++ .../peg/incremental/SessionError.java | 50 +++++ .../internal/IncrementalSession.java | 175 ++++++++++++++++-- .../incremental/internal/SessionFactory.java | 52 +++--- 5 files changed, 306 insertions(+), 172 deletions(-) create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/SessionError.java diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java index e292599..6749e28 100644 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java @@ -1,18 +1,13 @@ package org.pragmatica.peg.incremental.bench; -import org.pragmatica.peg.PegParser; -import org.pragmatica.peg.grammar.Grammar; import org.pragmatica.peg.grammar.GrammarParser; import org.pragmatica.peg.incremental.Edit; import org.pragmatica.peg.incremental.IncrementalParser; import org.pragmatica.peg.incremental.Session; -import org.pragmatica.peg.parser.Parser; -import org.pragmatica.peg.parser.ParserConfig; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.LinkedHashMap; import java.util.List; import java.util.Random; @@ -95,17 +90,15 @@ private record ClassifiedEdit(Edit edit, EditClass cls) {} *
    *
  • {@code APPLIED} — edit ran and was accepted (post-edit buffer parses).
  • *
  • {@code OUT_OF_BOUNDS} — edit offsets exceeded current buffer; not attempted.
  • - *
  • {@code INVALIDATED} — pre-edit validation found the prospective buffer - * unparseable; edit skipped, session reference unchanged.
  • - *
  • {@code EXCEPTION} — {@code session.edit} itself threw (should be - * unreachable post-validation, kept as safety net).
  • + *
  • {@code INVALIDATED} — post-edit {@link Session#parseSuccessful()} returned + * {@code false}; the random-edit generator produced a syntactically invalid + * mutation. Session reference is rolled back to the pre-edit value.
  • *
*/ private enum SkipReason { APPLIED, OUT_OF_BOUNDS, - INVALIDATED, - EXCEPTION + INVALIDATED } /** Per-edit measurement record. {@code -1} latency means the edit was skipped. */ @@ -129,14 +122,6 @@ public static void main(String[] args) throws Exception { }, g -> g); var parser = IncrementalParser.create(grammar); - // Validator parser: same grammar, same config — used for post-edit - // full-parse validation so the bench's session never advances onto a - // syntactically invalid buffer. See class doc and Step 6 plan. - var validator = PegParser.fromGrammar(grammar, ParserConfig.DEFAULT) - .fold(cause -> { - throw new IllegalStateException("validator parser construction failed: " + cause.message()); - }, - p -> p); // Generate the edit plan once. The same plan drives both regimes so // the comparison is apples-to-apples. var plan = generateEditPlan(fixtureSource, EDIT_COUNT, RNG_SEED); @@ -144,71 +129,43 @@ public static void main(String[] args) throws Exception { // not interpreter cost. We do a small throwaway session. warmJit(parser, fixtureSource); long t0 = System.nanoTime(); - var regimeBExc = new ArrayList(); - var regimeAExc = new ArrayList(); - var regimeB = runRegime(parser, validator, fixtureSource, plan, true, regimeBExc); - var regimeA = runRegime(parser, validator, fixtureSource, plan, false, regimeAExc); + var regimeB = runRegime(parser, fixtureSource, plan, true); + var regimeA = runRegime(parser, fixtureSource, plan, false); long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; - System.out.println("Validation: post-edit full-parse on every accepted edit (~+2x bench wallclock vs unvalidated)"); + System.out.println("Validation: post-edit Session.parseSuccessful() check; rejected buffers roll back the session reference."); System.out.println(); printReport(fixtureSource, regimeB, regimeA); System.out.println(); System.out.println("=== POST-EDIT VALIDATION SUMMARY ==="); - printRegimeSkipSummary("Regime B (cursor-moved)", regimeB, regimeBExc); - printRegimeSkipSummary("Regime A (cursor-pinned)", regimeA, regimeAExc); + printRegimeSkipSummary("Regime B (cursor-moved)", regimeB); + printRegimeSkipSummary("Regime A (cursor-pinned)", regimeA); System.out.printf("Total wallclock (both regimes): %d ms%n", elapsedMs); } - private record ExcReport(int editIndex, - int offset, - int oldLen, - int newLen, - String klass, - String message, - String topFrame) {} - - private static void printRegimeSkipSummary(String label, Measurement[] ms, List exceptions) { - long applied = 0, oob = 0, invalidated = 0, exc = 0; + private static void printRegimeSkipSummary(String label, Measurement[] ms) { + long applied = 0, oob = 0, invalidated = 0; for (var m : ms) { switch (m.skipReason()) { case APPLIED -> applied++; case OUT_OF_BOUNDS -> oob++; case INVALIDATED -> invalidated++; - case EXCEPTION -> exc++; } } - System.out.printf("%s: %d applied, %d invalidated, %d out-of-bounds, %d exceptions%n", + System.out.printf("%s: %d applied, %d invalidated, %d out-of-bounds%n", label, applied, invalidated, - oob, - exc); - if (!exceptions.isEmpty()) { - // Group by class+message+topFrame; show only the dominant entry. - var grouped = new LinkedHashMap(); - for (var e : exceptions) { - String key = e.klass() + " | " + e.message() + " | " + e.topFrame(); - grouped.merge(key, 1, Integer::sum); - } - String dominantKey = null; - int dominantCount = 0; - for (var entry : grouped.entrySet()) { - if (entry.getValue() > dominantCount) { - dominantCount = entry.getValue(); - dominantKey = entry.getKey(); - } - } - System.out.printf(" dominant exception (%d/%d): %s%n", dominantCount, exceptions.size(), dominantKey); - } + oob); } // -------- Regime driver ---------------------------------------------------------------- + // Post-migration: Session.edit always returns a Session; parse failures surface via + // parseSuccessful() rather than exceptions, so the bench observes them post-edit + // and rolls the session reference back instead of swallowing a thrown RuntimeException. private static Measurement[] runRegime(IncrementalParser parser, - Parser validator, String fixtureSource, List plan, - boolean moveCursor, - List excOut) { + boolean moveCursor) { var session = parser.initialize(fixtureSource, 0); int prevFallbacks = session.stats() .fullReparseCount(); @@ -235,16 +192,18 @@ private static Measurement[] runRegime(IncrementalParser parser, 0); continue; } - // PRE-edit validation: apply the edit to the text in isolation - // (no parser involvement) and full-parse the result. If parsing - // fails, the random-edit generator produced a syntactically - // invalid mutation (stray quote, unbalanced brace, etc.). Skip - // the edit so session.edit never sees an invalid buffer — that - // path's parseFull would throw IllegalStateException. Validation - // runs OUTSIDE the timed region. See Step 6 plan. - String prospectiveText = applyEditToText(currentText, edit); - if (validator.parseCst(prospectiveText) - .isFailure()) { + long t0 = System.nanoTime(); + Session next = moveCursor + ? session.moveCursor(edit.offset()) + .edit(edit) + : session.edit(edit); + long t1 = System.nanoTime(); + long latencyNs = t1 - t0; + // Post-edit validation: if the parser rejected the new buffer, Session.edit + // returns a degraded session whose parseSuccessful() is false. Treat the + // edit as INVALIDATED, keep the previous session, and exclude the latency + // sample from the timing buckets (mirrors the prior pre-validation skip). + if (!next.parseSuccessful()) { out[i] = new Measurement(ce.cls(), - 1L, false, @@ -258,33 +217,6 @@ private static Measurement[] runRegime(IncrementalParser parser, 0); continue; } - Session next; - long latencyNs; - try{ - long t0 = System.nanoTime(); - if (moveCursor) { - next = session.moveCursor(edit.offset()) - .edit(edit); - } else { - next = session.edit(edit); - } - long t1 = System.nanoTime(); - latencyNs = t1 - t0; - } catch (RuntimeException ex) { - out[i] = new Measurement(ce.cls(), - - 1L, - false, - true, - SkipReason.EXCEPTION, - edit.offset(), - edit.oldLen(), - edit.newText() - .length(), - "", - 0); - recordException(excOut, i, edit, ex); - continue; - } var stats = next.stats(); int fallbacks = stats.fullReparseCount(); boolean wasFallback = fallbacks > prevFallbacks; @@ -305,49 +237,15 @@ private static Measurement[] runRegime(IncrementalParser parser, return out; } - /** - * Apply {@code edit} to {@code text} as a pure string transform — used - * for pre-edit validation. Mirrors the buffer mutation that - * {@code IncrementalSession.applyEdit} performs internally. - */ - private static String applyEditToText(String text, Edit edit) { - var sb = new StringBuilder(text.length() + edit.delta()); - sb.append(text, 0, edit.offset()); - sb.append(edit.newText()); - sb.append(text, - edit.offset() + edit.oldLen(), - text.length()); - return sb.toString(); - } - - private static void recordException(List excOut, int i, Edit edit, RuntimeException ex) { - if (excOut == null) { - return; - } - var trace = ex.getStackTrace(); - String topFrame = trace.length > 0 - ? trace[0].toString() - : ""; - excOut.add(new ExcReport(i, - edit.offset(), - edit.oldLen(), - edit.newText() - .length(), - ex.getClass() - .getName(), - String.valueOf(ex.getMessage()), - topFrame)); - } - private static void warmJit(IncrementalParser parser, String fixtureSource) { var s = parser.initialize(fixtureSource, fixtureSource.length() / 2); for (int i = 0; i < 20; i++) { int off = (i * 37 + 100) % s.text() .length(); - try{ - s = s.moveCursor(off) - .edit(new Edit(off, 0, "x")); - } catch (RuntimeException ignored) {} + // Post-migration: edit never throws on parse failure; degraded sessions + // are harmless during JIT warmup so we don't bother inspecting parseSuccessful(). + s = s.moveCursor(off) + .edit(new Edit(off, 0, "x")); } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Session.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Session.java index d64a776..4b0bb2e 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Session.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Session.java @@ -1,5 +1,6 @@ package org.pragmatica.peg.incremental; +import org.pragmatica.lang.Option; import org.pragmatica.peg.tree.CstNode; /** @@ -73,4 +74,36 @@ default Session edit(int offset, int oldLen, String newText) { * is incremented by one. */ Session reparseAll(); + + /** + * 0.5.0 (Path A) — {@code true} when the most recent full parse (or the + * splice-and-validate of an incremental reparse) produced a structurally + * valid CST; {@code false} when {@link #edit(Edit)} or {@link #reparseAll()} + * fell through to the degraded-Session synthesis path because the backing + * parser rejected the buffer. + * + *

Default: {@code true}. The package-private degraded session produced + * by {@code SessionFactory} on parse failure overrides this to surface the + * failure without resorting to exceptional control flow. + * + *

Editor-style callers that need to distinguish "edit applied to a + * valid buffer" from "edit applied to a buffer the grammar rejects" + * inspect this flag (and {@link #lastParseError()} for the cause message). + * Callers that don't care continue to use {@link #root()} and + * {@link #text()} as before — the degraded session still has a non-null + * root and text, just a single {@link CstNode.Error} root carrying the + * rejected buffer. + */ + default boolean parseSuccessful() { + return true; + } + + /** + * 0.5.0 (Path A) — when {@link #parseSuccessful()} is {@code false}, the + * {@link SessionError} returned by the most recent full parse, expressed + * via its {@link SessionError#message()}. Empty otherwise. + */ + default Option lastParseError() { + return Option.none(); + } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/SessionError.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/SessionError.java new file mode 100644 index 0000000..b531534 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/SessionError.java @@ -0,0 +1,50 @@ +package org.pragmatica.peg.incremental; + +import org.pragmatica.lang.Cause; + +/** + * Failure modes surfaced by an {@link org.pragmatica.peg.incremental.internal.SessionFactory} + * full-parse, propagated through {@link Session#edit(Edit)} and + * {@link Session#reparseAll()} as a degraded {@link Session} carrying a + * single {@link org.pragmatica.peg.tree.CstNode.Error} root (Path A — the + * public {@code Session.edit}/{@code reparseAll} signatures stay non-Result; + * callers concerned with parse health inspect {@link Session#parseSuccessful()} + * and {@link Session#lastParseError()}). + * + *

Variants: + *

    + *
  • {@link ParseFailed} — the backing parser rejected the buffer; carries + * the wrapped {@link Cause#message()} from the parser.
  • + *
  • {@link NoStartRule} — the grammar has no effective start rule; this + * is theoretically unreachable for a grammar that parsed successfully + * but is kept as a defensive variant.
  • + *
+ * + * @since 0.5.0 + */ +@SuppressWarnings("JBCT-SEAL-01") +public sealed interface SessionError extends Cause { + /** + * Full-parse failed because the parser rejected the buffer. + * + * @param parserMessage the {@link Cause#message()} reported by the + * backing parser, preserved verbatim for diagnostic + * surfaces. + */ + record ParseFailed(String parserMessage) implements SessionError { + @Override + public String message() { + return "full parse failed: " + parserMessage; + } + } + + /** + * Full-parse failed because the grammar has no effective start rule. + */ + record NoStartRule() implements SessionError { + @Override + public String message() { + return "full parse failed: No start rule defined in grammar"; + } + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java index 7d52983..06e1811 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java @@ -1,12 +1,16 @@ package org.pragmatica.peg.incremental.internal; import org.pragmatica.lang.Option; +import org.pragmatica.lang.Result; import org.pragmatica.peg.incremental.Edit; import org.pragmatica.peg.incremental.Session; +import org.pragmatica.peg.incremental.SessionError; import org.pragmatica.peg.incremental.Stats; import org.pragmatica.peg.parser.PegEngine; import org.pragmatica.peg.tree.CstNode; import org.pragmatica.peg.tree.IdGenerator; +import org.pragmatica.peg.tree.SourceLocation; +import org.pragmatica.peg.tree.SourceSpan; import java.util.List; @@ -41,7 +45,8 @@ record IncrementalSession( CstNode enclosingNode, NodeIndex index, IdGenerator idGen, - Stats stats) implements Session { + Stats stats, + Option lastError) implements Session { /** Build the initial session after a fresh full parse. */ static IncrementalSession initial(SessionFactory factory, String text, @@ -51,7 +56,60 @@ static IncrementalSession initial(SessionFactory factory, var index = NodeIndex.build(root); var enclosing = index.smallestContaining(cursor) .or(root); - return new IncrementalSession(factory, text, root, cursor, enclosing, index, idGen, Stats.INITIAL); + return new IncrementalSession(factory, text, root, cursor, enclosing, index, idGen, Stats.INITIAL, Option.none()); + } + + /** + * 0.5.0 (Path A) — synthesise a degraded initial session for the case + * where {@link SessionFactory#parseFull(String, IdGenerator)} fails on + * {@link SessionFactory#initialize(String, int)}. The resulting session + * has a single {@link CstNode.Error} root carrying the rejected buffer + * and an {@link Option#some(Object) Option.some(error)} for + * {@link Session#lastParseError()}. Stats start at {@code INITIAL}; no + * fallback count is incremented because the failure was on the very first + * parse, not on a fallback from incremental reparse. + */ + static IncrementalSession degradedInitial(SessionFactory factory, + String text, + int cursor, + IdGenerator idGen, + SessionError error) { + var root = degradedRoot(text, idGen, error); + var index = NodeIndex.build(root); + var enclosing = index.smallestContaining(cursor) + .or(root); + return new IncrementalSession(factory, + text, + root, + cursor, + enclosing, + index, + idGen, + Stats.INITIAL, + Option.some(error)); + } + + /** + * Build the single-{@link CstNode.Error}-root that represents a degraded + * Session per Path A. The error span covers the entire buffer; the + * {@code skippedText} is the rejected buffer; {@code expected} is the + * {@link SessionError#message()}. Trivia lists are empty. + */ + private static CstNode degradedRoot(String text, IdGenerator idGen, SessionError error) { + var start = SourceLocation.START; + var end = SourceLocation.sourceLocation(1, 1 + text.length(), text.length()); + var span = SourceSpan.sourceSpan(start, end); + return new CstNode.Error(idGen.next(), span, text, error.message(), List.of(), List.of()); + } + + @Override + public boolean parseSuccessful() { + return lastError.isEmpty(); + } + + @Override + public Option lastParseError() { + return lastError.map(SessionError::message); } @Override @@ -98,7 +156,8 @@ public Session edit(Edit edit) { nextEnclosing, nextIndex, idGen, - nextStats); + nextStats, + Option.none()); } } // Try incremental reparse next. @@ -106,8 +165,11 @@ public Session edit(Edit edit) { if (incremental.isPresent()) { return applyIncremental(incremental.unwrap(), newText, newCursor, t0); } - // Fall back to a full reparse. - return fallback(newText, newCursor, t0); + // Fall back to a full reparse. 0.5.0 (Path A): on parse failure, + // synthesise a degraded Session — never throw. + return fallback(newText, newCursor, t0) + .fold(cause -> degradedFallback(newText, newCursor, t0, (SessionError) cause), + session -> session); } @Override @@ -119,13 +181,21 @@ public Session moveCursor(int newOffset) { } var newEnclosing = index.smallestContainingFrom(enclosingNode, clamped) .or(root); - return new IncrementalSession(factory, text, root, clamped, newEnclosing, index, idGen, stats); + return new IncrementalSession(factory, text, root, clamped, newEnclosing, index, idGen, stats, lastError); } @Override public Session reparseAll() { long t0 = System.nanoTime(); - var fresh = factory.parseFull(text, idGen); + // 0.5.0 (Path A): reparseAll is the diagnostic escape hatch. On parse + // failure synthesise a degraded Session rather than throw — preserves + // the public non-Result contract; callers inspect parseSuccessful(). + return factory.parseFull(text, idGen) + .fold(cause -> degradedReparseAll(t0, (SessionError) cause), + fresh -> reparseAllSuccess(fresh, t0)); + } + + private Session reparseAllSuccess(CstNode fresh, long t0) { var freshIndex = NodeIndex.build(fresh); var enclosing = freshIndex.smallestContaining(cursor) .or(fresh); @@ -136,11 +206,74 @@ public Session reparseAll() { NodeIndex.flatten(fresh) .size(), System.nanoTime() - t0); - return new IncrementalSession(factory, text, fresh, cursor, enclosing, freshIndex, idGen, nextStats); + return new IncrementalSession(factory, text, fresh, cursor, enclosing, freshIndex, idGen, nextStats, Option.none()); } - private Session fallback(String newText, int newCursor, long t0) { - var fresh = factory.parseFull(newText, idGen); + private Session degradedReparseAll(long t0, SessionError error) { + var fresh = degradedRoot(text, idGen, error); + var freshIndex = NodeIndex.build(fresh); + var enclosing = freshIndex.smallestContaining(cursor) + .or(fresh); + var nextStats = new Stats( + stats.reparseCount() + 1, + stats.fullReparseCount() + 1, + "", + NodeIndex.flatten(fresh) + .size(), + System.nanoTime() - t0); + return new IncrementalSession(factory, + text, + fresh, + cursor, + enclosing, + freshIndex, + idGen, + nextStats, + Option.some(error)); + } + + /** + * 0.5.0 — full-reparse fallback now returns {@code Result}. + * Parse success yields a healthy Session; parse failure is propagated as + * a {@link SessionError} {@link Result#failure(org.pragmatica.lang.Cause) failure} + * for the caller in {@link #edit(Edit)} to materialise as a degraded + * Session per Path A. + */ + private Result fallback(String newText, int newCursor, long t0) { + return factory.parseFull(newText, idGen) + .map(fresh -> fallbackSuccess(fresh, newText, newCursor, t0)); + } + + private Session fallbackSuccess(CstNode fresh, String newText, int newCursor, long t0) { + var freshIndex = NodeIndex.build(fresh); + var enclosing = freshIndex.smallestContaining(newCursor) + .or(fresh); + var nextStats = new Stats( + stats.reparseCount() + 1, + stats.fullReparseCount() + 1, + "", + NodeIndex.flatten(fresh) + .size(), + System.nanoTime() - t0); + return new IncrementalSession(factory, + newText, + fresh, + newCursor, + enclosing, + freshIndex, + idGen, + nextStats, + Option.none()); + } + + /** + * 0.5.0 (Path A) — synthesise the degraded Session for the {@code edit} + * fallback path. Mirrors {@link #fallbackSuccess} but with a + * single-{@link CstNode.Error}-root tree and {@code Option.some(error)} + * recorded in {@link #lastError}. + */ + private Session degradedFallback(String newText, int newCursor, long t0, SessionError error) { + var fresh = degradedRoot(newText, idGen, error); var freshIndex = NodeIndex.build(fresh); var enclosing = freshIndex.smallestContaining(newCursor) .or(fresh); @@ -151,7 +284,15 @@ private Session fallback(String newText, int newCursor, long t0) { NodeIndex.flatten(fresh) .size(), System.nanoTime() - t0); - return new IncrementalSession(factory, newText, fresh, newCursor, enclosing, freshIndex, idGen, nextStats); + return new IncrementalSession(factory, + newText, + fresh, + newCursor, + enclosing, + freshIndex, + idGen, + nextStats, + Option.some(error)); } /** @@ -196,7 +337,17 @@ private Session applyIncremental(IncrementalResult incremental, String newText, } var nextEnclosing = nextIndex.smallestContaining(newCursor) .or(normalized); - return new IncrementalSession(factory, newText, normalized, newCursor, nextEnclosing, nextIndex, idGen, nextStats); + // Successful incremental reparse — never carries a parse-error flag; + // the splice already validated against the rule's expected end offset. + return new IncrementalSession(factory, + newText, + normalized, + newCursor, + nextEnclosing, + nextIndex, + idGen, + nextStats, + Option.none()); } /** diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java index 74be300..1489e51 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java @@ -1,10 +1,12 @@ package org.pragmatica.peg.incremental.internal; +import org.pragmatica.lang.Result; import org.pragmatica.peg.PegParser; import org.pragmatica.peg.action.RuleId; import org.pragmatica.peg.grammar.Grammar; import org.pragmatica.peg.incremental.IncrementalParser; import org.pragmatica.peg.incremental.Session; +import org.pragmatica.peg.incremental.SessionError; import org.pragmatica.peg.parser.Parser; import org.pragmatica.peg.parser.ParserConfig; import org.pragmatica.peg.parser.PegEngine; @@ -108,8 +110,13 @@ public Session initialize(String buffer, int cursorOffset) { // it through every reparse so node IDs stay stable across edits. This is // the precondition for Path D's optimized NodeIndex.applyIncremental. var idGen = new IdGenerator.PerSessionCounter(); - CstNode root = parseFull(buffer, idGen); - return IncrementalSession.initial(this, buffer, clampedCursor, root, idGen); + // 0.5.0 — parseFull returns Result. On failure synthesise a + // degraded Session per Path A: caller's contract on initialize is + // non-Result, so wrap the failure in a CstNode.Error root and surface + // it through Session#parseSuccessful()/lastParseError(). + return parseFull(buffer, idGen) + .fold(cause -> IncrementalSession.degradedInitial(this, buffer, clampedCursor, idGen, (SessionError) cause), + root -> IncrementalSession.initial(this, buffer, clampedCursor, root, idGen)); } Grammar grammar() { @@ -147,41 +154,36 @@ boolean triviaFastPathEnabled() { return triviaFastPathEnabled; } - /** - * Full-parse the buffer via the backing {@link Parser}. Surfaces errors - * as {@link IllegalStateException} — v1 treats an unparseable full - * buffer as a programmer-level failure; recovery-aware callers should - * configure {@link ParserConfig} with {@link - * org.pragmatica.peg.error.RecoveryStrategy#ADVANCED} and read - * diagnostics from the resulting tree's {@link CstNode.Error} nodes. - */ - CstNode parseFull(String buffer) { - return parser.parseCst(buffer) - .fold(cause -> { - throw new IllegalStateException("full parse failed: " + cause.message()); - }, - node -> node); - } - /** * Phase 1.5 (v0.5.0): Session-aware full-parse that uses the supplied * {@link IdGenerator}. Routes through {@link PegEngine}'s id-aware overload * so node IDs come from the Session's counter rather than a fresh per-call - * one. Surfaces errors identically to {@link #parseFull(String)}. + * one. + * + *

0.5.0 — failures surface as {@link Result#failure(org.pragmatica.lang.Cause)} + * carrying a {@link SessionError} variant rather than the pre-0.5.0 + * {@link IllegalStateException}. Callers in {@link IncrementalSession} + * propagate Result through {@code fallback}/{@code reparseAll}; the public + * {@link Session} surface synthesises a degraded Session on failure so the + * exception never escapes (Path A — see {@link SessionError}). + * + *

Recovery-aware callers should configure {@link ParserConfig} with + * {@link org.pragmatica.peg.error.RecoveryStrategy#ADVANCED} and read + * diagnostics from the resulting tree's {@link CstNode.Error} nodes — that + * path produces a successful Result with embedded error nodes rather than + * a Result.failure. */ - CstNode parseFull(String buffer, IdGenerator idGen) { + Result parseFull(String buffer, IdGenerator idGen) { var startRule = grammar.effectiveStartRule(); if (startRule.isEmpty()) { - throw new IllegalStateException("full parse failed: No start rule defined in grammar"); + return new SessionError.NoStartRule().result(); } var engine = (PegEngine) parser; return engine.parseCst(buffer, startRule.unwrap() .name(), idGen) - .fold(cause -> { - throw new IllegalStateException("full parse failed: " + cause.message()); - }, - node -> node); + .fold(cause -> new SessionError.ParseFailed(cause.message()).result(), + Result::success); } } From 5275d86c4844d6cd8dcf32f5250e95d728000623 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 15:19:52 +0200 Subject: [PATCH 18/46] chore: remove Phase 0/1 sandbox + sandbox JMH benches (production has equivalents) --- .../peg/incremental/bench/MidBufferBench.java | 241 ----------- .../bench/MidBufferTreeBuilder.java | 77 ---- .../peg/incremental/bench/PathDBench.java | 182 -------- .../incremental/bench/Phase0SpikeBench.java | 146 ------- .../bench/SyntheticTreeBuilder.java | 134 ------ .../incremental/experimental/IdCstNode.java | 144 ------- .../experimental/IdCstNodeBuilder.java | 80 ---- .../incremental/experimental/IdGenerator.java | 52 --- .../incremental/experimental/IdNodeIndex.java | 278 ------------ .../experimental/IdTreeSplicer.java | 137 ------ .../LinearProbingLongLongMap.java | 221 ---------- .../incremental/experimental/LongLongMap.java | 91 ---- .../experimental/OffsetDecoupledNode.java | 131 ------ .../OffsetDecoupledNodeIndex.java | 137 ------ .../experimental/OffsetDecoupledSplicer.java | 185 -------- .../incremental/experimental/SpanIndex.java | 141 ------- .../experimental/StableIdNodeIndex.java | 238 ----------- .../experimental/StableIdSplicer.java | 159 ------- .../experimental/package-info.java | 18 - .../CalculatorTriviaIncrementalTest.java | 307 -------------- .../experimental/IdCstNodeBuilderTest.java | 124 ------ .../experimental/IdCstNodeTest.java | 148 ------- .../experimental/IdGeneratorTest.java | 49 --- .../experimental/IdNodeIndexTest.java | 395 ------------------ .../experimental/IdTreeSplicerTest.java | 297 ------------- .../experimental/LongLongMapTest.java | 263 ------------ .../experimental/OffsetDecoupledNodeTest.java | 94 ----- .../OffsetDecoupledSplicerTest.java | 279 ------------- .../experimental/SpanIndexTest.java | 254 ----------- .../experimental/StableIdNodeIndexTest.java | 279 ------------- .../experimental/StableIdSplicerTest.java | 182 -------- 31 files changed, 5463 deletions(-) delete mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java delete mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java delete mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java delete mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java delete mode 100644 peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java delete mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/package-info.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/CalculatorTriviaIncrementalTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdGeneratorTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdNodeIndexTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicerTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java delete mode 100644 peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java deleted file mode 100644 index 852a764..0000000 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferBench.java +++ /dev/null @@ -1,241 +0,0 @@ -package org.pragmatica.peg.incremental.bench; - -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; -import org.pragmatica.peg.incremental.experimental.IdCstNode; -import org.pragmatica.peg.incremental.experimental.IdGenerator; -import org.pragmatica.peg.incremental.experimental.IdNodeIndex; -import org.pragmatica.peg.incremental.experimental.OffsetDecoupledNode; -import org.pragmatica.peg.incremental.experimental.OffsetDecoupledNodeIndex; -import org.pragmatica.peg.incremental.experimental.OffsetDecoupledSplicer; -import org.pragmatica.peg.incremental.experimental.SpanIndex; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; - -/** - * Phase 1.0/1.1 — perf-gate JMH bench for path-A (offset decoupling) on - * mid-buffer edits. - * - *

Why this bench exists. The Phase 0 spike measured a - * 38-67× speedup on a balanced 4-ary tree with a depth-3 splice. That tree - * shape didn't trigger the right-of-edit deep-copy that - * {@code TreeSplicer.spliceAndShift} pays for siblings whose offsets must - * shift. Real source files (long method bodies, sequential top-level - * declarations) produce wide, sequential children at the spine, and - * mid-buffer edits force deep-copy of ~half of them. This bench measures - * that case. - * - *

Tree shape

- * - *

{@code Root[child_0, child_1, ..., child_{N-1}]} — a single - * NonTerminal root with {@code N} terminal children. Each child spans - * {@code [i*10, i*10+10)}. Mimics a Java method body of {@code N} statements. - * - *

Edit

- * - *

Replace child at index {@code N/2} with a freshly-built terminal of - * width 13 (delta = +3). The edit is mid-buffer: ~N/2 siblings to the right - * have offsets {@code >= editEnd} and need shifting. - * - *

Two arms compared

- * - *
    - *
  • productionStyle: replicates - * {@code TreeSplicer.spliceAndShift} on {@link IdCstNode} — for every - * child slot, if the child starts at or after {@code editEnd}, deep-copy - * it via {@code shiftAll} (rebuild record with new {@link SourceSpan}). - * Then run {@link IdNodeIndex#applyIncremental}. - *
  • offsetDecoupled: {@link OffsetDecoupledSplicer#splice} - * does the work — the SpanIndex is shifted in place via a single walk - * over a primitive {@code long[]}; sibling records are reference-shared. - * Then run {@link OffsetDecoupledNodeIndex#applyIncremental}. - *
- * - *

The "production-style" arm is implemented inline (rather than calling - * the production {@code TreeSplicer}) for two reasons: (1) {@code TreeSplicer} - * works on production {@link org.pragmatica.peg.tree.CstNode}, not - * {@link IdCstNode}; (2) we need the index update to operate on the same - * record type for an apples-to-apples comparison. The inlined logic mirrors - * {@code TreeSplicer.shiftAll} and {@code TreeSplicer.rebuildNonTerminal} - * exactly. - * - *

Perf gate

- * - *

Path A passes the gate iff {@code productionStyle / offsetDecoupled ≥ 5} - * at tree size ≥ 1000 children. NO-GO falls back to path B (lazy shift) or - * path C (accept partial win). - * - *

How to run

- *
{@code
- *   mvn -pl peglib-incremental -am -Pbench -DskipTests package
- *   java -jar peglib-incremental/target/benchmarks.jar MidBufferBench \
- *     -rf json -rff phase1-spanindex.json -i 3 -wi 2 -f 1
- * }
- * - * @since 0.5.0 - */ -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(TimeUnit.MICROSECONDS) -@Warmup(iterations = 3, time = 1) -@Measurement(iterations = 5, time = 2) -@Fork(2) -@State(Scope.Benchmark) -public class MidBufferBench { - - private static final List NO_TRIVIA = List.of(); - - @Param({"100", "1000", "10000"}) - private int treeSize; - - // --- production-style arm state --- - private IdCstNode idRoot; - private IdCstNode idTarget; // the child being replaced - private IdCstNode idNewPivot; - private IdGenerator idGenProd; // for ancestor rebuild ids - private IdNodeIndex idOldIndex; // pre-built; rebuilt per Level.Invocation - private int editEnd; - private int delta; - - // --- offset-decoupled arm state --- - private OffsetDecoupledNode odRoot; - private OffsetDecoupledNode odTarget; - private OffsetDecoupledNode odNewPivot; - private SpanIndex odBaselineSpans; - private OffsetDecoupledSplicer odSplicer; - private OffsetDecoupledNodeIndex odOldIndex; // rebuilt per Level.Invocation - private IdGenerator idGenOd; - - @Setup(Level.Trial) - public void buildTrees() { - // Production-style tree. - idGenProd = new IdGenerator.PerSessionCounter(); - idRoot = MidBufferTreeBuilder.buildIdTree(treeSize, idGenProd); - var idChildren = ((IdCstNode.NonTerminal) idRoot).children(); - int midIndex = treeSize / 2; - idTarget = idChildren.get(midIndex); - // Replacement: width 13 (delta = +3) at the same start offset. - int targetStart = midIndex * MidBufferTreeBuilder.CHILD_WIDTH; - var newPivotSpan = new SourceSpan(1, 1, targetStart, 1, 1, targetStart + 13); - idNewPivot = new IdCstNode.Terminal(idGenProd.next(), newPivotSpan, "Stmt", "yyy", NO_TRIVIA, NO_TRIVIA); - editEnd = targetStart + MidBufferTreeBuilder.CHILD_WIDTH; // old end - delta = 3; - - // Offset-decoupled tree (same shape, fresh ids). - idGenOd = new IdGenerator.PerSessionCounter(); - var odTree = MidBufferTreeBuilder.buildOffsetDecoupledTree(treeSize, idGenOd); - odRoot = odTree.root(); - odBaselineSpans = odTree.spans(); - var odChildren = ((OffsetDecoupledNode.NonTerminal) odRoot).children(); - odTarget = odChildren.get(midIndex); - odNewPivot = new OffsetDecoupledNode.Terminal(idGenOd.next(), "Stmt", "yyy", NO_TRIVIA, NO_TRIVIA); - // Caller responsibility: register newPivot's span. - odBaselineSpans.put(odNewPivot.id(), targetStart, targetStart + 13); - odSplicer = new OffsetDecoupledSplicer(idGenOd); - } - - /** - * Per-invocation: rebuild both arm's old indices, because - * {@link IdNodeIndex#applyIncremental} and - * {@link OffsetDecoupledNodeIndex#applyIncremental} mutate the receiver's - * parent map. Cost is the same on both sides ({@code O(N)} build); the - * bench measures the differential cost of the splice + index update arm. - */ - @Setup(Level.Invocation) - public void rebuildIndices() { - idOldIndex = IdNodeIndex.build(idRoot); - odOldIndex = OffsetDecoupledNodeIndex.build(odRoot); - } - - /** - * Production-style: deep-copy every right-of-edit sibling, rebuild root - * with new spans, then call {@code applyIncremental} on the index. - * - *

Mirrors {@code TreeSplicer.spliceAndShift} for a single-level tree — - * step 1 is the spans-baked-in rebuild that path A claims to eliminate. - */ - @Benchmark - public Object productionStyle() { - // Step 1: build new root with the spliced child + shifted right siblings. - var oldNT = (IdCstNode.NonTerminal) idRoot; - var oldChildren = oldNT.children(); - var newChildren = new ArrayList(oldChildren.size()); - for (var child : oldChildren) { - if (child == idTarget) { - newChildren.add(idNewPivot); - } else if (child.span().startOffset() >= editEnd) { - newChildren.add(shiftAll(child, delta)); - } else { - newChildren.add(child); - } - } - // Rebuild root span: end shifted because old end >= editEnd. - var oldSpan = oldNT.span(); - var newRootSpan = new SourceSpan( - oldSpan.startLine(), oldSpan.startColumn(), oldSpan.startOffset(), - oldSpan.endLine(), oldSpan.endColumn(), oldSpan.endOffset() + delta); - var newRoot = new IdCstNode.NonTerminal( - idGenProd.next(), newRootSpan, oldNT.rule(), List.copyOf(newChildren), - oldNT.leadingTrivia(), oldNT.trailingTrivia()); - - // Step 2: incremental index update. - // oldPath: [idRoot, idTarget]; newPath: [newRoot, idNewPivot]. - return idOldIndex.applyIncremental(newRoot, List.of(idRoot, idTarget), List.of(newRoot, idNewPivot)); - } - - /** - * Path A: SpanIndex.shift + reference-shared siblings + incremental index update. - */ - @Benchmark - public Object offsetDecoupled() { - var oldPath = List.of(odRoot, odTarget); - var spliceResult = odSplicer.splice(odBaselineSpans, oldPath, odNewPivot, editEnd, delta); - - return odOldIndex.applyIncremental(spliceResult.newRoot(), oldPath, spliceResult.newPath()); - } - - // --- inline production-style helpers (mirror TreeSplicer.shiftAll) --- - - private static IdCstNode shiftAll(IdCstNode node, int delta) { - if (delta == 0) { - return node; - } - var span = shiftSpan(node.span(), delta); - return switch (node) { - case IdCstNode.Terminal t -> new IdCstNode.Terminal( - t.id(), span, t.rule(), t.text(), t.leadingTrivia(), t.trailingTrivia()); - case IdCstNode.Token t -> new IdCstNode.Token( - t.id(), span, t.rule(), t.text(), t.leadingTrivia(), t.trailingTrivia()); - case IdCstNode.Error e -> new IdCstNode.Error( - e.id(), span, e.skippedText(), e.expected(), e.leadingTrivia(), e.trailingTrivia()); - case IdCstNode.NonTerminal nt -> { - var shifted = new ArrayList(nt.children().size()); - for (var child : nt.children()) { - shifted.add(shiftAll(child, delta)); - } - yield new IdCstNode.NonTerminal( - nt.id(), span, nt.rule(), List.copyOf(shifted), - nt.leadingTrivia(), nt.trailingTrivia()); - } - }; - } - - private static SourceSpan shiftSpan(SourceSpan span, int delta) { - return new SourceSpan( - span.startLine(), span.startColumn(), span.startOffset() + delta, - span.endLine(), span.endColumn(), span.endOffset() + delta); - } -} diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java deleted file mode 100644 index 149260d..0000000 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/MidBufferTreeBuilder.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.pragmatica.peg.incremental.bench; - -import org.pragmatica.peg.incremental.experimental.IdCstNode; -import org.pragmatica.peg.incremental.experimental.IdGenerator; -import org.pragmatica.peg.incremental.experimental.OffsetDecoupledNode; -import org.pragmatica.peg.incremental.experimental.SpanIndex; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.ArrayList; -import java.util.List; - -/** - * Synthesizes a "long method body" tree shape for {@link MidBufferBench} — - * a single root with N sibling children, mimicking a long sequence of - * statement nodes in a Java method body. Mid-buffer edits replace the - * middle child, exposing the right-of-edit shift cost that - * {@code TreeSplicer.spliceAndShift} pays in production. - * - *

Each child has a span of width 10, so child {@code i} spans - * {@code [i*10, i*10 + 10)}. The mid-buffer edit point is index N/2. - * - *

The shape is intentionally unbalanced — flat with high fan-out — to - * stress the right-of-edit path. The Phase 0 spike used a balanced 4-ary - * tree where the splice depth-3 found the pivot near the leaves before - * many right siblings; this is why the 67× speedup didn't translate to - * mid-buffer edits in real source. - * - *

Sandbox-only; not referenced by {@code peglib-core}. - * - * @since 0.5.0 - */ -final class MidBufferTreeBuilder { - - static final int CHILD_WIDTH = 10; - private static final List NO_TRIVIA = List.of(); - - private MidBufferTreeBuilder() {} - - /** - * Build an {@link IdCstNode} tree: Root with {@code childCount} terminal - * children, child {@code i} at offsets {@code [i*10, i*10+10)}. - */ - static IdCstNode buildIdTree(int childCount, IdGenerator idGen) { - var children = new ArrayList(childCount); - for (int i = 0; i < childCount; i++) { - int start = i * CHILD_WIDTH; - int end = start + CHILD_WIDTH; - var span = new SourceSpan(1, 1, start, 1, 1, end); - children.add(new IdCstNode.Terminal(idGen.next(), span, "Stmt", "x", NO_TRIVIA, NO_TRIVIA)); - } - var rootSpan = new SourceSpan(1, 1, 0, 1, 1, childCount * CHILD_WIDTH); - return new IdCstNode.NonTerminal(idGen.next(), rootSpan, "Body", List.copyOf(children), NO_TRIVIA, NO_TRIVIA); - } - - /** - * Build an {@link OffsetDecoupledNode} tree with the same shape, plus - * a populated {@link SpanIndex}. - */ - static OffsetDecoupledTree buildOffsetDecoupledTree(int childCount, IdGenerator idGen) { - var spans = new SpanIndex(childCount * 2); - var children = new ArrayList(childCount); - for (int i = 0; i < childCount; i++) { - int start = i * CHILD_WIDTH; - int end = start + CHILD_WIDTH; - long id = idGen.next(); - children.add(new OffsetDecoupledNode.Terminal(id, "Stmt", "x", NO_TRIVIA, NO_TRIVIA)); - spans.put(id, start, end); - } - long rootId = idGen.next(); - var root = new OffsetDecoupledNode.NonTerminal(rootId, "Body", List.copyOf(children), NO_TRIVIA, NO_TRIVIA); - spans.put(rootId, 0, childCount * CHILD_WIDTH); - return new OffsetDecoupledTree(root, spans); - } - - record OffsetDecoupledTree(OffsetDecoupledNode root, SpanIndex spans) {} -} diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java deleted file mode 100644 index 3de79bd..0000000 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/PathDBench.java +++ /dev/null @@ -1,182 +0,0 @@ -package org.pragmatica.peg.incremental.bench; - -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; -import org.pragmatica.peg.incremental.experimental.IdCstNode; -import org.pragmatica.peg.incremental.experimental.IdGenerator; -import org.pragmatica.peg.incremental.experimental.IdNodeIndex; -import org.pragmatica.peg.incremental.experimental.IdTreeSplicer; -import org.pragmatica.peg.incremental.experimental.StableIdNodeIndex; -import org.pragmatica.peg.incremental.experimental.StableIdSplicer; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -/** - * Path D — perf-gate JMH bench. Three-way comparison on the same flat-tree - * mid-buffer-splice shape used by {@link MidBufferBench}, isolating where the - * Path D win comes from. - * - *

Three arms

- * - *
    - *
  • productionStyle: original spec §2 algorithm. Fresh - * ancestor IDs (via {@link IdTreeSplicer}) and full - * {@link IdNodeIndex#applyIncremental} including step 3 - * sibling-rewire. The baseline. - *
  • stableIdNoOpt: stable ancestor IDs (via - * {@link StableIdSplicer}) but unoptimized - * {@link IdNodeIndex#applyIncremental}. Measures whether the splicer - * change alone pays off — it shouldn't, because step 3 still walks all - * direct children of the new (stable-ID) root and rewrites their parent - * links to a value that's already correct. - *
  • pathD: stable IDs + optimized - * {@link StableIdNodeIndex#applyIncremental}. The actual O(δ) - * algorithm — skips ancestor-removal and sibling-rewire entirely. - *
- * - *

Tree shape & edit

- * - *

Identical to {@link MidBufferBench}: single root with N terminal children; - * splice replaces the child at index N/2 with a single fresh terminal. - * - *

Perf gate

- * - *

Path D passes the gate iff {@code productionStyle / pathD ≥ 5} at tree - * size ≥ 1000. NO-GO falls back to investigating further architectural - * changes (lazy parent-link computation, persistent map structures, etc). - * - *

How to run

- *
{@code
- *   mvn -pl peglib-incremental -am -Pbench -DskipTests package
- *   java -jar peglib-incremental/target/benchmarks.jar PathDBench \
- *     -rf json -rff peglib-incremental/target/path-d.json -i 3 -wi 2 -f 1
- * }
- * - * @since 0.5.0 - */ -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(TimeUnit.MICROSECONDS) -@Warmup(iterations = 3, time = 1) -@Measurement(iterations = 5, time = 2) -@Fork(2) -@State(Scope.Benchmark) -public class PathDBench { - - private static final List NO_TRIVIA = List.of(); - - @Param({"100", "1000", "10000"}) - private int treeSize; - - // --- Shared trial state (built once per @Param value) --- - private IdCstNode oldRoot; // fresh-ID-style baseline root (used by productionStyle + stableIdNoOpt — same tree, both splicers operate on it) - private IdCstNode oldTarget; // child being replaced - private List oldPath; // [oldRoot, oldTarget] - private IdGenerator idGenProd; - private IdGenerator idGenStable; - - // Pre-built splice results — productionStyle (fresh ancestor IDs). - private IdCstNode newRoot_freshIds; - private List newPath_freshIds; - - // Pre-built splice results — stable ancestor IDs. - private IdCstNode newRoot_stableIds; - private List newPath_stableIds; - - // Per-invocation indices (rebuilt fresh because applyIncremental mutates). - private IdNodeIndex prodIndex; - private IdNodeIndex stableNoOptIndex; - private StableIdNodeIndex pathDIndex; - - @Setup(Level.Trial) - public void buildTrees() { - idGenProd = new IdGenerator.PerSessionCounter(); - oldRoot = MidBufferTreeBuilder.buildIdTree(treeSize, idGenProd); - var oldChildren = ((IdCstNode.NonTerminal) oldRoot).children(); - int midIndex = treeSize / 2; - oldTarget = oldChildren.get(midIndex); - oldPath = List.of(oldRoot, oldTarget); - - // Pre-build the new pivot once. Same span/text for both arms. - int targetStart = midIndex * MidBufferTreeBuilder.CHILD_WIDTH; - var newPivotSpan = new SourceSpan(1, 1, targetStart, 1, 1, targetStart + 13); - - // productionStyle splicer (fresh ancestor IDs). Pre-compute the splice - // result once — the bench measures only applyIncremental cost. - IdCstNode newPivotProd = new IdCstNode.Terminal( - idGenProd.next(), newPivotSpan, "Stmt", "yyy", NO_TRIVIA, NO_TRIVIA); - var freshSplicer = new IdTreeSplicer(idGenProd); - var freshResult = freshSplicer.splice(oldPath, newPivotProd); - newRoot_freshIds = freshResult.newRoot(); - newPath_freshIds = freshResult.newPath(); - - // stable-ID splicer. Use a separate generator so ID streams don't - // interleave; same target span. - idGenStable = new IdGenerator.PerSessionCounter(); - // Re-prime idGenStable past the existing tree's IDs to avoid collision - // when StableIdSplicer is asked to allocate (it doesn't, in current - // impl, but defensive in case future variants do). - for (int i = 0; i <= treeSize + 2; i++) { - idGenStable.next(); - } - IdCstNode newPivotStable = new IdCstNode.Terminal( - idGenStable.next(), newPivotSpan, "Stmt", "yyy", NO_TRIVIA, NO_TRIVIA); - var stableSplicer = new StableIdSplicer(idGenStable); - var stableResult = stableSplicer.splice(oldPath, newPivotStable); - newRoot_stableIds = stableResult.newRoot(); - newPath_stableIds = stableResult.newPath(); - } - - /** - * Per-invocation: rebuild all three arms' indices, because - * {@code applyIncremental} mutates the receiver's parents map. Build cost - * is identical across arms ({@code O(N)} on the same tree); the bench - * measures the differential cost of the {@code applyIncremental} call - * itself. - */ - @Setup(Level.Invocation) - public void rebuildIndices() { - prodIndex = IdNodeIndex.build(oldRoot); - stableNoOptIndex = IdNodeIndex.build(oldRoot); - pathDIndex = StableIdNodeIndex.build(oldRoot); - } - - /** - * Original spec §2 algorithm: fresh ancestor IDs + step-3 sibling rewire. - */ - @Benchmark - public IdNodeIndex productionStyle() { - return prodIndex.applyIncremental(newRoot_freshIds, oldPath, newPath_freshIds); - } - - /** - * Stable IDs but unoptimized applyIncremental — measures whether the - * splicer change alone is sufficient. (Expectation: no — step 3 still - * walks every direct child.) - */ - @Benchmark - public IdNodeIndex stableIdNoOpt() { - return stableNoOptIndex.applyIncremental(newRoot_stableIds, oldPath, newPath_stableIds); - } - - /** - * Path D — stable IDs + optimized applyIncremental. The actual O(δ) - * algorithm. - */ - @Benchmark - public StableIdNodeIndex pathD() { - return pathDIndex.applyIncremental(newRoot_stableIds, oldPath, newPath_stableIds); - } -} diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java deleted file mode 100644 index 2b1c193..0000000 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/Phase0SpikeBench.java +++ /dev/null @@ -1,146 +0,0 @@ -package org.pragmatica.peg.incremental.bench; - -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; -import org.pragmatica.peg.incremental.experimental.IdCstNode; -import org.pragmatica.peg.incremental.experimental.IdGenerator; -import org.pragmatica.peg.incremental.experimental.IdNodeIndex; -import org.pragmatica.peg.incremental.experimental.IdTreeSplicer; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -/** - * Phase 0d.2 — perf-gate JMH bench for the v0.5.0 architectural rework - * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §6 Phase 0 and §9 - * perf targets). - * - *

Central perf claim under test: - * {@link IdNodeIndex#applyIncremental} is asymptotically {@code O(δ)} — - * specifically {@code O(splicedSize + depth × branching)} — and therefore - * ≥5× faster than the {@code O(N)} {@link IdNodeIndex#build} at - * representative tree sizes (≥1000 nodes). The Phase 0 GO/NO-GO gate fails - * if this benchmark reports less than 5× speedup at the larger sizes; the - * spec explicitly accommodates a NO-GO outcome. - * - *

Bench design

- * - *

For each parameterized {@code treeSize}, build a balanced 4-ary tree - * (mirroring typical PEG CST fan-out) and splice a small replacement subtree - * in at depth 3. Both benchmarks operate on the SAME post-edit tree: - * - *

    - *
  • {@link #incrementalUpdate} — measures the full - * {@link IdNodeIndex#applyIncremental} cost (steps 1-3: remove dead + - * insert spliced + rewire siblings). Pre-built {@code oldIndex} is - * recreated per invocation because {@code applyIncremental} mutates the - * receiver's parent map per Phase 0c semantics. - *
  • {@link #fullRebuild} — measures {@link IdNodeIndex#build} on the new - * root: the comparison floor representing "rebuild after edit" - * (≈ what 0.4.3 does today, modulo IdCstNode + LongLongMap constant - * factors). - *
- * - *

The {@code Level.Invocation} setup penalty (~tens of ns to allocate a - * fresh {@code IdNodeIndex}) is well below the bench timings (μs range) and - * is documented as acceptable by JMH for this class of bench. - * - *

Caveats

- * - *
    - *
  • Synthetic balanced tree, branching 4. Real CST topology is irregular; - * perf may differ on the 1900-LOC fixture (Phase 1 deliverable). - *
  • Depth-3 splice with a leaf-sized pivot. Worse-case (deep splice with - * large pivot) is not in this bench. - *
  • {@link IdNodeIndex#applyIncremental} mutates the receiver. Production - * v0.5.0 will likely use a persistent map to recover snapshot semantics - * — that adds cost not measured here. - *
- * - *

How to run

- *
{@code
- *   mvn -pl peglib-incremental -am -Pbench -DskipTests package
- *   java -jar peglib-incremental/target/benchmarks.jar Phase0SpikeBench \
- *     -rf json -rff phase0-spike.json -i 5 -wi 3 -f 2
- * }
- * - * @since 0.5.0 - */ -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(TimeUnit.MICROSECONDS) -@Warmup(iterations = 3, time = 1) -@Measurement(iterations = 5, time = 2) -@Fork(2) -@State(Scope.Benchmark) -public class Phase0SpikeBench { - - /** Splice depth — depth 3 puts the pivot at a representative interior. */ - private static final int SPLICE_DEPTH = 3; - /** Branching factor (typical PEG CST fan-out). */ - private static final int BRANCHING = 4; - /** Pivot subtree size — leaf. The "small token edit" case in spec §9. */ - private static final int PIVOT_DEPTH = 0; - - @Param({"100", "1000", "10000"}) - private int treeSize; - - private IdCstNode newRoot; - private List oldPath; - private List newPath; - private IdNodeIndex oldIndex; - - // Held across invocations so we can rebuild oldIndex per invocation - // without re-running the splicer. - private IdCstNode oldRoot; - - @Setup(Level.Trial) - public void buildTrees() { - var gen = new IdGenerator.PerSessionCounter(); - oldRoot = SyntheticTreeBuilder.buildBalanced(treeSize, BRANCHING, gen); - var actualOldPath = SyntheticTreeBuilder.findPathAtDepth(oldRoot, SPLICE_DEPTH); - var pivot = SyntheticTreeBuilder.buildPivot(PIVOT_DEPTH, BRANCHING, gen); - - var splicer = new IdTreeSplicer(gen); - var spliceResult = splicer.splice(actualOldPath, pivot); - - oldPath = actualOldPath; - newRoot = spliceResult.newRoot(); - newPath = spliceResult.newPath(); - } - - /** - * Rebuilt fresh per invocation: {@link IdNodeIndex#applyIncremental} - * mutates the receiver's parent map. Re-running the bench against an - * already-mutated index would measure no-op behaviour, not the algorithm - * under test. - * - *

JMH's {@code Level.Invocation} adds ~tens of ns of overhead; the - * bench timings are in μs. Acceptable for a spike. - */ - @Setup(Level.Invocation) - public void rebuildOldIndex() { - oldIndex = IdNodeIndex.build(oldRoot); - } - - /** The 0.5.0 GO target: O(splicedSize + depth × branching). */ - @Benchmark - public IdNodeIndex incrementalUpdate() { - return oldIndex.applyIncremental(newRoot, oldPath, newPath); - } - - /** The "if NO-GO, rebuild from scratch" floor: O(N). */ - @Benchmark - public IdNodeIndex fullRebuild() { - return IdNodeIndex.build(newRoot); - } -} diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java deleted file mode 100644 index 7cf0a7e..0000000 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/SyntheticTreeBuilder.java +++ /dev/null @@ -1,134 +0,0 @@ -package org.pragmatica.peg.incremental.bench; - -import org.pragmatica.peg.incremental.experimental.IdCstNode; -import org.pragmatica.peg.incremental.experimental.IdGenerator; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.ArrayList; -import java.util.List; - -/** - * Synthesizes balanced {@link IdCstNode} trees for the Phase 0 perf-gate JMH - * spike (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §6 Phase 0, - * §9 perf targets, and {@link Phase0SpikeBench}). - * - *

The builder produces a balanced tree of approximately {@code targetSize} - * nodes with the supplied branching factor — branching 4 mirrors the typical - * fan-out of a real PEG CST. Leaves are {@link IdCstNode.Terminal}; interior - * nodes are {@link IdCstNode.NonTerminal} with rule names cycled from a small - * pool ({@code "Block"}, {@code "Stmt"}, {@code "Expr"}, {@code "Decl"}). - * - *

This class is only used from the JMH source tree and is sandbox-only; - * it is not referenced by {@code peglib-core} and will be deleted at the - * Phase 0 GO/NO-GO gate. - * - * @since 0.5.0 - */ -final class SyntheticTreeBuilder { - - private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); - private static final List NO_TRIVIA = List.of(); - private static final String[] RULES = {"Block", "Stmt", "Expr", "Decl"}; - - private SyntheticTreeBuilder() {} - - /** - * Build a balanced tree of approximately {@code targetSize} nodes with the - * given {@code branchingFactor}. - * - *

The tree is a perfect {@code branchingFactor}-ary tree: depth is - * chosen as {@code ceil(log_b(targetSize))}, then the bottom level is - * truncated so the total node count is the largest perfect tree - * {@code <= targetSize × 1.2}. Exact sizing is not critical for a - * spike bench — what matters is that depth and branching are realistic. - */ - static IdCstNode buildBalanced(int targetSize, int branchingFactor, IdGenerator idGen) { - if (targetSize < 1) { - throw new IllegalArgumentException("targetSize must be >= 1"); - } - if (branchingFactor < 2) { - throw new IllegalArgumentException("branchingFactor must be >= 2"); - } - // Compute depth d so that branchingFactor^d roughly matches targetSize. - // For a balanced tree: total nodes = (b^(d+1) - 1) / (b - 1). - int depth = 0; - long total = 1; - long levelSize = 1; - while (total < targetSize) { - depth++; - levelSize *= branchingFactor; - total += levelSize; - } - return buildSubtree(depth, branchingFactor, idGen); - } - - /** - * Recursively build a perfect tree of given depth. Depth 0 → single - * {@link IdCstNode.Terminal} leaf. Depth > 0 → {@link IdCstNode.NonTerminal} - * with {@code branchingFactor} children of depth-1 subtrees. - * - *

Children are built first (post-order ID assignment, mirroring - * {@link org.pragmatica.peg.incremental.experimental.IdCstNodeBuilder}). - */ - private static IdCstNode buildSubtree(int depth, int branchingFactor, IdGenerator idGen) { - if (depth == 0) { - return new IdCstNode.Terminal(idGen.next(), SPAN, "Leaf", "x", NO_TRIVIA, NO_TRIVIA); - } - var children = new ArrayList(branchingFactor); - for (int i = 0; i < branchingFactor; i++) { - children.add(buildSubtree(depth - 1, branchingFactor, idGen)); - } - var rule = RULES[depth % RULES.length]; - return new IdCstNode.NonTerminal(idGen.next(), SPAN, rule, List.copyOf(children), NO_TRIVIA, NO_TRIVIA); - } - - /** - * Find a path from {@code root} to a node at exactly {@code targetDepth}. - * Walks the leftmost child at each level. Used to position the splice - * pivot at a representative interior depth. - * - *

Returns the inclusive {@code root → pivot} path. The list size equals - * {@code targetDepth + 1}. - * - * @throws IllegalArgumentException if the tree is not deep enough - */ - static List findPathAtDepth(IdCstNode root, int targetDepth) { - if (targetDepth < 0) { - throw new IllegalArgumentException("targetDepth must be >= 0"); - } - var path = new ArrayList(targetDepth + 1); - path.add(root); - var current = root; - for (int i = 0; i < targetDepth; i++) { - if (!(current instanceof IdCstNode.NonTerminal nt) || nt.children().isEmpty()) { - throw new IllegalArgumentException( - "tree not deep enough: requested depth " + targetDepth + ", reached level " + i); - } - current = nt.children().get(0); - path.add(current); - } - return List.copyOf(path); - } - - /** - * Build a small replacement subtree to splice in at the pivot. The pivot - * is a small balanced {@code branchingFactor}-ary tree of the requested - * depth, sharing IDs from the supplied generator (so it doesn't collide - * with the existing tree). - */ - static IdCstNode buildPivot(int pivotDepth, int branchingFactor, IdGenerator idGen) { - return buildSubtree(pivotDepth, branchingFactor, idGen); - } - - /** Count every node in the tree (terminals and non-terminals). */ - static int countAll(IdCstNode node) { - int c = 1; - if (node instanceof IdCstNode.NonTerminal nt) { - for (var child : nt.children()) { - c += countAll(child); - } - } - return c; - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java deleted file mode 100644 index 2588f66..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNode.java +++ /dev/null @@ -1,144 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.List; -import java.util.Objects; - -/** - * Sandbox CST node carrying a stable {@code long id}. - * - *

Phase 0b spike for the v0.5.0 architectural rework - * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A and §7 R1). - * Mirrors the production {@link org.pragmatica.peg.tree.CstNode} shape and - * reuses the production {@link SourceSpan} and {@link Trivia} types directly; - * the sole structural delta is a leading {@code long id} component on every - * variant. - * - *

Equality contract (R1 mitigation). Per spec §7 R1, IDs - * are metadata and must not participate in identity. Each variant's - * {@code equals} and {@code hashCode} compare structural fields only and - * exclude {@code id}. Two nodes of the same variant with identical structure - * but different IDs are equal and share a hash code; nodes of different - * variants are never equal even if their structural fields match. - * - *

This type is sandbox-only — it is not referenced by {@code peglib-core} - * and will be promoted, reshaped, or deleted at the Phase 0 GO/NO-GO gate. - * - * @since 0.5.0 - */ -public sealed interface IdCstNode { - /** Stable identifier within the owning {@code Session}'s lineage. */ - long id(); - - /** Source span covered by this node (excluding trivia). */ - SourceSpan span(); - - /** Rule name that produced this node. */ - String rule(); - - /** Trivia preceding this node. */ - List leadingTrivia(); - - /** Trivia following this node. */ - List trailingTrivia(); - - /** Terminal node — leaf that matched literal text. */ - record Terminal(long id, - SourceSpan span, - String rule, - String text, - List leadingTrivia, - List trailingTrivia) implements IdCstNode { - @Override - public boolean equals(Object other) { - return other instanceof Terminal that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(text, - that.text) && Objects.equals(leadingTrivia, - that.leadingTrivia) && Objects.equals(trailingTrivia, - that.trailingTrivia); - } - - @Override - public int hashCode() { - return Objects.hash(Terminal.class, span, rule, text, leadingTrivia, trailingTrivia); - } - } - - /** Non-terminal node — interior node with children. */ - record NonTerminal(long id, - SourceSpan span, - String rule, - List children, - List leadingTrivia, - List trailingTrivia) implements IdCstNode { - @Override - public boolean equals(Object other) { - return other instanceof NonTerminal that && Objects.equals(span, that.span) && Objects.equals(rule, - that.rule) && Objects.equals(children, - that.children) && Objects.equals(leadingTrivia, - that.leadingTrivia) && Objects.equals(trailingTrivia, - that.trailingTrivia); - } - - @Override - public int hashCode() { - return Objects.hash(NonTerminal.class, span, rule, children, leadingTrivia, trailingTrivia); - } - } - - /** - * Token node — result of the token boundary operator {@code < >}. - * Captures the matched text as a single unit. - */ - record Token(long id, - SourceSpan span, - String rule, - String text, - List leadingTrivia, - List trailingTrivia) implements IdCstNode { - @Override - public boolean equals(Object other) { - return other instanceof Token that && Objects.equals(span, that.span) && Objects.equals(rule, that.rule) && Objects.equals(text, - that.text) && Objects.equals(leadingTrivia, - that.leadingTrivia) && Objects.equals(trailingTrivia, - that.trailingTrivia); - } - - @Override - public int hashCode() { - return Objects.hash(Token.class, span, rule, text, leadingTrivia, trailingTrivia); - } - } - - /** - * Error node — unparseable input region during error recovery. - * Mirrors the production {@code CstNode.Error} shape; {@link #rule()} - * returns {@code ""}. - */ - record Error(long id, - SourceSpan span, - String skippedText, - String expected, - List leadingTrivia, - List trailingTrivia) implements IdCstNode { - @Override - public String rule() { - return ""; - } - - @Override - public boolean equals(Object other) { - return other instanceof Error that && Objects.equals(span, that.span) && Objects.equals(skippedText, - that.skippedText) && Objects.equals(expected, - that.expected) && Objects.equals(leadingTrivia, - that.leadingTrivia) && Objects.equals(trailingTrivia, - that.trailingTrivia); - } - - @Override - public int hashCode() { - return Objects.hash(Error.class, span, skippedText, expected, leadingTrivia, trailingTrivia); - } - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java deleted file mode 100644 index 41c9c44..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilder.java +++ /dev/null @@ -1,80 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.pragmatica.peg.tree.CstNode; - -import java.util.ArrayList; -import java.util.List; - -/** - * Walks a production {@link CstNode} tree and emits the corresponding - * {@link IdCstNode} tree, assigning a stable {@code long id} to every node - * via the supplied {@link IdGenerator}. - * - *

Phase 0b sandbox component for the v0.5.0 architectural rework - * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A). It exists - * to prove the ID-assignment plumbing without disturbing the production - * {@link CstNode} surface. - * - *

Order of assignment. Children are converted before - * their parents (post-order). The parent therefore always carries an ID - * strictly greater than any descendant's ID under - * {@link IdGenerator.PerSessionCounter}. The algorithm does not depend on - * this; it is a debugging convenience aligned with the spec phrase - * "ID assignment ... at construction". - * - *

Trivia handling. Trivia lists are copied by reference. - * The production tree treats them as immutable; preserving reference identity - * lets equality tests assert pre/post identity cheaply and avoids needless - * allocation. - * - * @since 0.5.0 - */ -public final class IdCstNodeBuilder { - private final IdGenerator idGen; - - public IdCstNodeBuilder(IdGenerator idGen) { - this.idGen = idGen; - } - - /** Convert {@code source} and every descendant into an {@link IdCstNode}. */ - public IdCstNode build(CstNode source) { - return switch (source) { - case CstNode.Terminal t -> buildTerminal(t); - case CstNode.Token t -> buildToken(t); - case CstNode.Error e -> buildError(e); - case CstNode.NonTerminal n -> buildNonTerminal(n); - }; - } - - private IdCstNode buildTerminal(CstNode.Terminal t) { - return new IdCstNode.Terminal(idGen.next(), t.span(), t.rule(), t.text(), t.leadingTrivia(), t.trailingTrivia()); - } - - private IdCstNode buildToken(CstNode.Token t) { - return new IdCstNode.Token(idGen.next(), t.span(), t.rule(), t.text(), t.leadingTrivia(), t.trailingTrivia()); - } - - private IdCstNode buildError(CstNode.Error e) { - return new IdCstNode.Error(idGen.next(), - e.span(), - e.skippedText(), - e.expected(), - e.leadingTrivia(), - e.trailingTrivia()); - } - - private IdCstNode buildNonTerminal(CstNode.NonTerminal n) { - // Post-order: convert children first so descendants get lower IDs. - var sourceChildren = n.children(); - var converted = new ArrayList(sourceChildren.size()); - for (CstNode child : sourceChildren) { - converted.add(build(child)); - } - return new IdCstNode.NonTerminal(idGen.next(), - n.span(), - n.rule(), - List.copyOf(converted), - n.leadingTrivia(), - n.trailingTrivia()); - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java deleted file mode 100644 index bd30b52..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdGenerator.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; -/** - * Source of stable {@code long} identifiers for CST nodes. - * - *

Phase 0 of the v0.5.0 incremental-native rework introduces stable per-node - * IDs (Lever A in {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2). Open - * question Q1 in §8 is resolved: the v0.5.0 strategy is a per-Session counter, - * not a process-global counter. Each {@link org.pragmatica.peg.incremental.Session} - * instantiates its own generator and the IDs it produces are unique only - * within that session's lineage. - * - *

The interface exists so that future strategies — a process-global - * {@link java.util.concurrent.atomic.AtomicLong}, content-derived hashes, or - * an external store — can swap in without disturbing call sites. Only - * {@link PerSessionCounter} is permitted today (YAGNI; add variants when a - * concrete need lands). - * - *

Implementations are not required to be thread-safe. - * {@link org.pragmatica.peg.incremental.Session} is single-threaded by design - * (per the {@code Session} Javadoc, concurrent edits against the same - * instance are undefined), so the parser engine never races on ID - * allocation. - * - * @since 0.5.0 - */ -public sealed interface IdGenerator permits IdGenerator.PerSessionCounter { - /** - * Produce the next ID. Successive calls on the same instance return - * strictly monotonically increasing values starting from 0. - */ - long next(); - - /** - * Single-threaded counter. The first call returns 0, then 1, 2, … - * - *

Overflow is theoretically possible after 2^63 calls but practically - * unreachable: at one ID per nanosecond it would take ~292 years to wrap. - * No overflow check is performed. - */ - final class PerSessionCounter implements IdGenerator { - private long next; - - public PerSessionCounter() { - this.next = 0L; - } - - @Override - public long next() { - return next++ ; - } - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java deleted file mode 100644 index 395cc66..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdNodeIndex.java +++ /dev/null @@ -1,278 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.pragmatica.lang.Option; - -import java.util.ArrayList; -import java.util.List; - -/** - * Stable-ID-keyed parent index for {@link IdCstNode} trees — Phase 0c sandbox. - * - *

Mirrors the production - * {@link org.pragmatica.peg.incremental.internal.NodeIndex} surface (parent - * lookup, presence test, root accessor) but keys the parent map by the stable - * {@code long id} carried on each {@link IdCstNode} record (per - * {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A) instead of JVM - * identity. This unlocks the central perf claim of the v0.5.0 rework: - * {@link #applyIncremental} is {@code O(splicedSize + depth × branchingFactor)}, - * not {@code O(N)}. - * - *

Two construction paths

- *
    - *
  • {@link #build(IdCstNode)} — initial full walk over a tree. {@code O(N)} - * in the node count. Used once per session at the initial parse. - *
  • {@link #applyIncremental(IdCstNode, List, List)} — splice-and-shift - * update producing a new index that reflects the post-edit tree. - * {@code O(oldPath.size() + oldPivotSize + newPivotSize + - * newPath.size() × branchingFactor)} — typically 100-300 map operations - * even on a 91k-node tree, vs the 91k operations a full rebuild would - * require. This is the hot path. - *
- * - *

Mutate-in-place / invalidate-on-incremental semantics

- * - *

{@link #applyIncremental} mutates the underlying parents map in - * place and returns a NEW {@link IdNodeIndex} sharing that same map - * with the receiver. The receiver becomes invalid after the call. - * Callers MUST use the returned instance and discard the receiver. Any - * subsequent observation of the receiver — including {@link #parentIdOf}, - * {@link #contains}, {@link #size}, {@link #root} — is undefined behaviour. - * - *

The reason: spike-grade perf measurement. Copying the {@link LongLongMap} - * on every incremental update would add an {@code O(N)} step that defeats the - * O(δ) cost claim we're trying to validate. Production v0.5.0 will likely - * use a persistent (path-copying) map to recover snapshot semantics — that - * design exploration is explicitly out of scope for Phase 0c. - * - *

This sandbox class is not referenced by {@code peglib-core} and will be - * promoted, reshaped, or deleted at the Phase 0 GO/NO-GO gate. - * - * @since 0.5.0 - */ -public final class IdNodeIndex { - private final IdCstNode root; - private final LongLongMap parents; - - /** - * Test hook — number of {@code parents.put} calls performed during the - * most recent {@link #applyIncremental} invocation that produced this - * index, or {@code -1} when this index was produced by {@link #build}. - * Package-private; used by Phase 0c microcount tests to validate the - * algorithm is O(δ), not O(N). Will be removed when the spike is - * promoted (test-only instrumentation). - */ - final int lastIncrementalPutCount; - - private IdNodeIndex(IdCstNode root, LongLongMap parents, int lastIncrementalPutCount) { - this.root = root; - this.parents = parents; - this.lastIncrementalPutCount = lastIncrementalPutCount; - } - - /** - * Build a fresh index over {@code root}. {@code O(N)} in the descendant - * count. Used only on the initial parse; subsequent edits should call - * {@link #applyIncremental} for {@code O(δ)} updates. - * - *

The backing {@link LongLongMap} is pre-sized to the exact descendant - * count to avoid resize churn during the build (mirrors the 0.4.3 - * {@code NodeIndex} pre-sizing fix; see {@code HANDOVER.md} §5). - */ - public static IdNodeIndex build(IdCstNode root) { - int expectedSize = countDescendants(root); - var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); - indexChildren(root, parents); - return new IdNodeIndex(root, parents, - 1); - } - - /** - * Splice-and-shift update: produce a new {@link IdNodeIndex} reflecting a - * post-edit tree. - * - *

Invalidates the receiver. See class Javadoc. Callers - * MUST use the returned instance and discard {@code this}. - * - * @param newRoot root of the post-edit tree - * @param oldPath {@code root → oldPivot} chain in the pre-edit tree, - * inclusive (size ≥ 1; first element is the pre-edit root, - * last element is the pre-edit pivot — the smallest node - * whose subtree was wholesale replaced) - * @param newPath {@code root → newPivot} chain in the post-edit tree, - * inclusive (size ≥ 1; first element is {@code newRoot}, - * last element is the post-edit pivot) - * - *

Algorithm (per spec §2)

- *
    - *
  1. Step 1 — Remove dead entries. Every node on the - * old path was replaced (their record identities are dead — the - * newPath nodes carry fresh IDs). For each {@code oldNode} in - * {@code oldPath} we remove its up-entry. Then we walk the old - * pivot's descendants (excluding the pivot itself, already removed - * in the previous loop) and remove their up-entries — the old - * pivot's subtree is wholesale replaced, so none of its descendants - * survive in the new tree by ID. - * Cost: {@code O(oldPath.size() + oldPivotSize)}. - *
  2. Step 2 — Insert new spliced subtree. Walk the new - * pivot's subtree pre-order and {@code parents.put(child.id, - * parent.id)} for every parent-child pair. Wire the new pivot to - * its new parent (the second-to-last entry in {@code newPath}) when - * the pivot is not itself the root. - * Cost: {@code O(newPivotSize)}. - *
  3. Step 3 — Walk new ancestor chain top-down. For - * each new ancestor on {@code newPath} except the pivot itself, set - * parent links for ALL its direct children. This catches sibling - * subtrees that are record-shared with the old tree (same internal - * IDs survive) but whose parent in the new tree has a fresh ID - * (different from the old ancestor's ID). Their internal subtrees - * are NOT walked — the {@code parents} entries inside those shared - * subtrees were created by an earlier {@link #build} or - * {@link #applyIncremental} and remain correct because the IDs - * there are unchanged. - * Cost: {@code O(newPath.size() × branchingFactor)}. - *
- * - *

Total cost: O(splicedSize + depth × branchingFactor). - * On the 1900-LOC Java fixture (≈10k nodes, depth ≈ 30, branching ≈ 4), - * a single-token edit produces ≈100-300 map operations vs ≈10k for full - * rebuild — the central perf claim of the v0.5.0 rework. - * - *

Resolved spec ambiguities

- *
    - *
  • Step 1 scope of {@code oldPath} removal: spec - * reads "Walk oldPath … remove their entries". We interpret this as - * every node on the old path (including {@code oldRoot}), - * because even when {@code oldRoot.id == newRoot.id} structurally, - * a splice that touches the root produces a new {@link IdCstNode} - * record with a fresh id (the {@code IdCstNodeBuilder} assigns IDs - * fresh per build). Step 3 then re-establishes parent entries for - * the surviving siblings under the new ancestor chain. - *
  • Step 1 walk of pivot descendants: the spec is - * silent on whether the pivot's subtree must be cleared. We clear - * it (excluding the pivot itself, already removed) because the - * splice replaces the pivot's subtree wholesale; no - * subtree-internal record sharing is preserved across the splice - * boundary. Step 2 then inserts the new pivot's subtree. - *
- */ - public IdNodeIndex applyIncremental(IdCstNode newRoot, List oldPath, List newPath) { - if (oldPath == null || oldPath.isEmpty()) { - throw new IllegalArgumentException("oldPath must contain at least the old root"); - } - if (newPath == null || newPath.isEmpty()) { - throw new IllegalArgumentException("newPath must contain at least the new root"); - } - var oldPivot = oldPath.get(oldPath.size() - 1); - var newPivot = newPath.get(newPath.size() - 1); - int putCount = 0; - // Step 1a — Remove every old-path node's up-entry (their records are dead). - for (var oldNode : oldPath) { - parents.remove(oldNode.id()); - } - // Step 1b — Remove the old pivot's descendants (subtree replaced wholesale). - var oldPivotDescendants = new ArrayList(); - flattenDescendantsInto(oldPivot, oldPivotDescendants); - for (var d : oldPivotDescendants) { - parents.remove(d.id()); - } - // Step 2 — Insert new pivot's subtree internal links. - indexChildren(newPivot, parents); - putCount += subtreeChildCount(newPivot); - // Wire pivot to its new parent (unless pivot is the new root). - if (newPath.size() >= 2) { - var newPivotParent = newPath.get(newPath.size() - 2); - parents.put(newPivot.id(), newPivotParent.id()); - putCount++ ; - } - // Step 3 — Walk new ancestor chain (excluding pivot — already wired in step 2). - // For each ancestor, set parent links for ALL its direct children. Sibling - // subtrees keep their internal entries (same IDs, still correct). - for (int i = 0; i < newPath.size() - 1; i++ ) { - var ancestor = newPath.get(i); - if (ancestor instanceof IdCstNode.NonTerminal nt) { - for (var child : nt.children()) { - parents.put(child.id(), ancestor.id()); - putCount++ ; - } - } - } - return new IdNodeIndex(newRoot, parents, putCount); - } - - /** Root of the CST this index reflects. */ - public IdCstNode root() { - return root; - } - - /** - * Parent ID of the node identified by {@code childId}, or - * {@link Option#none()} when {@code childId} is the root, absent from this - * index, or has been removed. - */ - public Option parentIdOf(long childId) { - if (!parents.containsKey(childId)) { - return Option.none(); - } - return Option.some(parents.get(childId)); - } - - /** - * {@code true} iff {@code id} is present as a key (i.e., the node has a - * parent in this index). Note: the root itself returns {@code false} - * because the root has no parent entry. - */ - public boolean contains(long id) { - return parents.containsKey(id); - } - - /** Number of parent entries — equals {@code descendantCount(root)}. */ - public int size() { - return parents.size(); - } - - // -- Helpers (mirror the production NodeIndex static helpers) -- - private static int countDescendants(IdCstNode node) { - int count = 0; - if (node instanceof IdCstNode.NonTerminal nt) { - for (var child : nt.children()) { - count += 1 + countDescendants(child); - } - } - return count; - } - - /** - * Pre-order recursive walk: for every parent-child pair under {@code node} - * (inclusive), {@code parents.put(child.id, parent.id)}. Mirrors the - * production {@code NodeIndex.indexChildren}. - */ - private static void indexChildren(IdCstNode node, LongLongMap parents) { - if (node instanceof IdCstNode.NonTerminal nt) { - for (var child : nt.children()) { - parents.put(child.id(), nt.id()); - indexChildren(child, parents); - } - } - } - - /** - * Pre-order: append every strict descendant of {@code node} to - * {@code out} (excludes {@code node} itself). Used in step 1 to enumerate - * the old pivot's subtree for removal. - */ - private static void flattenDescendantsInto(IdCstNode node, List out) { - if (node instanceof IdCstNode.NonTerminal nt) { - for (var child : nt.children()) { - out.add(child); - flattenDescendantsInto(child, out); - } - } - } - - /** - * Counts the number of {@code parents.put} calls {@link #indexChildren} - * will perform on {@code node} — equals the descendant count. - */ - private static int subtreeChildCount(IdCstNode node) { - return countDescendants(node); - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java deleted file mode 100644 index 18a89bf..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicer.java +++ /dev/null @@ -1,137 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import java.util.ArrayList; -import java.util.List; - -/** - * Hand-rolled sandbox splicer for {@link IdCstNode} trees — Phase 0d.1 spike - * for the v0.5.0 architectural rework - * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A and §8 Q3). - * - *

Given an {@code oldPath} (root → oldPivot, inclusive) and a freshly built - * {@code newPivot}, produces a new tree by replacing {@code oldPivot} with - * {@code newPivot} and rebuilding ONLY the ancestor chain. Every sibling - * subtree of every node on the splice path is reference-shared ({@code ==}) - * with the corresponding subtree in the input tree. - * - *

This is the GO/NO-GO gate for spec §8 Q3 — the "identity-preservation - * invariant" — without which {@link IdNodeIndex#applyIncremental} cannot - * achieve its O(δ) cost claim. (If sibling subtrees were re-allocated by the - * splicer, the index update would have to re-walk them, defeating the whole - * point.) - * - *

Algorithm

- * - *
    - *
  1. Walk {@code oldPath} from leaf upward. - *
  2. At each ancestor, locate the child slot by record identity ({@code ==}) - * to the next-deeper element of {@code oldPath}. - *
  3. Build a fresh {@link IdCstNode.NonTerminal} with new ID, copying the - * old children list verbatim except for the spliced slot — every other - * slot is reference-shared. - *
  4. Continue upward; the rebuilt ancestor becomes the splice target for - * the next level. - *
- * - *

Trivia lists are reference-shared (immutable). {@link org.pragmatica.peg.tree.SourceSpan} - * is reference-shared (immutable record). Only fresh allocations: one - * {@link IdCstNode.NonTerminal} per ancestor on the splice path, each with a - * new {@code ArrayList} for {@code children}. - * - *

This sandbox class is not referenced by {@code peglib-core} and will be - * promoted, reshaped, or deleted at the Phase 0 GO/NO-GO gate. - * - * @since 0.5.0 - */ -public final class IdTreeSplicer { - /** - * Result of a splice — the new root and the new path (root → newPivot, - * inclusive). The new path is parallel to {@code oldPath}: {@code newPath.get(i)} - * is the freshly allocated ancestor that replaces {@code oldPath.get(i)}. - */ - public record Result(IdCstNode newRoot, List newPath) {} - - private final IdGenerator idGen; - - public IdTreeSplicer(IdGenerator idGen) { - this.idGen = idGen; - } - - /** - * Splice {@code newPivot} into the tree by replacing the last element of - * {@code oldPath}. - * - * @param oldPath root → oldPivot, inclusive (size ≥ 1) - * @param newPivot the replacement subtree - * @return new root + new path; every sibling subtree of every splice-path - * node is reference-shared ({@code ==}) with the corresponding old - * subtree - * @throws IllegalArgumentException when {@code oldPath} is null or empty - * @throws IllegalStateException when an ancestor is not a {@code NonTerminal} - * or the child-identity link in {@code oldPath} is broken - */ - public Result splice(List oldPath, IdCstNode newPivot) { - if (oldPath == null || oldPath.isEmpty()) { - throw new IllegalArgumentException("oldPath must contain at least the old pivot"); - } - if (newPivot == null) { - throw new IllegalArgumentException("newPivot must not be null"); - } - // Pivot IS the root — no rebuild needed. - if (oldPath.size() == 1) { - return new Result(newPivot, List.of(newPivot)); - } - // Accumulator: builds newPath in REVERSE (leaf → root), reversed at end. - // We pre-size to oldPath.size() to avoid resize churn. - var reversedNewPath = new ArrayList(oldPath.size()); - reversedNewPath.add(newPivot); - var current = newPivot; - var oldChild = oldPath.get(oldPath.size() - 1); - for (int i = oldPath.size() - 2; i >= 0; i-- ) { - var oldAncestor = oldPath.get(i); - if (! (oldAncestor instanceof IdCstNode.NonTerminal nt)) { - throw new IllegalStateException( - "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); - } - var children = nt.children(); - int slot = indexOfByIdentity(children, oldChild); - if (slot < 0) { - throw new IllegalStateException( - "splice path broken at depth " + i + ": child " + oldChild + " not found in parent's children list"); - } - // Copy the children list, replacing only the spliced slot. - // CRITICAL: every other entry is the same reference — this is what - // preserves the identity invariant (spec §8 Q3). - var newChildren = new ArrayList(children.size()); - for (int k = 0; k < children.size(); k++ ) { - newChildren.add(k == slot - ? current - : children.get(k)); - } - var newAncestor = new IdCstNode.NonTerminal( - idGen.next(), nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia()); - reversedNewPath.add(newAncestor); - current = newAncestor; - oldChild = oldAncestor; - } - // Reverse to get root → newPivot order. - var newPath = new ArrayList(reversedNewPath.size()); - for (int i = reversedNewPath.size() - 1; i >= 0; i-- ) { - newPath.add(reversedNewPath.get(i)); - } - return new Result(current, List.copyOf(newPath)); - } - - /** - * Linear scan for a child by record identity ({@code ==}). Children lists - * are typically small (≤ 8); a linear scan is dominant and avoids hashing. - */ - private static int indexOfByIdentity(List children, IdCstNode target) { - for (int i = 0; i < children.size(); i++ ) { - if (children.get(i) == target) { - return i; - } - } - return - 1; - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java deleted file mode 100644 index ca58f55..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LinearProbingLongLongMap.java +++ /dev/null @@ -1,221 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; -/** - * Open-addressing, linear-probing implementation of {@link LongLongMap}. - * - *

Capacity is always a power of two so the slot index can be computed via a - * bitmask. Resize at load factor 0.75 doubles the table. - * - *

Slot occupancy is encoded as a single {@code byte[]} ({@link #EMPTY}, - * {@link #OCCUPIED}, {@link #TOMBSTONE}) rather than two parallel - * {@code boolean[]}s. This keeps each probe step to one byte fetch + - * branch instead of two array reads, and halves the metadata footprint - * (1 byte/slot vs 2 bytes/slot for two parallel boolean arrays — the JVM - * stores a {@code boolean[]} element as a byte). - * - *

Probing rules: - *

    - *
  • {@link #put}: stop at the first empty slot or matching key; the first - * tombstone seen on the probe is remembered and reused as the - * insertion target if the key is not already present further on. - *
  • {@link #get}, {@link #containsKey}, {@link #remove}: walk past - * tombstones, stop at empty. - *
- * - * @since 0.5.0 - */ -public final class LinearProbingLongLongMap implements LongLongMap { - /** Default backing-table capacity used by {@link #LinearProbingLongLongMap()}. */ - private static final int DEFAULT_CAPACITY = 16; - - private static final int MIN_CAPACITY = 4; - - /** Resize when {@code size > capacity * 0.75}. */ - private static final double LOAD_FACTOR = 0.75; - - private static final byte EMPTY = 0; - private static final byte OCCUPIED = 1; - private static final byte TOMBSTONE = 2; - - private long[] keys; - private long[] values; - private byte[] state; - private int size; - private int tombstones; - private int threshold; - private int mask; - - public LinearProbingLongLongMap() { - this(DEFAULT_CAPACITY); - } - - public LinearProbingLongLongMap(int initialCapacity) { - if (initialCapacity < 0) { - throw new IllegalArgumentException("initialCapacity must be non-negative: " + initialCapacity); - } - int capacity = roundUpToPowerOfTwo(Math.max(initialCapacity, MIN_CAPACITY)); - this.keys = new long[capacity]; - this.values = new long[capacity]; - this.state = new byte[capacity]; - this.size = 0; - this.tombstones = 0; - this.mask = capacity - 1; - this.threshold = (int)(capacity * LOAD_FACTOR); - } - - @Override - public void put(long key, long value) { - int slot = slotFor(key); - int firstTombstone = - 1; - while (true) { - byte s = state[slot]; - if (s == EMPTY) { - boolean reusedTombstone = firstTombstone >= 0; - int target = reusedTombstone - ? firstTombstone - : slot; - keys[target] = key; - values[target] = value; - state[target] = OCCUPIED; - size++; - if (reusedTombstone) { - tombstones--; - } - if (size > threshold) { - resize(state.length<< 1); - } else if (size + tombstones > threshold) { - // Tombstones alone are crowding the table — rehash at the - // current capacity to clear them and keep probe chains short. - // Without this, repeated put/remove cycles can saturate the - // table with TOMBSTONE+OCCUPIED slots and cause the put - // probe loop (which only stops at EMPTY) to spin forever. - resize(state.length); - } - return; - } - if (s == OCCUPIED && keys[slot] == key) { - values[slot] = value; - return; - } - if (s == TOMBSTONE && firstTombstone < 0) { - firstTombstone = slot; - } - slot = (slot + 1) & mask; - } - } - - @Override - public long get(long key) { - int slot = slotFor(key); - while (true) { - byte s = state[slot]; - if (s == EMPTY) { - return MISSING; - } - if (s == OCCUPIED && keys[slot] == key) { - return values[slot]; - } - slot = (slot + 1) & mask; - } - } - - @Override - public boolean containsKey(long key) { - int slot = slotFor(key); - while (true) { - byte s = state[slot]; - if (s == EMPTY) { - return false; - } - if (s == OCCUPIED && keys[slot] == key) { - return true; - } - slot = (slot + 1) & mask; - } - } - - @Override - public void remove(long key) { - int slot = slotFor(key); - while (true) { - byte s = state[slot]; - if (s == EMPTY) { - return; - } - if (s == OCCUPIED && keys[slot] == key) { - state[slot] = TOMBSTONE; - size--; - tombstones++; - return; - } - slot = (slot + 1) & mask; - } - } - - @Override - public int size() { - return size; - } - - @Override - public void clear() { - java.util.Arrays.fill(state, EMPTY); - size = 0; - tombstones = 0; - } - - @Override - public LongLongMap copy() { - var copy = new LinearProbingLongLongMap(state.length); - // Bypass the rounding/threshold dance — same capacity by construction. - System.arraycopy(this.keys, 0, copy.keys, 0, this.keys.length); - System.arraycopy(this.values, 0, copy.values, 0, this.values.length); - System.arraycopy(this.state, 0, copy.state, 0, this.state.length); - copy.size = this.size; - copy.tombstones = this.tombstones; - return copy; - } - - @Override - public void forEachEntry(EntryVisitor visitor) { - for (int i = 0; i < state.length; i++) { - if (state[i] == OCCUPIED) { - long oldValue = values[i]; - long newValue = visitor.visit(keys[i], oldValue); - if (newValue != oldValue) { - values[i] = newValue; - } - } - } - } - - private int slotFor(long key) { - return Long.hashCode(key) & mask; - } - - private void resize(int newCapacity) { - long[] oldKeys = this.keys; - long[] oldValues = this.values; - byte[] oldState = this.state; - this.keys = new long[newCapacity]; - this.values = new long[newCapacity]; - this.state = new byte[newCapacity]; - this.mask = newCapacity - 1; - this.threshold = (int)(newCapacity * LOAD_FACTOR); - this.size = 0; - this.tombstones = 0; - for (int i = 0; i < oldState.length; i++) { - if (oldState[i] == OCCUPIED) { - put(oldKeys[i], oldValues[i]); - } - } - } - - private static int roundUpToPowerOfTwo(int value) { - // Smallest power of two >= value, with a sane lower bound. - int n = MIN_CAPACITY; - while (n < value) { - n <<= 1; - } - return n; - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java deleted file mode 100644 index 309515f..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/LongLongMap.java +++ /dev/null @@ -1,91 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; -/** - * Primitive-keyed, primitive-valued map ({@code long → long}) used as the - * backing store for the v0.5.0 {@code NodeIndex} (per - * {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A). - * - *

Open question Q2 in §8 is resolved: hand-rolled, no third-party - * collections dependency. Eclipse Collections would add ~10 MB of jar weight - * for a single data structure; boxed {@code Map} eats too much - * per-entry overhead and pressures the GC under tight per-edit budgets. - * - *

Sentinel. {@link #MISSING} is returned by {@link #get} - * for absent keys. Callers must not store {@code Long.MIN_VALUE} as a value; - * the {@code NodeIndex} use case (mapping child-id → parent-id, where IDs - * come from {@link IdGenerator.PerSessionCounter} and start at 0) honours - * this naturally. - * - *

Thread-safety. Implementations are not thread-safe. - * - *

TODO(0.5.x): consider swapping {@link LinearProbingLongLongMap} for a - * funnel-hashing variant per Farach-Colton, Krapivin, Kuszmaul, - * "Optimal Bounds for Open Addressing Without Reordering" (2025). - * - * @since 0.5.0 - */ -public sealed interface LongLongMap permits LinearProbingLongLongMap { - /** - * Returned by {@link #get(long)} for keys that are not present in the - * map. Callers must never use {@code Long.MIN_VALUE} as a stored value. - */ - long MISSING = Long.MIN_VALUE; - - /** - * Insert or overwrite the value for {@code key}. {@link #size()} grows - * when {@code key} is new and stays the same on overwrite. - */ - void put(long key, long value); - - /** - * Value associated with {@code key}, or {@link #MISSING} when absent. - */ - long get(long key); - - /** {@code true} iff {@code key} is present. */ - boolean containsKey(long key); - - /** - * Remove the entry for {@code key} if present. No-op when absent. - */ - void remove(long key); - - /** Number of live entries. */ - int size(); - - /** Discard every entry. Capacity is preserved by the implementation. */ - void clear(); - - /** - * Independent deep copy. Subsequent mutations on this map or the copy - * do not affect the other. Required for snapshot/rollback paths in - * future {@code TreeSplicer} work. - */ - LongLongMap copy(); - - /** - * Visit every live (occupied, non-tombstone) entry exactly once. Iteration - * order is implementation-defined and not stable across mutations. Callers - * MUST NOT mutate the map (put/remove) during iteration; the visitor may - * read other entries via {@link #get}/{@link #containsKey} but writes are - * unsupported and may corrupt the table. - * - *

Added in Phase 1.0/1.1 to support {@link SpanIndex#shift}, which needs - * to rewrite values in place when used with the packed-long encoding. This - * is additive: existing call sites are unaffected. - */ - void forEachEntry(EntryVisitor visitor); - - /** Visitor for {@link #forEachEntry}. */ - @FunctionalInterface - interface EntryVisitor { - /** - * Receive one entry. Return the new value to write into the slot, or - * the same value to leave the entry untouched. Implementations may - * fast-path "no change" by reference comparison on the boxed value — - * but for the {@code long} signature here, the fast path is a numeric - * equality test and the implementation rewrites unconditionally - * (cheap on a primitive {@code long[]}). - */ - long visit(long key, long value); - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java deleted file mode 100644 index 1327b3f..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNode.java +++ /dev/null @@ -1,131 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.pragmatica.peg.tree.Trivia; - -import java.util.List; -import java.util.Objects; - -/** - * Path-A spike CST node — carries a stable {@code long id} but does NOT - * carry a {@link org.pragmatica.peg.tree.SourceSpan}. Spans live externally - * in a {@link SpanIndex} keyed by id. - * - *

This is the design fork of the v0.5.0 architectural rework - * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 + the path-A blocker - * resolution). Mirrors {@link IdCstNode} structurally but with the - * {@code SourceSpan span} component removed from every variant. Callers - * resolve offsets via {@code spanIndex.startOffset(node.id())} instead of - * {@code node.span().startOffset()}. - * - *

Why decouple offsets

- * - *

{@code TreeSplicer.spliceAndShift} (production) deep-copies every sibling - * subtree right of an edit because the offset shift requires rewriting the - * embedded {@code SourceSpan} on every record. With offsets external, the - * splicer reference-shares both flanking subtrees and the shift becomes a - * single in-place walk over the {@link SpanIndex} primitive array — see - * {@link OffsetDecoupledSplicer}. - * - *

Equality contract

- * - *

Per spec §7 R1, IDs are metadata and must not participate in identity. - * Each variant's {@code equals}/{@code hashCode} compares structural fields - * only and excludes {@code id}. Spans are also excluded from equality (they - * are not part of the record), which deviates from {@link IdCstNode}; this - * is intentional — equality on path-A nodes is purely structural, and a span - * comparator (via {@link SpanIndex}) must be applied explicitly when needed. - * - *

Trivia note

- * - *

Production {@link Trivia} still carries {@link org.pragmatica.peg.tree.SourceSpan} - * components. For this prove-out we leave Trivia as-is — decoupling Trivia - * spans from records is a separate scope (a future {@code TriviaIndex}). - * The bench operates on trees with empty trivia lists, so this does not - * affect the perf comparison. - * - *

Skipped variant

- * - *

{@code Error} is omitted: the bench fixture is synthetic and the - * production calculator/Java grammars used by the spike do not produce - * Error nodes. Adding it is mechanical if the prove-out goes GREEN and - * the design is migrated. - * - *

This sandbox class is not referenced by {@code peglib-core} and will - * be promoted, reshaped, or deleted at the Phase 1.0/1.1 GO/NO-GO gate. - * - * @since 0.5.0 - */ -public sealed interface OffsetDecoupledNode { - /** Stable identifier within the owning Session's lineage. */ - long id(); - - /** Rule name that produced this node. */ - String rule(); - - /** Trivia preceding this node (production type — still carries spans). */ - List leadingTrivia(); - - /** Trivia following this node (production type — still carries spans). */ - List trailingTrivia(); - - /** Terminal node — leaf that matched literal text. */ - record Terminal(long id, - String rule, - String text, - List leadingTrivia, - List trailingTrivia) implements OffsetDecoupledNode { - @Override - public boolean equals(Object other) { - return other instanceof Terminal that && Objects.equals(rule, that.rule) && Objects.equals(text, that.text) && Objects.equals(leadingTrivia, - that.leadingTrivia) && Objects.equals(trailingTrivia, - that.trailingTrivia); - } - - @Override - public int hashCode() { - return Objects.hash(Terminal.class, rule, text, leadingTrivia, trailingTrivia); - } - } - - /** Non-terminal node — interior node with children. */ - record NonTerminal(long id, - String rule, - List children, - List leadingTrivia, - List trailingTrivia) implements OffsetDecoupledNode { - @Override - public boolean equals(Object other) { - return other instanceof NonTerminal that && Objects.equals(rule, that.rule) && Objects.equals(children, - that.children) && Objects.equals(leadingTrivia, - that.leadingTrivia) && Objects.equals(trailingTrivia, - that.trailingTrivia); - } - - @Override - public int hashCode() { - return Objects.hash(NonTerminal.class, rule, children, leadingTrivia, trailingTrivia); - } - } - - /** - * Token node — result of the token boundary operator {@code < >}. - * Captures the matched text as a single unit. - */ - record Token(long id, - String rule, - String text, - List leadingTrivia, - List trailingTrivia) implements OffsetDecoupledNode { - @Override - public boolean equals(Object other) { - return other instanceof Token that && Objects.equals(rule, that.rule) && Objects.equals(text, that.text) && Objects.equals(leadingTrivia, - that.leadingTrivia) && Objects.equals(trailingTrivia, - that.trailingTrivia); - } - - @Override - public int hashCode() { - return Objects.hash(Token.class, rule, text, leadingTrivia, trailingTrivia); - } - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java deleted file mode 100644 index c4375d4..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeIndex.java +++ /dev/null @@ -1,137 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.pragmatica.lang.Option; - -import java.util.ArrayList; -import java.util.List; - -/** - * Path-A counterpart of {@link IdNodeIndex} — keyed parent index for - * {@link OffsetDecoupledNode} trees. - * - *

Identical algorithm to {@link IdNodeIndex} (build, applyIncremental). - * The only delta is the node type — we cannot reuse {@code IdNodeIndex} - * directly because {@link OffsetDecoupledNode} and {@link IdCstNode} are - * disjoint sealed hierarchies with no common parent. Java sealed-interface - * constraints make a structural-typing approach more invasive than a clone. - * - *

Mutate-in-place / invalidate-on-incremental semantics mirror - * {@link IdNodeIndex}: callers MUST use the returned instance after - * {@link #applyIncremental} and discard the receiver. - * - *

This sandbox class is not referenced by {@code peglib-core} and will - * be promoted, reshaped, or deleted at the Phase 1.0/1.1 GO/NO-GO gate. - * - * @since 0.5.0 - */ -public final class OffsetDecoupledNodeIndex { - private final OffsetDecoupledNode root; - private final LongLongMap parents; - - private OffsetDecoupledNodeIndex(OffsetDecoupledNode root, LongLongMap parents) { - this.root = root; - this.parents = parents; - } - - /** - * Build a fresh index over {@code root}. {@code O(N)} in the descendant - * count. Pre-sizes the backing map to avoid resize churn. - */ - public static OffsetDecoupledNodeIndex build(OffsetDecoupledNode root) { - int expectedSize = countDescendants(root); - var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); - indexChildren(root, parents); - return new OffsetDecoupledNodeIndex(root, parents); - } - - /** - * Splice-and-shift index update. Mirrors {@link IdNodeIndex#applyIncremental}. - */ - public OffsetDecoupledNodeIndex applyIncremental(OffsetDecoupledNode newRoot, - List oldPath, - List newPath) { - if (oldPath == null || oldPath.isEmpty()) { - throw new IllegalArgumentException("oldPath must contain at least the old root"); - } - if (newPath == null || newPath.isEmpty()) { - throw new IllegalArgumentException("newPath must contain at least the new root"); - } - var oldPivot = oldPath.get(oldPath.size() - 1); - var newPivot = newPath.get(newPath.size() - 1); - // Step 1a — Remove every old-path node's up-entry. - for (var oldNode : oldPath) { - parents.remove(oldNode.id()); - } - // Step 1b — Remove the old pivot's descendants. - var oldPivotDescendants = new ArrayList(); - flattenDescendantsInto(oldPivot, oldPivotDescendants); - for (var d : oldPivotDescendants) { - parents.remove(d.id()); - } - // Step 2 — Insert new pivot's subtree internal links. - indexChildren(newPivot, parents); - if (newPath.size() >= 2) { - var newPivotParent = newPath.get(newPath.size() - 2); - parents.put(newPivot.id(), newPivotParent.id()); - } - // Step 3 — Walk new ancestor chain top-down, set parent links for - // ALL direct children of each ancestor. - for (int i = 0; i < newPath.size() - 1; i++ ) { - var ancestor = newPath.get(i); - if (ancestor instanceof OffsetDecoupledNode.NonTerminal nt) { - for (var child : nt.children()) { - parents.put(child.id(), ancestor.id()); - } - } - } - return new OffsetDecoupledNodeIndex(newRoot, parents); - } - - public OffsetDecoupledNode root() { - return root; - } - - public Option parentIdOf(long childId) { - if (!parents.containsKey(childId)) { - return Option.none(); - } - return Option.some(parents.get(childId)); - } - - public boolean contains(long id) { - return parents.containsKey(id); - } - - public int size() { - return parents.size(); - } - - // -- Helpers -- - private static int countDescendants(OffsetDecoupledNode node) { - int count = 0; - if (node instanceof OffsetDecoupledNode.NonTerminal nt) { - for (var child : nt.children()) { - count += 1 + countDescendants(child); - } - } - return count; - } - - private static void indexChildren(OffsetDecoupledNode node, LongLongMap parents) { - if (node instanceof OffsetDecoupledNode.NonTerminal nt) { - for (var child : nt.children()) { - parents.put(child.id(), nt.id()); - indexChildren(child, parents); - } - } - } - - private static void flattenDescendantsInto(OffsetDecoupledNode node, List out) { - if (node instanceof OffsetDecoupledNode.NonTerminal nt) { - for (var child : nt.children()) { - out.add(child); - flattenDescendantsInto(child, out); - } - } - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java deleted file mode 100644 index 820d489..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicer.java +++ /dev/null @@ -1,185 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import java.util.ArrayList; -import java.util.List; - -/** - * Path-A splicer for the v0.5.0 architectural rework prove-out - * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 + the path-A - * blocker resolution). - * - *

Operates on {@link OffsetDecoupledNode} trees with offsets stored in - * a separate {@link SpanIndex}. Mirrors the semantics of production - * {@link org.pragmatica.peg.incremental.internal.TreeSplicer#spliceAndShift} - * — replace a pivot subtree, shift offsets right of the edit by {@code delta} - * — but with one critical difference: - * - *

    - *
  • Production: siblings right of the edit are deep-copied - * via {@code shiftAll} because every {@code CstNode} record carries an - * embedded {@code SourceSpan} that must be rewritten. - *
  • Path A (this class): siblings right of the edit are - * reference-shared. The offset shift is a single in-place walk over the - * primitive {@code long[]} backing the {@link SpanIndex}. - *
- * - *

This is the central correctness claim under test by - * {@code OffsetDecoupledSplicerTest#rightOfEditSiblingsPreserveIdentity}: even - * for siblings whose offsets need shifting, the record references survive - * the splice. The shift happens on the SpanIndex side. - * - *

Algorithm

- * - *
    - *
  1. Copy the receiver's {@link SpanIndex} so the receiver remains valid - * (bench fairness — mirrors the snapshot semantics production v0.5.0 - * will need). - *
  2. Apply {@link SpanIndex#shift}{@code (editEnd, delta)} on the new - * index — rewrites every entry whose {@code startOffset >= editEnd}. - * Caller is responsible for inserting {@link SpanIndex} entries for - * {@code newPivot}'s subtree BEFORE calling {@code splice}; the - * splicer only registers ancestor spans (next step). - *
  3. Walk {@code oldPath} from leaf to root, building one new - * {@link OffsetDecoupledNode.NonTerminal} per ancestor with the spliced - * child swapped and every other child slot reference-shared. For each - * new ancestor, insert its span into the new {@link SpanIndex}: the - * start equals the old ancestor's start (unchanged — the ancestor - * began before the edit by the enclosing-node invariant) and the end - * equals the old ancestor's end shifted by {@code delta} when the old - * end was {@code >= editEnd}, else unchanged. This mirrors - * {@code TreeSplicer.rebuildNonTerminal}. - *
- * - *

Cost

- * - *

{@code O(splicedSize + depth + spanIndex.size())} — the depth term is - * the ancestor rebuild, the spliced-size term is the new-pivot span - * registration (caller's responsibility, not in this class), and the - * {@code spanIndex.size()} term is the eager shift walk. The dominant - * cost shifts from {@code O(N)} record allocations (production) to - * {@code O(N)} primitive-long writes (path A) — same big-O class but a - * radically smaller constant factor and no GC pressure. - * - *

This sandbox class is not referenced by {@code peglib-core} and will - * be promoted, reshaped, or deleted at the Phase 1.0/1.1 GO/NO-GO gate. - * - * @since 0.5.0 - */ -public final class OffsetDecoupledSplicer { - /** - * Result of a splice — the new root, the new ancestor path - * ({@code root → newPivot}, inclusive), and the new (independent) - * span index. - */ - public record Result(OffsetDecoupledNode newRoot, - List newPath, - SpanIndex newSpans) {} - - private final IdGenerator idGen; - - public OffsetDecoupledSplicer(IdGenerator idGen) { - this.idGen = idGen; - } - - /** - * Splice {@code newPivot} into the tree at {@code oldPath}'s terminus and - * shift every span whose {@code startOffset >= editEnd} by {@code delta}. - * - *

Caller MUST register {@code newPivot}'s subtree spans into - * {@code oldSpans} (or in a way that survives the {@link SpanIndex#copy} - * below) BEFORE calling this method. The splicer registers ancestor - * spans only. - * - * @param oldSpans pre-edit span index. Copied; the receiver is left valid. - * @param oldPath {@code root → oldPivot} chain in the pre-edit tree - * (size ≥ 1, last element is the pivot to replace). - * @param newPivot the replacement subtree. - * @param editEnd the offset at or after which spans should shift. - * @param delta the offset delta to apply. - */ - public Result splice(SpanIndex oldSpans, - List oldPath, - OffsetDecoupledNode newPivot, - int editEnd, - int delta) { - if (oldPath == null || oldPath.isEmpty()) { - throw new IllegalArgumentException("oldPath must contain at least the old pivot"); - } - if (newPivot == null) { - throw new IllegalArgumentException("newPivot must not be null"); - } - if (oldSpans == null) { - throw new IllegalArgumentException("oldSpans must not be null"); - } - // Step 1 — Independent span index for the post-edit tree. - var newSpans = oldSpans.copy(); - // Step 2 — Eager shift on the new span index. - newSpans.shift(editEnd, delta); - // Pivot IS the root: no ancestor rebuild. The pivot's spans must - // already be registered in oldSpans by the caller. - if (oldPath.size() == 1) { - return new Result(newPivot, List.of(newPivot), newSpans); - } - // Step 3 — Rebuild the ancestor chain leaf-to-root, splicing - // newPivot in and reference-sharing every sibling slot. - var reversedNewPath = new ArrayList(oldPath.size()); - reversedNewPath.add(newPivot); - OffsetDecoupledNode current = newPivot; - OffsetDecoupledNode oldChild = oldPath.get(oldPath.size() - 1); - for (int i = oldPath.size() - 2; i >= 0; i-- ) { - var oldAncestor = oldPath.get(i); - if (! (oldAncestor instanceof OffsetDecoupledNode.NonTerminal nt)) { - throw new IllegalStateException( - "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); - } - var children = nt.children(); - int slot = indexOfByIdentity(children, oldChild); - if (slot < 0) { - throw new IllegalStateException( - "splice path broken at depth " + i + ": child " + oldChild + " not found in parent's children list"); - } - // Reference-share every sibling slot — both LEFT AND RIGHT of - // the edit. This is the path-A invariant: siblings right of the - // edit don't need rebuilding because their offsets live in - // newSpans, which we already shifted. - var newChildren = new ArrayList(children.size()); - for (int k = 0; k < children.size(); k++ ) { - newChildren.add(k == slot - ? current - : children.get(k)); - } - long newAncestorId = idGen.next(); - var newAncestor = new OffsetDecoupledNode.NonTerminal( - newAncestorId, nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia()); - // Compute the new ancestor's span: - // start = old start (unchanged — ancestor began before the edit) - // end = old end + delta if old end >= editEnd, else old end - // Mirrors TreeSplicer.rebuildNonTerminal. - int oldStart = oldSpans.startOffset(oldAncestor.id()); - int oldEnd = oldSpans.endOffset(oldAncestor.id()); - int newEnd = oldEnd >= editEnd - ? oldEnd + delta - : oldEnd; - newSpans.put(newAncestorId, oldStart, newEnd); - reversedNewPath.add(newAncestor); - current = newAncestor; - oldChild = oldAncestor; - } - // Reverse to get root → newPivot order. - var newPath = new ArrayList(reversedNewPath.size()); - for (int i = reversedNewPath.size() - 1; i >= 0; i-- ) { - newPath.add(reversedNewPath.get(i)); - } - return new Result(current, List.copyOf(newPath), newSpans); - } - - /** Linear scan for a child by record identity ({@code ==}). */ - private static int indexOfByIdentity(List children, OffsetDecoupledNode target) { - for (int i = 0; i < children.size(); i++ ) { - if (children.get(i) == target) { - return i; - } - } - return - 1; - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java deleted file mode 100644 index 9a7b35e..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/SpanIndex.java +++ /dev/null @@ -1,141 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; -/** - * External {@code id → (startOffset, endOffset)} index for the path-A spike of - * the v0.5.0 incremental-native rework - * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 + the path-A blocker - * resolution in HANDOVER §10/11). - * - *

Why this exists. Production {@link org.pragmatica.peg.tree.CstNode} - * carries a {@link org.pragmatica.peg.tree.SourceSpan} as a record component. - * Mid-buffer edits force {@code TreeSplicer.spliceAndShift} to deep-copy every - * sibling subtree right of the edit (~half the tree on a typical interior - * keystroke) just to rewrite offsets. Path A decouples offsets from the - * record, storing them in this {@code SpanIndex} keyed by the stable - * {@code long id} carried on each {@link OffsetDecoupledNode}. Edits become - * a single in-place walk over a primitive {@code long[]} (this class) instead - * of a recursive record rebuild. - * - *

Storage

- * - *

Backed by a {@link LinearProbingLongLongMap} keyed by node id. Values pack - * {@code (startOffset, endOffset)} into a single {@code long}: high 32 bits - * are {@code startOffset}, low 32 bits are {@code endOffset & 0xFFFFFFFFL}. - * This reuses the Phase 0a primitive-long-keyed map verbatim and avoids a - * second parallel-arrays implementation. - * - *

The packed encoding constrains offsets to fit in a signed 32-bit int, - * which matches {@link org.pragmatica.peg.tree.SourceSpan#startOffset()} - * production semantics; offsets up to ~2 GiB are representable. - * - *

Thread-safety

- * - *

Not thread-safe; mirrors {@link LongLongMap}. - * - * @since 0.5.0 - */ -public final class SpanIndex { - private final LongLongMap map; - - public SpanIndex(int initialCapacity) { - this.map = new LinearProbingLongLongMap(initialCapacity); - } - - private SpanIndex(LongLongMap map) { - this.map = map; - } - - /** Insert or overwrite the {@code (startOffset, endOffset)} for {@code nodeId}. */ - public void put(long nodeId, int startOffset, int endOffset) { - map.put(nodeId, pack(startOffset, endOffset)); - } - - /** - * Start offset for {@code nodeId}. - * - * @throws IllegalStateException when {@code nodeId} is absent. - */ - public int startOffset(long nodeId) { - long packed = map.get(nodeId); - if (packed == LongLongMap.MISSING && !map.containsKey(nodeId)) { - throw new IllegalStateException("nodeId not present in SpanIndex: " + nodeId); - } - return unpackStart(packed); - } - - /** - * End offset for {@code nodeId}. - * - * @throws IllegalStateException when {@code nodeId} is absent. - */ - public int endOffset(long nodeId) { - long packed = map.get(nodeId); - if (packed == LongLongMap.MISSING && !map.containsKey(nodeId)) { - throw new IllegalStateException("nodeId not present in SpanIndex: " + nodeId); - } - return unpackEnd(packed); - } - - /** {@code true} iff an entry exists for {@code nodeId}. */ - public boolean contains(long nodeId) { - return map.containsKey(nodeId); - } - - /** Number of entries. */ - public int size() { - return map.size(); - } - - /** - * Shift every entry whose {@code startOffset >= afterOffset} by - * {@code delta}: both {@code startOffset} and {@code endOffset} move by - * the same amount (a wholesale move, not a resize). Entries whose - * {@code startOffset < afterOffset} but whose {@code endOffset >= - * afterOffset} (i.e., spanning the edit) are NOT touched here — those - * belong to ancestors on the splice path, which {@link OffsetDecoupledSplicer} - * rewrites explicitly with their own end-extension logic. - * - *

Implemented eagerly: walks the underlying primitive {@code long[]} - * via {@link LongLongMap#forEachEntry}. Cost is {@code O(size)} but each - * slot touch is one packed-long compare and one packed-long write — the - * tightest possible inner loop on the JVM. Compared to record-rebuilding - * the same nodes (allocation + List.copyOf + per-record GC pressure) - * this is the whole point of path A. - * - *

{@code delta == 0} is a no-op (skip the walk). - * - *

Future work (out of scope for the spike): a range-tree or lazy-log - * encoding would lower cost to {@code O(log N)} per shift, at the cost of - * read complexity. The eager walk is the simplest correct prove-out shape. - */ - public void shift(int afterOffset, int delta) { - if (delta == 0) { - return; - } - map.forEachEntry((nodeId, packed) -> { - int start = unpackStart(packed); - if (start >= afterOffset) { - int end = unpackEnd(packed); - return pack(start + delta, end + delta); - } - return packed; - }); - } - - /** Independent deep copy; subsequent mutations on either side are isolated. */ - public SpanIndex copy() { - return new SpanIndex(map.copy()); - } - - // --- packing helpers --- - private static long pack(int start, int end) { - return ((long) start<< 32) | (end & 0xFFFFFFFFL); - } - - private static int unpackStart(long packed) { - return (int)(packed>> 32); - } - - private static int unpackEnd(long packed) { - return (int) packed; - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java deleted file mode 100644 index ae6ce46..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndex.java +++ /dev/null @@ -1,238 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.pragmatica.lang.Option; - -import java.util.ArrayList; -import java.util.List; - -/** - * Path D — stable-id-aware parent index for {@link IdCstNode} trees. - * - *

Identical surface to {@link IdNodeIndex}; the sole difference is the - * implementation of {@link #applyIncremental}, which exploits the stable - * ancestor-id invariant guaranteed by {@link StableIdSplicer} to drop the - * cost from {@code O(N)} (on flat trees) to - * {@code O(oldPivotSize + newPivotSize)} — independent of N and of - * tree shape. - * - *

Path D vs IdNodeIndex.applyIncremental

- * - *

{@link IdNodeIndex#applyIncremental} executes three steps: - * - *

    - *
  1. Remove every old-path ancestor's up-entry, plus every old-pivot - * descendant's up-entry. - *
  2. Insert the new pivot's subtree internal links and wire the pivot to - * its new parent. - *
  3. Walk the new ancestor chain top-down and rewire ALL direct children - * of every ancestor — needed because each new ancestor has a FRESH id - * (the old siblings' parent-links still point to the dead old ancestor - * ids). - *
- * - *

{@link StableIdSplicer} reuses each old ancestor's id when building the - * corresponding new ancestor record. Consequence: - * - *

    - *
  • Old-path ancestor entries in the parents map remain valid — - * their parent's id is unchanged (the parent record was rebuilt but - * carries the same id as before). Step 1's old-path-ancestor removal - * becomes wrong (would delete still-correct entries). - *
  • Step 3's sibling-rewire becomes redundant — sibling subtrees - * are reference-shared (per the identity invariant), so their - * parent-link entries already point to the correct id (the stable - * ancestor id, unchanged across the splice). - *
- * - *

Path D therefore performs only the work that must happen: - * - *

    - *
  1. Remove the old pivot's descendants (the wholesale-replaced subtree).
  2. - *
  3. Remove the old pivot's own up-pointer (the new pivot has a fresh id by - * caller construction, so this entry is dead even with stable ancestors).
  4. - *
  5. Insert the new pivot's subtree internal links.
  6. - *
  7. Wire the new pivot's up-pointer to its parent's stable id.
  8. - *
- * - *

Total cost: {@code O(oldPivotSize + newPivotSize)}. Independent of N. - * - *

Mutate-in-place / invalidate-on-incremental semantics

- * - *

Same as {@link IdNodeIndex}: {@link #applyIncremental} mutates the - * receiver's parents map in place and the receiver becomes invalid after the - * call. Production v0.5.0 will likely use a persistent map for snapshot - * semantics; that design is out of scope for the spike. - * - *

This sandbox class is not referenced by {@code peglib-core} and will be - * promoted, reshaped, or deleted at the Phase 0/1 GO/NO-GO gate. - * - * @since 0.5.0 - */ -public final class StableIdNodeIndex { - private final IdCstNode root; - private final LongLongMap parents; - - /** - * Test hook — number of {@code parents.put} calls performed during the - * most recent {@link #applyIncremental} invocation that produced this - * index, or {@code -1} when this index was produced by {@link #build}. - * Mirrors {@link IdNodeIndex#lastIncrementalPutCount} for microcount - * comparisons. - */ - final int lastIncrementalPutCount; - - /** - * Test hook — number of {@code parents.remove} calls performed during - * the most recent {@link #applyIncremental} invocation that produced this - * index, or {@code -1} when this index was produced by {@link #build}. - */ - final int lastIncrementalRemoveCount; - - private StableIdNodeIndex(IdCstNode root, - LongLongMap parents, - int lastIncrementalPutCount, - int lastIncrementalRemoveCount) { - this.root = root; - this.parents = parents; - this.lastIncrementalPutCount = lastIncrementalPutCount; - this.lastIncrementalRemoveCount = lastIncrementalRemoveCount; - } - - /** - * Build a fresh index over {@code root}. {@code O(N)} in the descendant - * count. Used only on the initial parse; subsequent edits should call - * {@link #applyIncremental} for {@code O(δ)} updates. - */ - public static StableIdNodeIndex build(IdCstNode root) { - int expectedSize = countDescendants(root); - var parents = new LinearProbingLongLongMap(Math.max(expectedSize, 4)); - indexChildren(root, parents); - return new StableIdNodeIndex(root, parents, - 1, - 1); - } - - /** - * Path D optimized incremental index update. Assumes the splicer - * preserved ancestor IDs (use {@link StableIdSplicer}). - * - *

Cost: {@code O(oldPivotSize + newPivotSize)} — independent of N. - * - *

Invalidates the receiver. Callers MUST use the - * returned instance and discard {@code this}. - * - * @param newRoot root of the post-edit tree - * @param oldPath {@code root → oldPivot} chain in the pre-edit tree - * (size ≥ 1) - * @param newPath {@code root → newPivot} chain in the post-edit tree - * (size ≥ 1; ancestors carry stable ids matching - * {@code oldPath}) - */ - public StableIdNodeIndex applyIncremental(IdCstNode newRoot, - List oldPath, - List newPath) { - if (oldPath == null || oldPath.isEmpty()) { - throw new IllegalArgumentException("oldPath must contain at least the old pivot"); - } - if (newPath == null || newPath.isEmpty()) { - throw new IllegalArgumentException("newPath must contain at least the new pivot"); - } - var oldPivot = oldPath.get(oldPath.size() - 1); - var newPivot = newPath.get(newPath.size() - 1); - int putCount = 0; - int removeCount = 0; - // Step 1 — remove oldPivot's descendants (wholesale replaced subtree). - // Note: we do NOT touch oldPath ancestors' entries — their ids are - // reused on the new path, so the entries are still valid. - var oldPivotDescendants = new ArrayList(); - flattenDescendantsInto(oldPivot, oldPivotDescendants); - for (var d : oldPivotDescendants) { - parents.remove(d.id()); - removeCount++ ; - } - // Step 2 — remove the oldPivot's own up-pointer. The new pivot - // typically has a fresh id (caller responsibility), so the old entry - // is dead. (If the caller chose to reuse oldPivot.id() for the new - // pivot, step 4 below will simply re-insert the same entry — still - // correct.) - parents.remove(oldPivot.id()); - removeCount++ ; - // Step 3 — insert new pivot's subtree internal links. - indexChildren(newPivot, parents); - putCount += subtreeChildCount(newPivot); - // Step 4 — wire the new pivot to its parent's stable id, unless the - // pivot is itself the new root. - if (newPath.size() >= 2) { - var newPivotParent = newPath.get(newPath.size() - 2); - parents.put(newPivot.id(), newPivotParent.id()); - putCount++ ; - } - // Steps explicitly SKIPPED vs IdNodeIndex.applyIncremental: - // - removal of oldPath ancestors' entries (their ids are reused) - // - sibling rewire under each new ancestor (siblings already point - // to the correct stable id, unchanged across the splice). - return new StableIdNodeIndex(newRoot, parents, putCount, removeCount); - } - - /** Root of the CST this index reflects. */ - public IdCstNode root() { - return root; - } - - /** - * Parent ID of the node identified by {@code childId}, or - * {@link Option#none()} when {@code childId} is the root, absent from - * this index, or has been removed. - */ - public Option parentIdOf(long childId) { - if (!parents.containsKey(childId)) { - return Option.none(); - } - return Option.some(parents.get(childId)); - } - - /** - * {@code true} iff {@code id} is present as a key (i.e., the node has a - * parent in this index). Note: the root itself returns {@code false} - * because the root has no parent entry. - */ - public boolean contains(long id) { - return parents.containsKey(id); - } - - /** Number of parent entries — equals {@code descendantCount(root)}. */ - public int size() { - return parents.size(); - } - - // -- Helpers (mirror IdNodeIndex's static helpers) -- - private static int countDescendants(IdCstNode node) { - int count = 0; - if (node instanceof IdCstNode.NonTerminal nt) { - for (var child : nt.children()) { - count += 1 + countDescendants(child); - } - } - return count; - } - - private static void indexChildren(IdCstNode node, LongLongMap parents) { - if (node instanceof IdCstNode.NonTerminal nt) { - for (var child : nt.children()) { - parents.put(child.id(), nt.id()); - indexChildren(child, parents); - } - } - } - - private static void flattenDescendantsInto(IdCstNode node, List out) { - if (node instanceof IdCstNode.NonTerminal nt) { - for (var child : nt.children()) { - out.add(child); - flattenDescendantsInto(child, out); - } - } - } - - private static int subtreeChildCount(IdCstNode node) { - return countDescendants(node); - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java deleted file mode 100644 index 57ea833..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/StableIdSplicer.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import java.util.ArrayList; -import java.util.List; - -/** - * Stable-ID variant of {@link IdTreeSplicer} — Path D spike for the v0.5.0 - * architectural rework - * (see {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A and §8 Q3, - * and {@code docs/bench-results/phase1-spanindex-results.md} "Architectural - * insight" for the motivation). - * - *

Identical to {@link IdTreeSplicer} in every respect EXCEPT one: when - * building the new ancestor records on the splice path, this splicer - * reuses each old ancestor's {@code id} instead of allocating - * a fresh one from the {@link IdGenerator}. - * - *

Why stable IDs?

- * - *

Phase 1 (Path A) bench surfaced the actual bottleneck on flat trees: - * {@code IdNodeIndex.applyIncremental} step 3 walks every direct child of every - * rebuilt ancestor because the new ancestor carries a fresh ID — every sibling - * needs its parent-link rewritten to point to the new ID. On a flat tree - * (one root with N statement children), that's {@code O(N)} parent-link - * rewrites per edit, dominating any splice-side savings. - * - *

Reusing the old ancestor's id flips that: - * - *

    - *
  • The new ancestor record is structurally distinct ({@code !=}) but - * carries the same {@code id} as the old one. - *
  • Sibling subtrees that are reference-shared (per the identity invariant - * carried over from {@link IdTreeSplicer}) also have their - * existing parent-link entries in any {@link IdNodeIndex}'s parents map - * remain valid — the parent's id is unchanged. - *
  • {@link StableIdNodeIndex#applyIncremental} can therefore skip the - * sibling-rewire step entirely, dropping the cost from - * {@code O(N)} per edit to {@code O(oldPivotSize + newPivotSize)}. - *
- * - *

ID semantics tradeoff

- * - *

This is a semantic shift. With {@link IdTreeSplicer}, an id corresponds - * 1:1 to a JVM record instance — {@code id == JVM identity}. With - * {@code StableIdSplicer}, an id corresponds to a logical node - * preserved across splices when the splicer's discretion deems the structural - * role unchanged (here: every node on the splice path keeps its id, even - * though its {@code children} list was rebuilt). Two distinct {@link IdCstNode} - * records may share an id across edit generations. - * - *

{@link IdGenerator} is still required (and still parameterizable) because - * future callers may need to allocate ids for genuinely new internal nodes - * (e.g., a node split during recovery). The splicer itself does not consume - * any new ids during the ancestor rebuild on the splice path. - * - *

This sandbox class is not referenced by {@code peglib-core} and will be - * promoted, reshaped, or deleted at the Phase 0/1 GO/NO-GO gate. - * - * @since 0.5.0 - */ -public final class StableIdSplicer { - /** - * Result of a splice — the new root and the new path (root → newPivot, - * inclusive). Path elements at indices {@code [0, newPath.size() - 2]} - * carry stable ids matching the corresponding {@code oldPath} entries; the - * last element is the supplied {@code newPivot} (its id is whatever the - * caller built it with). - */ - public record Result(IdCstNode newRoot, List newPath) {} - - @SuppressWarnings("unused") // retained for symmetry with IdTreeSplicer; future use cases may need to allocate IDs for genuinely new internal nodes (e.g., recovery splits). - private final IdGenerator idGen; - - public StableIdSplicer(IdGenerator idGen) { - this.idGen = idGen; - } - - /** - * Splice {@code newPivot} into the tree by replacing the last element of - * {@code oldPath}. Each new ancestor on the path reuses the corresponding - * old ancestor's id; sibling subtrees are reference-shared. - * - * @param oldPath root → oldPivot, inclusive (size ≥ 1) - * @param newPivot the replacement subtree - * @return new root + new path; ancestors on the path are fresh records but - * carry stable ids matching their {@code oldPath} counterparts - * @throws IllegalArgumentException when {@code oldPath} is null or empty - * @throws IllegalStateException when an ancestor is not a {@code NonTerminal} - * or the child-identity link in {@code oldPath} is broken - */ - public Result splice(List oldPath, IdCstNode newPivot) { - if (oldPath == null || oldPath.isEmpty()) { - throw new IllegalArgumentException("oldPath must contain at least the old pivot"); - } - if (newPivot == null) { - throw new IllegalArgumentException("newPivot must not be null"); - } - // Pivot IS the root — no rebuild needed. - if (oldPath.size() == 1) { - return new Result(newPivot, List.of(newPivot)); - } - // Accumulator: builds newPath in REVERSE (leaf → root), reversed at end. - var reversedNewPath = new ArrayList(oldPath.size()); - reversedNewPath.add(newPivot); - var current = newPivot; - var oldChild = oldPath.get(oldPath.size() - 1); - for (int i = oldPath.size() - 2; i >= 0; i-- ) { - var oldAncestor = oldPath.get(i); - if (! (oldAncestor instanceof IdCstNode.NonTerminal nt)) { - throw new IllegalStateException( - "splice path element at depth " + i + " is not a NonTerminal: " + oldAncestor); - } - var children = nt.children(); - int slot = indexOfByIdentity(children, oldChild); - if (slot < 0) { - throw new IllegalStateException( - "splice path broken at depth " + i + ": child " + oldChild + " not found in parent's children list"); - } - // Copy the children list, replacing only the spliced slot. Every - // other entry is the same reference — preserves the spec §8 Q3 - // identity invariant. - var newChildren = new ArrayList(children.size()); - for (int k = 0; k < children.size(); k++ ) { - newChildren.add(k == slot - ? current - : children.get(k)); - } - // SOLE DIVERGENCE FROM IdTreeSplicer: reuse oldAncestor.id() instead - // of idGen.next(). The new record is structurally distinct from the - // old (different children list) but carries the same id; the - // parents map in any StableIdNodeIndex therefore preserves every - // sibling's parent-link entry across the edit. - var newAncestor = new IdCstNode.NonTerminal( - oldAncestor.id(), nt.span(), nt.rule(), List.copyOf(newChildren), nt.leadingTrivia(), nt.trailingTrivia()); - reversedNewPath.add(newAncestor); - current = newAncestor; - oldChild = oldAncestor; - } - var newPath = new ArrayList(reversedNewPath.size()); - for (int i = reversedNewPath.size() - 1; i >= 0; i-- ) { - newPath.add(reversedNewPath.get(i)); - } - return new Result(current, List.copyOf(newPath)); - } - - /** - * Linear scan for a child by record identity ({@code ==}). Children lists - * are typically small (≤ 8); a linear scan dominates any hash-based - * approach. - */ - private static int indexOfByIdentity(List children, IdCstNode target) { - for (int i = 0; i < children.size(); i++ ) { - if (children.get(i) == target) { - return i; - } - } - return - 1; - } -} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/package-info.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/package-info.java deleted file mode 100644 index 7ed13c8..0000000 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/experimental/package-info.java +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Sandbox for the peglib 0.5.0 architectural rework. - * - *

This package is experimental and additive. Code here is - * Phase 0 spike work for the v0.5.0 incremental-native architecture (see - * {@code docs/incremental/ARCHITECTURE-0.5.0.md}). Nothing in this package is - * referenced from {@code peglib-core} or the production - * {@code org.pragmatica.peg.incremental} / {@code .internal} packages — the - * spike must not perturb the existing 100 incremental tests or the wider 897 - * test corpus. - * - *

If the Phase 0 GO/NO-GO gate ships GO, types here will be promoted to - * the production packages during Phases 1–5. If NO-GO, the package is - * removed wholesale. - * - * @since 0.5.0 - */ -package org.pragmatica.peg.incremental.experimental; diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/CalculatorTriviaIncrementalTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/CalculatorTriviaIncrementalTest.java deleted file mode 100644 index 37554d4..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/CalculatorTriviaIncrementalTest.java +++ /dev/null @@ -1,307 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.PegParser; -import org.pragmatica.peg.tree.CstNode; - -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase 0d.1 — GO/NO-GO gate for spec §8 Q4: trivia-bearing edits do not - * break the algorithm or the identity-preservation invariant when the splice - * is driven by {@link IdTreeSplicer}. - * - *

Test methodology

- * - *

The production parser (peglib-core) allocates fresh {@link CstNode} - * records on every parse — there is no record-sharing between two independent - * parses of similar inputs. {@link IdCstNodeBuilder} then assigns fresh stable - * IDs to every node it visits. Therefore, paired re-parses of {@code before} - * and {@code after} produce trees with completely disjoint ID spaces. - * - *

This is fine for Phase 0d.1's goal: the spike GATE concerns the - * algorithm's behavior given identity-shared trees. {@link IdTreeSplicer} - * MANUFACTURES that sharing — it builds a post-edit tree from the pre-edit - * tree by reusing every sibling subtree by reference. So the test: - * - *

    - *
  1. Parses {@code before} → IdCstNode (call this {@code originalTree}). - *
  2. Parses {@code after} → IdCstNode (call this the freshly-rebuilt - * "ground truth" — used only to source a {@code newPivot} subtree - * that has the structural shape we want to splice in). - *
  3. Identifies the splice point in {@code originalTree} (the {@code Number} - * node whose token text is "2"). The corresponding pivot in the - * ground-truth tree provides the new pivot subtree. - *
  4. Calls {@link IdTreeSplicer#splice} → {@code (newRoot, newPath)}. - *
  5. Calls {@link IdNodeIndex#applyIncremental} → {@code incrementalIndex}. - *
  6. Calls {@link IdNodeIndex#build} on {@code newRoot} → {@code groundTruthIndex} - * (this is a fresh full-walk on the post-splice tree, NOT the - * independently-parsed after-tree). - *
  7. Asserts: every node ID in {@code groundTruthIndex.parents} maps to the - * SAME parent ID in {@code incrementalIndex} (equivalence assertion). - *
  8. Asserts: every sibling subtree of every splice-path node is - * reference-shared between {@code originalTree} and {@code newRoot} - * (Q3 invariant carried through the splice). - *
- * - *

Note on the spike-vs-production gap. The production - * parser's lack of cross-parse record sharing means a true "incremental - * reparse" in v0.5.0 will not work by paired re-parses; the - * {@code IdTreeSplicer} (or its production successor) must be the source of - * identity-shared trees. That is a Phase 1 design concern. Phase 0d.1 only - * proves that given identity-shared trees, the algorithm is correct - * and the invariant holds. - */ -final class CalculatorTriviaIncrementalTest { - - private static final String CALCULATOR_GRAMMAR = """ - Expr <- Term (('+' / '-') Term)* - Term <- Factor (('*' / '/') Factor)* - Factor <- Number / '(' Expr ')' - Number <- < [0-9]+ > - Comment <- '/*' (!'*/' .)* '*/' - %whitespace <- ([ \\t\\r\\n]+ / Comment)+ - """; - - private static org.pragmatica.peg.parser.Parser calculator() { - return PegParser.fromGrammar(CALCULATOR_GRAMMAR).unwrap(); - } - - /** - * Parse {@code input} with the calculator grammar and convert to - * {@link IdCstNode} via the supplied generator. - */ - private static IdCstNode parseToIdCst(String input, IdGenerator gen) { - var cst = calculator().parseCst(input).unwrap(); - return new IdCstNodeBuilder(gen).build(cst); - } - - /** - * Walk {@code root} pre-order; return the first {@link IdCstNode.Token} - * whose text is exactly {@code targetText}. Returns null when not found. - * - *

For the calculator grammar, {@code Number <- < [0-9]+ >} produces a - * {@link IdCstNode.Token} (rule = parent rule = "Factor") rather than a - * NonTerminal. Token nodes are leaves, so they're a clean splice target — - * no internal subtree to disrupt the identity invariant. - */ - private static IdCstNode findNumberByText(IdCstNode root, String targetText) { - var stack = new ArrayList(); - stack.add(root); - while (!stack.isEmpty()) { - var node = stack.remove(stack.size() - 1); - if (node instanceof IdCstNode.Token t && t.text().equals(targetText)) { - return node; - } - if (node instanceof IdCstNode.NonTerminal nt) { - for (int i = nt.children().size() - 1; i >= 0; i--) { - stack.add(nt.children().get(i)); - } - } - } - return null; - } - - /** Aggregate the text of all Token/Terminal descendants of {@code node}. */ - private static String nodeTokenText(IdCstNode node) { - return switch (node) { - case IdCstNode.Token t -> t.text(); - case IdCstNode.Terminal t -> t.text(); - case IdCstNode.Error e -> e.skippedText(); - case IdCstNode.NonTerminal nt -> { - var sb = new StringBuilder(); - for (var ch : nt.children()) { - sb.append(nodeTokenText(ch)); - } - yield sb.toString(); - } - }; - } - - /** - * Build the path (root → target, inclusive) from {@code root} to {@code target} - * using record-identity ({@code ==}). Returns null when target is not in the tree. - */ - private static List pathToByIdentity(IdCstNode root, IdCstNode target) { - var acc = new ArrayList(); - if (collectPath(root, target, acc)) { - return List.copyOf(acc); - } - return null; - } - - private static boolean collectPath(IdCstNode node, IdCstNode target, List acc) { - acc.add(node); - if (node == target) { - return true; - } - if (node instanceof IdCstNode.NonTerminal nt) { - for (var ch : nt.children()) { - if (collectPath(ch, target, acc)) { - return true; - } - } - } - acc.remove(acc.size() - 1); - return false; - } - - /** Pre-order flatten of every node in {@code root}'s subtree (inclusive). */ - private static List flatten(IdCstNode root) { - var out = new ArrayList(); - flattenInto(root, out); - return out; - } - - private static void flattenInto(IdCstNode node, List out) { - out.add(node); - if (node instanceof IdCstNode.NonTerminal nt) { - for (var ch : nt.children()) { - flattenInto(ch, out); - } - } - } - - /** - * Run the full Phase 0d.1 Q4 gate for one trivia-bearing edit case. Returns - * a small report record so each test can assert. - */ - private record Q4Outcome(boolean equivalencePassed, - boolean invariantPassed, - int newTreeNodeCount, - int siblingsChecked) {} - - private static Q4Outcome runQ4Gate(String before, String after) { - var gen = new IdGenerator.PerSessionCounter(); - var originalTree = parseToIdCst(before, gen); - // Continue with the SAME generator so before/after IDs do not collide. - var afterTree = parseToIdCst(after, gen); - - var oldPivot = findNumberByText(originalTree, "2"); - var newPivot = findNumberByText(afterTree, "2"); - if (oldPivot == null || newPivot == null) { - throw new AssertionError("Could not locate Number=2 pivot in test inputs '" - + before + "' / '" + after + "'"); - } - - var oldPath = pathToByIdentity(originalTree, oldPivot); - if (oldPath == null) { - throw new AssertionError("Pivot not found via identity walk in originalTree"); - } - - var splicer = new IdTreeSplicer(gen); - var splice = splicer.splice(oldPath, newPivot); - - var oldIndex = IdNodeIndex.build(originalTree); - var incrementalIndex = oldIndex.applyIncremental(splice.newRoot(), oldPath, splice.newPath()); - var groundTruthIndex = IdNodeIndex.build(splice.newRoot()); - - // Equivalence: every node ID in the new tree's full-walk index must - // resolve to the same parent in the incremental index. - boolean equivalencePassed = true; - int newTreeNodeCount = 0; - for (var node : flatten(splice.newRoot())) { - newTreeNodeCount++; - var truth = groundTruthIndex.parentIdOf(node.id()); - var incr = incrementalIndex.parentIdOf(node.id()); - if (!truth.equals(incr)) { - equivalencePassed = false; - System.err.println("[Q4 equivalence FAIL] node id=" + node.id() - + " rule=" + node.rule() - + " truth.parent=" + truth - + " incremental.parent=" + incr); - } - } - - // Identity invariant: every non-spliced sibling along the path must be ==. - int siblingsChecked = 0; - boolean invariantPassed = true; - for (int depth = 0; depth < oldPath.size() - 1; depth++) { - var oldAncestor = oldPath.get(depth); - var newAncestor = splice.newPath().get(depth); - if (!(oldAncestor instanceof IdCstNode.NonTerminal oldNT) - || !(newAncestor instanceof IdCstNode.NonTerminal newNT)) { - invariantPassed = false; - continue; - } - var oldChildren = oldNT.children(); - var newChildren = newNT.children(); - if (oldChildren.size() != newChildren.size()) { - invariantPassed = false; - continue; - } - int splicedIdx = oldChildren.indexOf(oldPath.get(depth + 1)); - for (int k = 0; k < newChildren.size(); k++) { - if (k == splicedIdx) { - continue; - } - siblingsChecked++; - if (newChildren.get(k) != oldChildren.get(k)) { - invariantPassed = false; - System.err.println("[Q4 invariant FAIL] depth=" + depth + " idx=" + k - + " sibling not reference-shared"); - } - } - } - - return new Q4Outcome(equivalencePassed, invariantPassed, newTreeNodeCount, siblingsChecked); - } - - @Nested - @DisplayName("Trivia-bearing edits (spec §8 Q4)") - class TriviaEdits { - - @Test - @DisplayName("Edit A — insert blank line before second operand") - void edit_a_insert_blank_line() { - var outcome = runQ4Gate("1+2", "1+\n 2"); - - assertThat(outcome.equivalencePassed()) - .as("incremental index must equal ground-truth full-walk index") - .isTrue(); - assertThat(outcome.invariantPassed()) - .as("identity invariant: every sibling subtree of splice path is reference-shared") - .isTrue(); - assertThat(outcome.siblingsChecked()) - .as("at least some siblings should be checked along the path") - .isGreaterThanOrEqualTo(0); - System.out.println("[Phase 0d.1 Q4-A] newTreeNodes=" + outcome.newTreeNodeCount() - + " siblingsChecked=" + outcome.siblingsChecked()); - } - - @Test - @DisplayName("Edit B — delete inline comment between operands") - void edit_b_delete_inline_comment() { - var outcome = runQ4Gate("1 /*hi*/ + 2", "1 + 2"); - - assertThat(outcome.equivalencePassed()) - .as("incremental index must equal ground-truth full-walk index") - .isTrue(); - assertThat(outcome.invariantPassed()) - .as("identity invariant: every sibling subtree of splice path is reference-shared") - .isTrue(); - System.out.println("[Phase 0d.1 Q4-B] newTreeNodes=" + outcome.newTreeNodeCount() - + " siblingsChecked=" + outcome.siblingsChecked()); - } - - @Test - @DisplayName("Edit C — insert comment inside expression") - void edit_c_insert_comment_inside_expression() { - var outcome = runQ4Gate("1+2", "1+/*x*/2"); - - assertThat(outcome.equivalencePassed()) - .as("incremental index must equal ground-truth full-walk index") - .isTrue(); - assertThat(outcome.invariantPassed()) - .as("identity invariant: every sibling subtree of splice path is reference-shared") - .isTrue(); - System.out.println("[Phase 0d.1 Q4-C] newTreeNodes=" + outcome.newTreeNodeCount() - + " siblingsChecked=" + outcome.siblingsChecked()); - } - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java deleted file mode 100644 index 3076b71..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeBuilderTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.tree.CstNode; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase 0b builder tests — confirm post-order id assignment and - * field round-tripping for every production {@link CstNode} variant. - */ -final class IdCstNodeBuilderTest { - private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 4, 3); - private static final SourceSpan SPAN_INNER = new SourceSpan(1, 1, 0, 1, 2, 1); - private static final List NO_TRIVIA = List.of(); - - private IdCstNodeBuilder freshBuilder() { - return new IdCstNodeBuilder(new IdGenerator.PerSessionCounter()); - } - - @Test - @DisplayName("Single Terminal gets id 0") - void single_terminal_gets_id_zero() { - CstNode source = new CstNode.Terminal(0L, SPAN, "Number", "123", NO_TRIVIA, NO_TRIVIA); - var built = freshBuilder().build(source); - assertThat(built).isInstanceOf(IdCstNode.Terminal.class); - var terminal = (IdCstNode.Terminal) built; - assertThat(terminal.id()).isEqualTo(0L); - assertThat(terminal.span()).isEqualTo(SPAN); - assertThat(terminal.rule()).isEqualTo("Number"); - assertThat(terminal.text()).isEqualTo("123"); - } - - @Test - @DisplayName("NonTerminal with three Terminal children: children 0,1,2 / parent 3 (post-order)") - void nonterminal_post_order_ids() { - var c1 = new CstNode.Terminal(0L, SPAN, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var c2 = new CstNode.Terminal(0L, SPAN, "Number", "2", NO_TRIVIA, NO_TRIVIA); - var c3 = new CstNode.Terminal(0L, SPAN, "Number", "3", NO_TRIVIA, NO_TRIVIA); - CstNode parent = new CstNode.NonTerminal(0L, SPAN, "Expr", List.of(c1, c2, c3), NO_TRIVIA, NO_TRIVIA); - - var built = (IdCstNode.NonTerminal) freshBuilder().build(parent); - - assertThat(built.id()).isEqualTo(3L); - assertThat(built.children()).hasSize(3); - assertThat(built.children().get(0).id()).isEqualTo(0L); - assertThat(built.children().get(1).id()).isEqualTo(1L); - assertThat(built.children().get(2).id()).isEqualTo(2L); - } - - @Test - @DisplayName("3-deep nested NonTerminal -> NonTerminal -> Terminal: 0,1,2 inside-out") - void three_deep_nested_ids() { - var leaf = new CstNode.Terminal(0L, SPAN_INNER, "Atom", "x", NO_TRIVIA, NO_TRIVIA); - var middle = new CstNode.NonTerminal(0L, SPAN_INNER, "Inner", List.of(leaf), NO_TRIVIA, NO_TRIVIA); - CstNode root = new CstNode.NonTerminal(0L, SPAN, "Outer", List.of(middle), NO_TRIVIA, NO_TRIVIA); - - var builtRoot = (IdCstNode.NonTerminal) freshBuilder().build(root); - var builtMiddle = (IdCstNode.NonTerminal) builtRoot.children().get(0); - var builtLeaf = (IdCstNode.Terminal) builtMiddle.children().get(0); - - assertThat(builtLeaf.id()).isEqualTo(0L); - assertThat(builtMiddle.id()).isEqualTo(1L); - assertThat(builtRoot.id()).isEqualTo(2L); - } - - @Test - @DisplayName("Mixed tree with Token and Error preserves all fields and assigns ids to every node") - void mixed_variants_round_trip() { - var token = new CstNode.Token(0L, SPAN_INNER, "Ident", "foo", NO_TRIVIA, NO_TRIVIA); - var error = new CstNode.Error(0L, SPAN_INNER, "@@@", "expected ident", NO_TRIVIA, NO_TRIVIA); - var terminal = new CstNode.Terminal(0L, SPAN_INNER, "Number", "1", NO_TRIVIA, NO_TRIVIA); - CstNode root = new CstNode.NonTerminal(0L, SPAN, "Mixed", List.of(token, error, terminal), NO_TRIVIA, NO_TRIVIA); - - var builtRoot = (IdCstNode.NonTerminal) freshBuilder().build(root); - - assertThat(builtRoot.children()).hasSize(3); - var builtToken = (IdCstNode.Token) builtRoot.children().get(0); - var builtError = (IdCstNode.Error) builtRoot.children().get(1); - var builtTerminal = (IdCstNode.Terminal) builtRoot.children().get(2); - - assertThat(builtToken.id()).isEqualTo(0L); - assertThat(builtToken.text()).isEqualTo("foo"); - assertThat(builtError.id()).isEqualTo(1L); - assertThat(builtError.skippedText()).isEqualTo("@@@"); - assertThat(builtError.expected()).isEqualTo("expected ident"); - assertThat(builtError.rule()).isEqualTo(""); - assertThat(builtTerminal.id()).isEqualTo(2L); - assertThat(builtTerminal.text()).isEqualTo("1"); - assertThat(builtRoot.id()).isEqualTo(3L); - } - - @Test - @DisplayName("Trivia lists are reference-equal pre/post conversion") - void trivia_reference_identity_preserved() { - var leading = List.of(new Trivia.Whitespace(SPAN_INNER, " ")); - var trailing = List.of(new Trivia.LineComment(SPAN_INNER, "// done")); - CstNode source = new CstNode.Terminal(0L, SPAN, "Number", "1", leading, trailing); - - var built = (IdCstNode.Terminal) freshBuilder().build(source); - - assertThat(built.leadingTrivia()).isSameAs(leading); - assertThat(built.trailingTrivia()).isSameAs(trailing); - } - - @Test - @DisplayName("Two builds with independent counters yield equal trees (id excluded from equality)") - void independent_builds_are_structurally_equal() { - var c1 = new CstNode.Terminal(0L, SPAN_INNER, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var c2 = new CstNode.Terminal(0L, SPAN_INNER, "Number", "2", NO_TRIVIA, NO_TRIVIA); - CstNode source = new CstNode.NonTerminal(0L, SPAN, "Expr", List.of(c1, c2), NO_TRIVIA, NO_TRIVIA); - - var first = freshBuilder().build(source); - var second = freshBuilder().build(source); - - assertThat(first).isEqualTo(second); - assertThat(first.hashCode()).isEqualTo(second.hashCode()); - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeTest.java deleted file mode 100644 index 8d463d7..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdCstNodeTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase 0b foundation tests for {@link IdCstNode}. - * - *

Asserts the {@code id}-as-metadata equality contract from spec §7 R1: - * structural equality excludes the id, and cross-variant comparisons never - * collide. - */ -final class IdCstNodeTest { - private static final SourceSpan SPAN_A = new SourceSpan(1, 1, 0, 1, 4, 3); - private static final SourceSpan SPAN_B = new SourceSpan(1, 5, 4, 1, 8, 7); - private static final List NO_TRIVIA = List.of(); - - @Nested - @DisplayName("Construction and accessors") - final class Construction { - @Test - @DisplayName("Terminal exposes its assigned id") - void terminal_exposes_id() { - var node = new IdCstNode.Terminal(42L, SPAN_A, "Number", "123", NO_TRIVIA, NO_TRIVIA); - assertThat(node.id()).isEqualTo(42L); - assertThat(node.span()).isEqualTo(SPAN_A); - assertThat(node.rule()).isEqualTo("Number"); - assertThat(node.text()).isEqualTo("123"); - } - - @Test - @DisplayName("NonTerminal exposes its assigned id and children") - void nonterminal_exposes_id() { - var child = new IdCstNode.Terminal(0L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var parent = new IdCstNode.NonTerminal(7L, SPAN_A, "Expr", List.of(child), NO_TRIVIA, NO_TRIVIA); - assertThat(parent.id()).isEqualTo(7L); - assertThat(parent.children()).containsExactly(child); - } - - @Test - @DisplayName("Token exposes its assigned id") - void token_exposes_id() { - var node = new IdCstNode.Token(9L, SPAN_A, "Ident", "foo", NO_TRIVIA, NO_TRIVIA); - assertThat(node.id()).isEqualTo(9L); - assertThat(node.text()).isEqualTo("foo"); - } - - @Test - @DisplayName("Error exposes its assigned id and reports rule()=") - void error_exposes_id_and_default_rule() { - var node = new IdCstNode.Error(11L, SPAN_A, "@@@", "expected number", NO_TRIVIA, NO_TRIVIA); - assertThat(node.id()).isEqualTo(11L); - assertThat(node.rule()).isEqualTo(""); - assertThat(node.skippedText()).isEqualTo("@@@"); - assertThat(node.expected()).isEqualTo("expected number"); - } - } - - @Nested - @DisplayName("Equality excludes id (spec §7 R1)") - final class Equality { - @Test - @DisplayName("Terminals with identical structure but different ids are equal") - void terminal_equality_ignores_id() { - var a = new IdCstNode.Terminal(0L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var b = new IdCstNode.Terminal(999L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); - assertThat(a).isEqualTo(b); - assertThat(a.hashCode()).isEqualTo(b.hashCode()); - } - - @Test - @DisplayName("Terminals with different text are not equal") - void terminal_inequality_when_text_differs() { - var a = new IdCstNode.Terminal(0L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var b = new IdCstNode.Terminal(0L, SPAN_A, "Number", "2", NO_TRIVIA, NO_TRIVIA); - assertThat(a).isNotEqualTo(b); - } - - @Test - @DisplayName("NonTerminals are equal when children are structurally equal even with differing ids throughout") - void nonterminal_equality_ignores_id_recursively() { - var leftChild = new IdCstNode.Terminal(0L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var rightChild = new IdCstNode.Terminal(500L, SPAN_A, "Number", "1", NO_TRIVIA, NO_TRIVIA); - var left = new IdCstNode.NonTerminal(1L, SPAN_A, "Expr", List.of(leftChild), NO_TRIVIA, NO_TRIVIA); - var right = new IdCstNode.NonTerminal(900L, SPAN_A, "Expr", List.of(rightChild), NO_TRIVIA, NO_TRIVIA); - assertThat(left).isEqualTo(right); - assertThat(left.hashCode()).isEqualTo(right.hashCode()); - } - - @Test - @DisplayName("NonTerminals with different rules are not equal") - void nonterminal_inequality_when_rule_differs() { - var a = new IdCstNode.NonTerminal(0L, SPAN_A, "Expr", List.of(), NO_TRIVIA, NO_TRIVIA); - var b = new IdCstNode.NonTerminal(0L, SPAN_A, "Stmt", List.of(), NO_TRIVIA, NO_TRIVIA); - assertThat(a).isNotEqualTo(b); - } - - @Test - @DisplayName("Tokens with identical structure but different ids are equal") - void token_equality_ignores_id() { - var a = new IdCstNode.Token(2L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); - var b = new IdCstNode.Token(8L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); - assertThat(a).isEqualTo(b); - assertThat(a.hashCode()).isEqualTo(b.hashCode()); - } - - @Test - @DisplayName("Tokens with different spans are not equal") - void token_inequality_when_span_differs() { - var a = new IdCstNode.Token(0L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); - var b = new IdCstNode.Token(0L, SPAN_B, "Ident", "x", NO_TRIVIA, NO_TRIVIA); - assertThat(a).isNotEqualTo(b); - } - - @Test - @DisplayName("Errors with identical structure but different ids are equal") - void error_equality_ignores_id() { - var a = new IdCstNode.Error(0L, SPAN_A, "@@@", "expected number", NO_TRIVIA, NO_TRIVIA); - var b = new IdCstNode.Error(123L, SPAN_A, "@@@", "expected number", NO_TRIVIA, NO_TRIVIA); - assertThat(a).isEqualTo(b); - assertThat(a.hashCode()).isEqualTo(b.hashCode()); - } - - @Test - @DisplayName("Errors with different skippedText are not equal") - void error_inequality_when_skipped_differs() { - var a = new IdCstNode.Error(0L, SPAN_A, "@@@", "expected number", NO_TRIVIA, NO_TRIVIA); - var b = new IdCstNode.Error(0L, SPAN_A, "###", "expected number", NO_TRIVIA, NO_TRIVIA); - assertThat(a).isNotEqualTo(b); - } - - @Test - @DisplayName("Cross-variant comparison is never equal even with overlapping fields") - void cross_variant_never_equal() { - var terminal = new IdCstNode.Terminal(0L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); - var token = new IdCstNode.Token(0L, SPAN_A, "Ident", "x", NO_TRIVIA, NO_TRIVIA); - assertThat(terminal).isNotEqualTo(token); - assertThat(token).isNotEqualTo(terminal); - } - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdGeneratorTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdGeneratorTest.java deleted file mode 100644 index e267645..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdGeneratorTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase 0a foundation tests — single-threaded counter contract for - * {@link IdGenerator.PerSessionCounter}. - */ -final class IdGeneratorTest { - @Test - @DisplayName("First call returns 0") - void first_call_returns_zero() { - var gen = new IdGenerator.PerSessionCounter(); - assertThat(gen.next()).isEqualTo(0L); - } - - @Test - @DisplayName("Second call returns 1") - void second_call_returns_one() { - var gen = new IdGenerator.PerSessionCounter(); - gen.next(); - assertThat(gen.next()).isEqualTo(1L); - } - - @Test - @DisplayName("1000 calls return 0..999 in strict order") - void thousand_calls_strict_order() { - var gen = new IdGenerator.PerSessionCounter(); - for (long expected = 0; expected < 1000; expected++) { - assertThat(gen.next()).isEqualTo(expected); - } - } - - @Test - @DisplayName("Two independent counters do not share state") - void independent_counters_do_not_share_state() { - var first = new IdGenerator.PerSessionCounter(); - var second = new IdGenerator.PerSessionCounter(); - for (int i = 0; i < 5; i++) { - first.next(); - } - assertThat(second.next()).isEqualTo(0L); - assertThat(second.next()).isEqualTo(1L); - assertThat(first.next()).isEqualTo(5L); - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdNodeIndexTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdNodeIndexTest.java deleted file mode 100644 index 629f738..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdNodeIndexTest.java +++ /dev/null @@ -1,395 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase 0c tests for {@link IdNodeIndex}: full-build correctness, incremental - * update equivalence to full-build, and the central perf microcount that - * proves {@link IdNodeIndex#applyIncremental} is {@code O(δ)}, not - * {@code O(N)} (per - * {@code docs/incremental/ARCHITECTURE-0.5.0.md} §2 Lever A and §8 Q3). - */ -final class IdNodeIndexTest { - private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); - private static final List NO_TRIVIA = List.of(); - - private static IdCstNode.Terminal terminal(IdGenerator gen, String text) { - return new IdCstNode.Terminal(gen.next(), SPAN, "T", text, NO_TRIVIA, NO_TRIVIA); - } - - private static IdCstNode.NonTerminal nonTerminal(IdGenerator gen, String rule, List children) { - return new IdCstNode.NonTerminal(gen.next(), SPAN, rule, children, NO_TRIVIA, NO_TRIVIA); - } - - @Nested - @DisplayName("build") - class BuildTests { - @Test - @DisplayName("Single Terminal as root: size 0, no parent entry for root") - void single_terminal_root() { - var gen = new IdGenerator.PerSessionCounter(); - var root = terminal(gen, "x"); - - var index = IdNodeIndex.build(root); - - assertThat(index.size()).isEqualTo(0); - assertThat(index.contains(root.id())).isFalse(); - assertThat(index.parentIdOf(root.id()).isEmpty()).isTrue(); - assertThat(index.root()).isSameAs(root); - } - - @Test - @DisplayName("NonTerminal with 3 Terminal children: each child points to root") - void nonterminal_three_children() { - var gen = new IdGenerator.PerSessionCounter(); - var c1 = terminal(gen, "a"); - var c2 = terminal(gen, "b"); - var c3 = terminal(gen, "c"); - IdCstNode root = nonTerminal(gen, "Expr", List.of(c1, c2, c3)); - - var index = IdNodeIndex.build(root); - - assertThat(index.size()).isEqualTo(3); - assertThat(index.parentIdOf(c1.id())).isEqualTo(org.pragmatica.lang.Option.some(root.id())); - assertThat(index.parentIdOf(c2.id())).isEqualTo(org.pragmatica.lang.Option.some(root.id())); - assertThat(index.parentIdOf(c3.id())).isEqualTo(org.pragmatica.lang.Option.some(root.id())); - assertThat(index.contains(root.id())).isFalse(); - } - - @Test - @DisplayName("Three-deep tree: every interior parent link present, root has none") - void three_deep_tree() { - var gen = new IdGenerator.PerSessionCounter(); - var leaf = terminal(gen, "x"); - var middle = nonTerminal(gen, "Inner", List.of(leaf)); - IdCstNode root = nonTerminal(gen, "Outer", List.of(middle)); - - var index = IdNodeIndex.build(root); - - assertThat(index.size()).isEqualTo(2); - assertThat(index.parentIdOf(leaf.id())).isEqualTo(org.pragmatica.lang.Option.some(middle.id())); - assertThat(index.parentIdOf(middle.id())).isEqualTo(org.pragmatica.lang.Option.some(root.id())); - assertThat(index.contains(root.id())).isFalse(); - } - } - - @Nested - @DisplayName("applyIncremental") - class ApplyIncrementalTests { - - @Test - @DisplayName("Trivial replacement: middle child swapped under same root id") - void trivial_replacement() { - // Old tree: root -> [A, B, C] - // New tree: newRoot (fresh id) -> [A, B', C] (A and C reused; B replaced) - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "a"); - var b = terminal(gen, "b"); - var c = terminal(gen, "c"); - IdCstNode oldRoot = nonTerminal(gen, "R", List.of(a, b, c)); - - var index = IdNodeIndex.build(oldRoot); - - // Construct the new tree using the SAME generator (continues incrementing). - var bPrime = terminal(gen, "B-prime"); - IdCstNode newRoot = nonTerminal(gen, "R", List.of(a, bPrime, c)); - - var oldPath = List.of(oldRoot, b); - var newPath = List.of(newRoot, (IdCstNode) bPrime); - - var newIndex = index.applyIncremental(newRoot, oldPath, newPath); - - // A and C still point to NEW root. - assertThat(newIndex.parentIdOf(a.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); - assertThat(newIndex.parentIdOf(c.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); - // B' points to new root. - assertThat(newIndex.parentIdOf(bPrime.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); - // Old B is dead. - assertThat(newIndex.contains(b.id())).isFalse(); - // Old root was an entry? No — root never had a parent. Map entries: 3. - assertThat(newIndex.size()).isEqualTo(3); - assertThat(newIndex.root()).isSameAs(newRoot); - } - - @Test - @DisplayName("Pivot is the root itself: equivalent to full rebuild") - void pivot_is_root() { - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "a"); - var b = terminal(gen, "b"); - IdCstNode oldRoot = nonTerminal(gen, "R", List.of(a, b)); - - var index = IdNodeIndex.build(oldRoot); - - var aNew = terminal(gen, "anew"); - var bNew = terminal(gen, "bnew"); - IdCstNode newRoot = nonTerminal(gen, "R", List.of(aNew, bNew)); - - var oldPath = List.of(oldRoot); - var newPath = List.of(newRoot); - - var newIndex = index.applyIncremental(newRoot, oldPath, newPath); - - // Old root and old children all gone. - assertThat(newIndex.contains(oldRoot.id())).isFalse(); - assertThat(newIndex.contains(a.id())).isFalse(); - assertThat(newIndex.contains(b.id())).isFalse(); - // New entries match a fresh build. - assertThat(newIndex.parentIdOf(aNew.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); - assertThat(newIndex.parentIdOf(bNew.id())).isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); - assertThat(newIndex.size()).isEqualTo(2); - - // Equivalence to full rebuild. - var fresh = IdNodeIndex.build(newRoot); - assertThat(newIndex.size()).isEqualTo(fresh.size()); - assertThat(newIndex.parentIdOf(aNew.id())).isEqualTo(fresh.parentIdOf(aNew.id())); - assertThat(newIndex.parentIdOf(bNew.id())).isEqualTo(fresh.parentIdOf(bNew.id())); - } - - @Test - @DisplayName("Deep splice (4-level): siblings keep IDs, ancestor chain IDs replaced") - void deep_splice() { - // 4-level tree: - // oldRoot (depth 0) - // ├── lvl1Sibling (depth 1, retained) - // └── oldMid (depth 1, replaced) - // ├── mid_lvl2Sibling (depth 2, retained) - // └── oldInner (depth 2, replaced) - // └── oldPivot (depth 3, replaced -- the smallest replaced subtree contains JUST the pivot) - // - // New tree mirrors structure with fresh ancestor IDs but reuses - // lvl1Sibling and mid_lvl2Sibling (record-identical). - var gen = new IdGenerator.PerSessionCounter(); - - var lvl1Sibling = terminal(gen, "L1S"); - var midLvl2Sibling = terminal(gen, "L2S"); - var oldPivot = terminal(gen, "OLD_PIVOT"); - IdCstNode oldInner = nonTerminal(gen, "Inner", List.of(oldPivot)); - IdCstNode oldMid = nonTerminal(gen, "Mid", List.of(midLvl2Sibling, oldInner)); - IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(lvl1Sibling, oldMid)); - - var index = IdNodeIndex.build(oldRoot); - - // New tree: pivot replaced; ancestor chain Inner→Mid→Root all fresh. - var newPivot = terminal(gen, "NEW_PIVOT"); - IdCstNode newInner = nonTerminal(gen, "Inner", List.of(newPivot)); - IdCstNode newMid = nonTerminal(gen, "Mid", List.of(midLvl2Sibling, newInner)); - IdCstNode newRoot = nonTerminal(gen, "Root", List.of(lvl1Sibling, newMid)); - - // Pivot for incremental update is oldPivot (the smallest replaced node). - var oldPath = List.of(oldRoot, oldMid, oldInner, (IdCstNode) oldPivot); - var newPath = List.of(newRoot, newMid, newInner, (IdCstNode) newPivot); - - var newIndex = index.applyIncremental(newRoot, oldPath, newPath); - - // (a) Surviving siblings retain their IDs and have CORRECT new parent IDs. - assertThat(newIndex.parentIdOf(lvl1Sibling.id())) - .isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); - assertThat(newIndex.parentIdOf(midLvl2Sibling.id())) - .isEqualTo(org.pragmatica.lang.Option.some(newMid.id())); - // (b) Old ancestor chain IDs are GONE. - assertThat(newIndex.contains(oldRoot.id())).isFalse(); - assertThat(newIndex.contains(oldMid.id())).isFalse(); - assertThat(newIndex.contains(oldInner.id())).isFalse(); - assertThat(newIndex.contains(oldPivot.id())).isFalse(); - // New ancestor chain wired correctly. - assertThat(newIndex.parentIdOf(newMid.id())) - .isEqualTo(org.pragmatica.lang.Option.some(newRoot.id())); - assertThat(newIndex.parentIdOf(newInner.id())) - .isEqualTo(org.pragmatica.lang.Option.some(newMid.id())); - assertThat(newIndex.parentIdOf(newPivot.id())) - .isEqualTo(org.pragmatica.lang.Option.some(newInner.id())); - - // Cross-check with a fresh build. - var fresh = IdNodeIndex.build(newRoot); - assertThat(newIndex.size()).isEqualTo(fresh.size()); - } - - @Test - @DisplayName("Identity-preservation invariant: put count is O(δ), shared subtrees not re-walked") - void identity_preservation_microcount() { - // Construct an old tree where the splice path is at depth 3, and - // the splice point has TWO sibling subtrees flanking it, each - // record-identical between old and new trees. Each sibling - // subtree contains MANY internal nodes — the test asserts those - // internal nodes' parent entries are NOT touched (preserved - // verbatim) and the put count stays bounded by - // splicedSize + depth × branching. - // - // Old tree: - // root - // ├── leftBigSubtree (depth 1, record-shared, internally large) - // ├── mid (depth 1, replaced) - // │ ├── midLeftBigSubtree (depth 2, record-shared, internally large) - // │ ├── inner (depth 2, replaced) - // │ │ └── oldPivot (depth 3, replaced — the leaf splice target) - // │ └── midRightBigSubtree (depth 2, record-shared, internally large) - // └── rightBigSubtree (depth 1, record-shared, internally large) - var gen = new IdGenerator.PerSessionCounter(); - - // Build four "big" sibling subtrees (each: 1 NonTerminal + 5 leaf terminals = 6 nodes / 5 internal parent entries). - var leftBig = bigSubtree(gen, "leftBig", 5); - var midLeftBig = bigSubtree(gen, "midLeftBig", 5); - var midRightBig = bigSubtree(gen, "midRightBig", 5); - var rightBig = bigSubtree(gen, "rightBig", 5); - - var oldPivot = terminal(gen, "oldPivot"); - IdCstNode oldInner = nonTerminal(gen, "Inner", List.of(oldPivot)); - IdCstNode oldMid = nonTerminal(gen, "Mid", List.of(midLeftBig, oldInner, midRightBig)); - IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(leftBig, oldMid, rightBig)); - - var index = IdNodeIndex.build(oldRoot); - int totalNodes = countAll(oldRoot); - // sanity — total nodes well above any small-constant we'll assert. - assertThat(totalNodes).isGreaterThan(20); - - // Capture old parent links INSIDE the shared subtrees — they must be - // unchanged after applyIncremental (proves we did NOT walk into them). - var leftBigInternalChildIds = collectInternalChildIds(leftBig); - var midLeftBigInternalChildIds = collectInternalChildIds(midLeftBig); - - // New tree: oldPivot replaced; ancestor chain refreshed. - // CRITICAL: leftBig, midLeftBig, midRightBig, rightBig are REUSED (same object refs). - var newPivot = terminal(gen, "newPivot"); - IdCstNode newInner = nonTerminal(gen, "Inner", List.of(newPivot)); - IdCstNode newMid = nonTerminal(gen, "Mid", List.of(midLeftBig, newInner, midRightBig)); - IdCstNode newRoot = nonTerminal(gen, "Root", List.of(leftBig, newMid, rightBig)); - - var oldPath = List.of(oldRoot, oldMid, oldInner, (IdCstNode) oldPivot); - var newPath = List.of(newRoot, newMid, newInner, (IdCstNode) newPivot); - - var newIndex = index.applyIncremental(newRoot, oldPath, newPath); - - // -- Identity preservation: shared subtrees' internal entries are EXACTLY as before. - for (var e : leftBigInternalChildIds) { - assertThat(newIndex.parentIdOf(e.childId)) - .as("left big subtree internal child %d should still point to %d", e.childId, e.parentId) - .isEqualTo(org.pragmatica.lang.Option.some(e.parentId)); - } - for (var e : midLeftBigInternalChildIds) { - assertThat(newIndex.parentIdOf(e.childId)) - .as("midLeft big subtree internal child %d should still point to %d", e.childId, e.parentId) - .isEqualTo(org.pragmatica.lang.Option.some(e.parentId)); - } - - // -- Microcount: put count is O(splicedSize + depth × branching), NOT O(N). - // splicedSize = newPivot's subtree size (just newPivot, leaf) = 0 internal +1 (the pivot wired to its parent) = 1 - // depth (newPath.size()) = 4, max branching at any ancestor in newPath = 3 (Mid has 3 children) - // Bound: 1 + 4*3 = 13. We allow some slack with a generous bound: - int splicedSize = countAll(newPivot); // 1 - int depthBranchingBound = newPath.size() * 3 /* max branching */; - int generousBound = splicedSize + depthBranchingBound + 4 /* slack */; - assertThat(newIndex.lastIncrementalPutCount) - .as("put count must be O(splicedSize + depth × branching), NOT O(N=%d)", totalNodes) - .isLessThanOrEqualTo(generousBound) - .isLessThan(totalNodes); // strictly less than full rebuild - // Print for diagnostic — captured in test report. - System.out.println("[Phase 0c] depth-3 incremental put count = " - + newIndex.lastIncrementalPutCount - + " (vs full-rebuild N = " + totalNodes + ")"); - } - - @Test - @DisplayName("Equivalence to full rebuild: applyIncremental matches build(newRoot) exactly") - void equivalence_to_full_rebuild() { - // Random-ish 3-level tree, splice at depth 2. - var gen = new IdGenerator.PerSessionCounter(); - var leaf1 = terminal(gen, "1"); - var leaf2 = terminal(gen, "2"); - var leaf3 = terminal(gen, "3"); - var leaf4 = terminal(gen, "4"); - IdCstNode oldChildA = nonTerminal(gen, "A", List.of(leaf1, leaf2)); - IdCstNode oldChildB = nonTerminal(gen, "B", List.of(leaf3, leaf4)); - IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(oldChildA, oldChildB)); - - var index = IdNodeIndex.build(oldRoot); - - // Replace child B with a fresh subtree. - var newLeaf5 = terminal(gen, "5"); - var newLeaf6 = terminal(gen, "6"); - var newLeaf7 = terminal(gen, "7"); - IdCstNode newChildB = nonTerminal(gen, "B", List.of(newLeaf5, newLeaf6, newLeaf7)); - IdCstNode newRoot = nonTerminal(gen, "Root", List.of(oldChildA, newChildB)); - - var oldPath = List.of(oldRoot, oldChildB); - var newPath = List.of(newRoot, newChildB); - - var incremental = index.applyIncremental(newRoot, oldPath, newPath); - var rebuilt = IdNodeIndex.build(newRoot); - - // Both must agree on every node in the new tree. - for (var node : flatten(newRoot)) { - assertThat(incremental.parentIdOf(node.id())) - .as("parent of node id %d (rule %s)", node.id(), node.rule()) - .isEqualTo(rebuilt.parentIdOf(node.id())); - } - assertThat(incremental.size()).isEqualTo(rebuilt.size()); - // Old child B and its old descendants are gone from incremental (and absent from rebuilt). - assertThat(incremental.contains(oldChildB.id())).isFalse(); - assertThat(incremental.contains(leaf3.id())).isFalse(); - assertThat(incremental.contains(leaf4.id())).isFalse(); - } - } - - // -- helpers -- - - private static IdCstNode bigSubtree(IdGenerator gen, String rule, int leafCount) { - var children = new ArrayList(leafCount); - for (int i = 0; i < leafCount; i++) { - children.add(terminal(gen, rule + "-leaf-" + i)); - } - return nonTerminal(gen, rule, children); - } - - private static int countAll(IdCstNode node) { - int c = 1; - if (node instanceof IdCstNode.NonTerminal nt) { - for (var ch : nt.children()) { - c += countAll(ch); - } - } - return c; - } - - private record ParentLink(long childId, long parentId) {} - - /** Collect every (child.id → parent.id) link strictly inside {@code root}'s subtree. */ - private static List collectInternalChildIds(IdCstNode root) { - var out = new ArrayList(); - collectInternalChildIdsInto(root, out); - return out; - } - - private static void collectInternalChildIdsInto(IdCstNode node, List out) { - if (node instanceof IdCstNode.NonTerminal nt) { - for (var ch : nt.children()) { - out.add(new ParentLink(ch.id(), nt.id())); - collectInternalChildIdsInto(ch, out); - } - } - } - - private static List flatten(IdCstNode root) { - var out = new ArrayList(); - flattenInto(root, out); - return out; - } - - private static void flattenInto(IdCstNode node, List out) { - out.add(node); - if (node instanceof IdCstNode.NonTerminal nt) { - for (var ch : nt.children()) { - flattenInto(ch, out); - } - } - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicerTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicerTest.java deleted file mode 100644 index 068958c..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/IdTreeSplicerTest.java +++ /dev/null @@ -1,297 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Phase 0d.1 — GO/NO-GO gate for the spec §8 Q3 identity-preservation - * invariant. After splice, every sibling subtree of every node on the splice - * path must satisfy reference equality ({@code ==}) with the corresponding - * pre-edit subtree. - * - *

Without this invariant, the O(δ) cost claim of - * {@link IdNodeIndex#applyIncremental} collapses — the index update would have - * to re-walk re-allocated subtrees, and we'd be back to {@code O(N)}. - */ -final class IdTreeSplicerTest { - private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); - private static final List NO_TRIVIA = List.of(); - - private static IdCstNode.Terminal terminal(IdGenerator gen, String text) { - return new IdCstNode.Terminal(gen.next(), SPAN, "T", text, NO_TRIVIA, NO_TRIVIA); - } - - private static IdCstNode.NonTerminal nonTerminal(IdGenerator gen, String rule, List children) { - return new IdCstNode.NonTerminal(gen.next(), SPAN, rule, children, NO_TRIVIA, NO_TRIVIA); - } - - @Nested - @DisplayName("splice") - class SpliceTests { - - @Test - @DisplayName("Pivot is root: returns newPivot directly, newPath = [newPivot]") - void pivot_as_root() { - var gen = new IdGenerator.PerSessionCounter(); - IdCstNode oldRoot = terminal(gen, "old"); - IdCstNode newPivot = terminal(gen, "new"); - - var splicer = new IdTreeSplicer(gen); - var result = splicer.splice(List.of(oldRoot), newPivot); - - assertThat(result.newRoot()).isSameAs(newPivot); - assertThat(result.newPath()).hasSize(1); - assertThat(result.newPath().get(0)).isSameAs(newPivot); - } - - @Test - @DisplayName("Single-level splice: 3-child root, middle replaced; flanking siblings reference-shared") - void single_level_splice() { - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "A"); - var b = terminal(gen, "B"); - var c = terminal(gen, "C"); - IdCstNode root = nonTerminal(gen, "Root", List.of(a, b, c)); - - IdCstNode bPrime = terminal(gen, "B'"); - - var splicer = new IdTreeSplicer(gen); - var result = splicer.splice(List.of(root, b), bPrime); - - // Root has fresh ID — different record, but same rule/span/trivia. - assertThat(result.newRoot()).isNotSameAs(root); - assertThat(result.newRoot()).isInstanceOf(IdCstNode.NonTerminal.class); - - var newRootNT = (IdCstNode.NonTerminal) result.newRoot(); - assertThat(newRootNT.id()).isNotEqualTo(root.id()); - assertThat(newRootNT.rule()).isEqualTo("Root"); - assertThat(newRootNT.children()).hasSize(3); - - // Identity preservation: A and C are reference-shared. - assertThat(newRootNT.children().get(0)).isSameAs(a); - assertThat(newRootNT.children().get(2)).isSameAs(c); - // The spliced slot holds bPrime. - assertThat(newRootNT.children().get(1)).isSameAs(bPrime); - - // newPath = [newRoot, newPivot]. - assertThat(result.newPath()).hasSize(2); - assertThat(result.newPath().get(0)).isSameAs(result.newRoot()); - assertThat(result.newPath().get(1)).isSameAs(bPrime); - } - - @Test - @DisplayName("Deep splice (4-level): for every path node, all non-spliced siblings reference-shared") - void deep_splice_siblings_preserved() { - // Tree: - // root - // ├── lvl1Sib (preserved sibling at depth 1) - // └── mid (on splice path) - // ├── midSib1 (preserved sibling at depth 2) - // ├── inner (on splice path) - // │ ├── innerSibA (preserved sibling at depth 3) - // │ ├── pivot (REPLACED) - // │ └── innerSibB (preserved sibling at depth 3) - // └── midSib2 (preserved sibling at depth 2) - var gen = new IdGenerator.PerSessionCounter(); - var lvl1Sib = terminal(gen, "lvl1Sib"); - var midSib1 = terminal(gen, "midSib1"); - var midSib2 = terminal(gen, "midSib2"); - var innerSibA = terminal(gen, "innerSibA"); - var innerSibB = terminal(gen, "innerSibB"); - var pivot = terminal(gen, "PIVOT"); - IdCstNode inner = nonTerminal(gen, "Inner", List.of(innerSibA, pivot, innerSibB)); - IdCstNode mid = nonTerminal(gen, "Mid", List.of(midSib1, inner, midSib2)); - IdCstNode root = nonTerminal(gen, "Root", List.of(lvl1Sib, mid)); - - IdCstNode newPivot = terminal(gen, "NEW_PIVOT"); - var oldPath = List.of(root, mid, inner, (IdCstNode) pivot); - - var splicer = new IdTreeSplicer(gen); - var result = splicer.splice(oldPath, newPivot); - - // newPath structurally parallel to oldPath. - assertThat(result.newPath()).hasSize(4); - assertThat(result.newPath().get(0)).isSameAs(result.newRoot()); - assertThat(result.newPath().get(3)).isSameAs(newPivot); - - // -- depth 1: root has [lvl1Sib, newMid]. lvl1Sib reference-shared. - var newRootNT = (IdCstNode.NonTerminal) result.newRoot(); - assertThat(newRootNT.children().get(0)).isSameAs(lvl1Sib); - assertThat(newRootNT.children().get(1)).isSameAs(result.newPath().get(1)); - - // -- depth 2: newMid has [midSib1, newInner, midSib2]. Both midSibs reference-shared. - var newMidNT = (IdCstNode.NonTerminal) result.newPath().get(1); - assertThat(newMidNT.children().get(0)).isSameAs(midSib1); - assertThat(newMidNT.children().get(1)).isSameAs(result.newPath().get(2)); - assertThat(newMidNT.children().get(2)).isSameAs(midSib2); - - // -- depth 3: newInner has [innerSibA, newPivot, innerSibB]. Both innerSibs reference-shared. - var newInnerNT = (IdCstNode.NonTerminal) result.newPath().get(2); - assertThat(newInnerNT.children().get(0)).isSameAs(innerSibA); - assertThat(newInnerNT.children().get(1)).isSameAs(newPivot); - assertThat(newInnerNT.children().get(2)).isSameAs(innerSibB); - - // -- old ancestor records are NOT reused (each replaced with fresh ID). - assertThat(newRootNT.id()).isNotEqualTo(((IdCstNode.NonTerminal) root).id()); - assertThat(newMidNT.id()).isNotEqualTo(((IdCstNode.NonTerminal) mid).id()); - assertThat(newInnerNT.id()).isNotEqualTo(((IdCstNode.NonTerminal) inner).id()); - } - - @Test - @DisplayName("Identity invariant under iteration: count preserved siblings exactly") - void identity_preservation_under_iteration() { - // Build a 4-deep, 4-wide tree, splice ONE leaf, count preserved - // sibling references along the splice path. - // - // Path: root → lvl1[idx=1] → lvl2[idx=2] → lvl3[idx=3] → pivot - // At each level, branching = 4 → 3 siblings preserved per level. - // Total preserved direct siblings on path = 3 * 4 = 12. - // Plus deep subtrees under those siblings — also reference-shared. - var gen = new IdGenerator.PerSessionCounter(); - - var leaves = new ArrayList(); - for (int i = 0; i < 16; i++) { - leaves.add(terminal(gen, "leaf-" + i)); - } - // lvl3 nodes: 4 NonTerminals, each holding 4 leaves. - // We need one specific lvl3 to become the splice path's lvl3 and - // hold the pivot at index 3. - var lvl3Nodes = new ArrayList(); - for (int g = 0; g < 4; g++) { - var slice = new ArrayList(4); - for (int j = 0; j < 4; j++) { - slice.add(leaves.get(g * 4 + j)); - } - lvl3Nodes.add(nonTerminal(gen, "lvl3-" + g, slice)); - } - // lvl2 wraps lvl3 nodes with 4 children — reuse lvl3Nodes 4-by-4. - // To keep branching = 4 at lvl2 too, we need 4 lvl3 nodes per lvl2 - // and 4 lvl2 nodes total. Simplification: reuse the same lvl3 set - // BUT one lvl2 holds the splice path's lvl3 at index 2. - // To keep distinct subtrees, build 4 lvl2's each with 4 NEW lvl3 - // nodes. That's 16 lvl3 nodes total → 64 leaves. - var lvl2Nodes = new ArrayList(); - for (int b = 0; b < 4; b++) { - var lvl3Slice = new ArrayList(4); - for (int g = 0; g < 4; g++) { - var slice = new ArrayList(4); - for (int j = 0; j < 4; j++) { - slice.add(terminal(gen, "leaf-" + b + "-" + g + "-" + j)); - } - lvl3Slice.add(nonTerminal(gen, "lvl3-" + b + "-" + g, slice)); - } - lvl2Nodes.add(nonTerminal(gen, "lvl2-" + b, lvl3Slice)); - } - IdCstNode root = nonTerminal(gen, "Root", lvl2Nodes); - - // Splice path: root → lvl2Nodes.get(1) → its children.get(2) → its children.get(3) → pivot - var lvl1OnPath = lvl2Nodes.get(1); - var lvl2OnPath = ((IdCstNode.NonTerminal) lvl1OnPath).children().get(2); - var lvl3OnPath = ((IdCstNode.NonTerminal) lvl2OnPath).children().get(3); - - IdCstNode newPivot = terminal(gen, "NEW_PIVOT"); - var oldPath = List.of(root, lvl1OnPath, lvl2OnPath, lvl3OnPath); - - var splicer = new IdTreeSplicer(gen); - var result = splicer.splice(oldPath, newPivot); - - // Count siblings preserved by reference at every path level. - int preservedCount = 0; - int falseShareCount = 0; // would indicate corruption - for (int depth = 0; depth < oldPath.size() - 1; depth++) { - var oldAncestor = (IdCstNode.NonTerminal) oldPath.get(depth); - var newAncestor = (IdCstNode.NonTerminal) result.newPath().get(depth); - var oldChildren = oldAncestor.children(); - var newChildren = newAncestor.children(); - assertThat(newChildren).hasSize(oldChildren.size()); - int splicedIdx = oldChildren.indexOf(oldPath.get(depth + 1)); - for (int k = 0; k < newChildren.size(); k++) { - if (k == splicedIdx) { - // Spliced slot must NOT be == old (it was replaced). - if (newChildren.get(k) == oldChildren.get(k)) { - falseShareCount++; - } - } else { - // Non-spliced slots must be == old. - if (newChildren.get(k) == oldChildren.get(k)) { - preservedCount++; - } - } - } - } - - // 4 levels × (4 children - 1 spliced) = 12 preserved siblings. - // (We have 4-3-2-1 indexing: oldPath[0]=root has 4 children, sliced-at-index-1; 3 preserved. - // oldPath[1]=lvl1OnPath has 4 children, sliced-at-2; 3 preserved. - // oldPath[2]=lvl2OnPath has 4 children, sliced-at-3; 3 preserved. - // Total preserved = 9. The pivot's level (oldPath[3]) is not iterated — pivot itself replaced.) - int pathDepthsWithSiblings = oldPath.size() - 1; // 3 - int expectedPreserved = pathDepthsWithSiblings * 3; // 4-1 - assertThat(preservedCount) - .as("every non-spliced sibling at every depth must be reference-shared") - .isEqualTo(expectedPreserved); - assertThat(falseShareCount) - .as("no false-share: the spliced slot must NOT == old slot") - .isZero(); - } - - @Test - @DisplayName("Mismatched path: child not in parent's children list throws IllegalStateException") - void mismatched_path_throws() { - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "A"); - var b = terminal(gen, "B"); - // bogus node NOT in root's children. - var bogus = terminal(gen, "BOGUS"); - IdCstNode root = nonTerminal(gen, "Root", List.of(a, b)); - - var splicer = new IdTreeSplicer(gen); - IdCstNode newPivot = terminal(gen, "NEW"); - - assertThatThrownBy(() -> splicer.splice(List.of(root, bogus), newPivot)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("splice path broken"); - } - - @Test - @DisplayName("Empty path: throws IllegalArgumentException") - void empty_path_throws() { - var gen = new IdGenerator.PerSessionCounter(); - var splicer = new IdTreeSplicer(gen); - IdCstNode newPivot = terminal(gen, "NEW"); - - assertThatThrownBy(() -> splicer.splice(List.of(), newPivot)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("oldPath"); - - assertThatThrownBy(() -> splicer.splice(null, newPivot)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - @DisplayName("Non-NonTerminal in middle of path throws IllegalStateException") - void non_nonterminal_in_path_throws() { - // A Terminal cannot have children; if it appears in the middle of - // oldPath as a parent of the pivot, we must fail loudly. - var gen = new IdGenerator.PerSessionCounter(); - var leaf = terminal(gen, "leaf"); - var rogueChild = terminal(gen, "rogue"); - // leaf does NOT contain rogueChild — but more importantly, leaf is not a NonTerminal. - var splicer = new IdTreeSplicer(gen); - IdCstNode newPivot = terminal(gen, "NEW"); - - assertThatThrownBy(() -> splicer.splice(List.of((IdCstNode) leaf, rogueChild), newPivot)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("not a NonTerminal"); - } - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java deleted file mode 100644 index 0eee155..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/LongLongMapTest.java +++ /dev/null @@ -1,263 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import java.util.HashMap; -import java.util.Random; -import java.util.concurrent.TimeUnit; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase 0a foundation tests for {@link LinearProbingLongLongMap}. Covers the - * whole {@link LongLongMap} contract plus implementation-specific concerns: - * resize correctness, tombstone behaviour, hash-collision stress. - */ -final class LongLongMapTest { - @Test - @DisplayName("Empty map: size 0, no key contained, get returns MISSING") - void empty_map_state() { - LongLongMap map = new LinearProbingLongLongMap(); - assertThat(map.size()).isZero(); - assertThat(map.containsKey(0L)).isFalse(); - assertThat(map.get(0L)).isEqualTo(LongLongMap.MISSING); - assertThat(map.containsKey(Long.MAX_VALUE)).isFalse(); - assertThat(map.containsKey(- 1L)).isFalse(); - } - - @Test - @DisplayName("put then get returns the stored value, containsKey true") - void put_then_get() { - LongLongMap map = new LinearProbingLongLongMap(); - map.put(5L, 100L); - assertThat(map.get(5L)).isEqualTo(100L); - assertThat(map.containsKey(5L)).isTrue(); - assertThat(map.size()).isEqualTo(1); - } - - @Test - @DisplayName("put on existing key overwrites, size unchanged") - void put_overwrites_existing_key() { - LongLongMap map = new LinearProbingLongLongMap(); - map.put(5L, 100L); - map.put(5L, 200L); - assertThat(map.get(5L)).isEqualTo(200L); - assertThat(map.size()).isEqualTo(1); - } - - @Test - @DisplayName("1000 random distinct keys all retrievable") - void thousand_random_keys() { - LongLongMap map = new LinearProbingLongLongMap(); - var rng = new Random(0xCAFEBABEL); - var oracle = new HashMap(); - while (oracle.size() < 1000) { - long k = Math.abs(rng.nextLong() % Long.MAX_VALUE); - if (oracle.containsKey(k)) { - continue; - } - long v = rng.nextLong(); - oracle.put(k, v); - map.put(k, v); - } - assertThat(map.size()).isEqualTo(1000); - for (var entry : oracle.entrySet()) { - assertThat(map.containsKey(entry.getKey())).isTrue(); - assertThat(map.get(entry.getKey())).isEqualTo(entry.getValue()); - } - } - - @Test - @DisplayName("Pre-sized to 16 still grows correctly to hold 100 entries") - void resize_from_small_initial_capacity() { - LongLongMap map = new LinearProbingLongLongMap(16); - for (long k = 0; k < 100; k++) { - map.put(k, k * 10); - } - assertThat(map.size()).isEqualTo(100); - for (long k = 0; k < 100; k++) { - assertThat(map.containsKey(k)).isTrue(); - assertThat(map.get(k)).isEqualTo(k * 10); - } - } - - @Test - @DisplayName("remove on present key clears it, size decremented, neighbours intact") - void remove_present_key() { - LongLongMap map = new LinearProbingLongLongMap(); - map.put(1L, 10L); - map.put(2L, 20L); - map.put(3L, 30L); - map.remove(2L); - assertThat(map.containsKey(2L)).isFalse(); - assertThat(map.get(2L)).isEqualTo(LongLongMap.MISSING); - assertThat(map.size()).isEqualTo(2); - assertThat(map.get(1L)).isEqualTo(10L); - assertThat(map.get(3L)).isEqualTo(30L); - } - - @Test - @DisplayName("remove on absent key is a no-op") - void remove_absent_key_is_noop() { - LongLongMap map = new LinearProbingLongLongMap(); - map.put(1L, 10L); - map.remove(999L); - assertThat(map.size()).isEqualTo(1); - assertThat(map.get(1L)).isEqualTo(10L); - } - - @Test - @DisplayName("remove then put same key returns new value") - void remove_then_put_same_key() { - LongLongMap map = new LinearProbingLongLongMap(); - map.put(7L, 70L); - map.remove(7L); - map.put(7L, 700L); - assertThat(map.get(7L)).isEqualTo(700L); - assertThat(map.size()).isEqualTo(1); - } - - @Test - @DisplayName("Tombstone correctness: alternating put/remove of 1000 distinct keys") - void tombstone_correctness_under_alternating_ops() { - LongLongMap map = new LinearProbingLongLongMap(16); - var rng = new Random(0xDEADBEEFL); - var oracle = new HashMap(); - long[] keys = new long[1000]; - for (int i = 0; i < keys.length; i++) { - keys[i] = (long) i * 31 + rng.nextInt(7); - } - for (int i = 0; i < keys.length; i++) { - long k = keys[i]; - if ((i & 1) == 0) { - long v = rng.nextLong(); - map.put(k, v); - oracle.put(k, v); - } else { - map.remove(k); - oracle.remove(k); - } - } - // Drive a final put on every key so the surviving state is well-defined. - for (long k : keys) { - map.put(k, k + 1); - oracle.put(k, k + 1); - } - assertThat(map.size()).isEqualTo(oracle.size()); - for (var entry : oracle.entrySet()) { - assertThat(map.get(entry.getKey())).isEqualTo(entry.getValue()); - } - } - - @Test - @DisplayName("clear empties the map; keys no longer contained") - void clear_empties_map() { - LongLongMap map = new LinearProbingLongLongMap(); - for (long k = 0; k < 50; k++) { - map.put(k, k); - } - map.clear(); - assertThat(map.size()).isZero(); - for (long k = 0; k < 50; k++) { - assertThat(map.containsKey(k)).isFalse(); - assertThat(map.get(k)).isEqualTo(LongLongMap.MISSING); - } - // Map remains usable after clear. - map.put(123L, 456L); - assertThat(map.get(123L)).isEqualTo(456L); - } - - @Test - @DisplayName("copy is independent: mutations on original do not affect copy") - void copy_independence() { - LongLongMap original = new LinearProbingLongLongMap(); - original.put(1L, 10L); - original.put(2L, 20L); - original.put(3L, 30L); - LongLongMap copy = original.copy(); - original.put(1L, 999L); - original.remove(2L); - original.put(99L, 9900L); - assertThat(copy.size()).isEqualTo(3); - assertThat(copy.get(1L)).isEqualTo(10L); - assertThat(copy.get(2L)).isEqualTo(20L); - assertThat(copy.get(3L)).isEqualTo(30L); - assertThat(copy.containsKey(99L)).isFalse(); - // Mutating the copy must not leak back into original either. - copy.put(7L, 70L); - assertThat(original.containsKey(7L)).isFalse(); - } - - @Test - @DisplayName("Hash-collision stress: keys hashing to identical slot all retrievable") - void hash_collision_stress() { - // capacity is rounded up to power-of-two >= 16; for capacity 16, slot = hash & 15. - // Long.hashCode(k) = (int)(k ^ (k >>> 32)). For k = 0, 16, 32, 48 (small longs) - // the upper 32 bits are 0 so hashCode == (int) k, and (int) k & 15 == 0. - // All four keys collide in slot 0 — exercises linear probing. - LongLongMap map = new LinearProbingLongLongMap(16); - long[] colliders = {0L, 16L, 32L, 48L, 64L, 80L, 96L}; - for (int i = 0; i < colliders.length; i++) { - map.put(colliders[i], (long)(i + 1) * 1000L); - } - assertThat(map.size()).isEqualTo(colliders.length); - for (int i = 0; i < colliders.length; i++) { - assertThat(map.containsKey(colliders[i])).isTrue(); - assertThat(map.get(colliders[i])).isEqualTo((long)(i + 1) * 1000L); - } - // Remove from the middle of the probe chain; remaining keys must stay reachable. - map.remove(32L); - assertThat(map.containsKey(32L)).isFalse(); - for (long k : colliders) { - if (k == 32L) { - continue; - } - assertThat(map.containsKey(k)).as("key %d after removing 32", k) - .isTrue(); - } - // Re-insert across the tombstone — still finds itself. - map.put(32L, 12345L); - assertThat(map.get(32L)).isEqualTo(12345L); - } - - @Test - @DisplayName("Tombstone accumulation does NOT hang put (regression: Phase 1.5)") - @Timeout(value = 10, unit = TimeUnit.SECONDS) - void doesNotHangOnTombstoneAccumulation() { - // Pattern: insert N keys, remove half, insert another N. Without the - // tombstone-aware resize, this hangs (the second insert phase finds - // no EMPTY slot once tombstones + occupied saturate the table). - var map = new LinearProbingLongLongMap(16); - // small table to stress - int n = 200; - for (int i = 0; i < n; i++) { - map.put(i, i); - } - for (int i = 0; i < n; i += 2) { - map.remove(i); - } - for (int i = n; i < 2 * n; i++) { - map.put(i, i); - } - assertThat(map.size()).isEqualTo(n + n / 2); - } - - @Test - @DisplayName("Negative keys behave like any other long") - void negative_keys_supported() { - LongLongMap map = new LinearProbingLongLongMap(); - map.put(- 1L, 100L); - map.put(- 1000000L, 200L); - map.put(Long.MIN_VALUE + 1, 300L); - // avoid colliding with MISSING semantics - assertThat(map.get(- 1L)).isEqualTo(100L); - assertThat(map.get(- 1000000L)).isEqualTo(200L); - assertThat(map.get(Long.MIN_VALUE + 1)).isEqualTo(300L); - assertThat(map.size()).isEqualTo(3); - map.remove(- 1000000L); - assertThat(map.containsKey(- 1000000L)).isFalse(); - assertThat(map.size()).isEqualTo(2); - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java deleted file mode 100644 index 6a29c2d..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledNodeTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.tree.Trivia; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Minimal contract tests for {@link OffsetDecoupledNode}. The path-A spike - * design is exhaustively exercised by {@link OffsetDecoupledSplicerTest}; - * this file pins the equality contract and sealed-switch exhaustiveness. - */ -final class OffsetDecoupledNodeTest { - private static final List NO_TRIVIA = List.of(); - - @Test - @DisplayName("Terminal: constructs and exposes its components") - void terminal_construct() { - var t = new OffsetDecoupledNode.Terminal(1L, "Lit", "x", NO_TRIVIA, NO_TRIVIA); - assertThat(t.id()).isEqualTo(1L); - assertThat(t.rule()).isEqualTo("Lit"); - assertThat(t.text()).isEqualTo("x"); - assertThat(t.leadingTrivia()).isEmpty(); - assertThat(t.trailingTrivia()).isEmpty(); - } - - @Test - @DisplayName("NonTerminal: constructs with children") - void nonterminal_construct() { - var c1 = new OffsetDecoupledNode.Terminal(1L, "T", "a", NO_TRIVIA, NO_TRIVIA); - var c2 = new OffsetDecoupledNode.Terminal(2L, "T", "b", NO_TRIVIA, NO_TRIVIA); - var nt = new OffsetDecoupledNode.NonTerminal(3L, "Pair", List.of(c1, c2), NO_TRIVIA, NO_TRIVIA); - - assertThat(nt.id()).isEqualTo(3L); - assertThat(nt.rule()).isEqualTo("Pair"); - assertThat(nt.children()).hasSize(2); - assertThat(nt.children().get(0)).isSameAs(c1); - assertThat(nt.children().get(1)).isSameAs(c2); - } - - @Test - @DisplayName("Token: constructs and exposes text") - void token_construct() { - var tok = new OffsetDecoupledNode.Token(1L, "Number", "42", NO_TRIVIA, NO_TRIVIA); - assertThat(tok.id()).isEqualTo(1L); - assertThat(tok.rule()).isEqualTo("Number"); - assertThat(tok.text()).isEqualTo("42"); - } - - @Test - @DisplayName("Equality excludes id (R1: ids are metadata)") - void equality_excludes_id() { - var a = new OffsetDecoupledNode.Terminal(1L, "T", "x", NO_TRIVIA, NO_TRIVIA); - var b = new OffsetDecoupledNode.Terminal(2L, "T", "x", NO_TRIVIA, NO_TRIVIA); - var c = new OffsetDecoupledNode.Terminal(3L, "T", "y", NO_TRIVIA, NO_TRIVIA); - - assertThat(a).isEqualTo(b); - assertThat(a.hashCode()).isEqualTo(b.hashCode()); - assertThat(a).isNotEqualTo(c); - } - - @Test - @DisplayName("Different variants are never equal even if structural fields match") - void variants_never_cross_equal() { - var term = new OffsetDecoupledNode.Terminal(1L, "T", "x", NO_TRIVIA, NO_TRIVIA); - var tok = new OffsetDecoupledNode.Token(1L, "T", "x", NO_TRIVIA, NO_TRIVIA); - - assertThat(term).isNotEqualTo(tok); - assertThat(tok).isNotEqualTo(term); - } - - @Test - @DisplayName("Sealed switch is exhaustive over all three variants") - void sealed_switch_exhaustive() { - OffsetDecoupledNode term = new OffsetDecoupledNode.Terminal(1L, "T", "x", NO_TRIVIA, NO_TRIVIA); - OffsetDecoupledNode tok = new OffsetDecoupledNode.Token(2L, "T", "y", NO_TRIVIA, NO_TRIVIA); - OffsetDecoupledNode nt = new OffsetDecoupledNode.NonTerminal(3L, "Pair", List.of(), NO_TRIVIA, NO_TRIVIA); - - assertThat(label(term)).isEqualTo("Terminal"); - assertThat(label(tok)).isEqualTo("Token"); - assertThat(label(nt)).isEqualTo("NonTerminal"); - } - - private static String label(OffsetDecoupledNode node) { - return switch (node) { - case OffsetDecoupledNode.Terminal t -> "Terminal"; - case OffsetDecoupledNode.Token t -> "Token"; - case OffsetDecoupledNode.NonTerminal n -> "NonTerminal"; - }; - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java deleted file mode 100644 index 589825c..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/OffsetDecoupledSplicerTest.java +++ /dev/null @@ -1,279 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.tree.Trivia; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Phase 1.0/1.1 — GO/NO-GO gate for the path-A correctness invariant. - * - *

Central claim under test: after splice, every sibling - * of every node on the splice path satisfies reference equality ({@code ==}) - * with its pre-edit record — including siblings to the RIGHT of the edit - * whose offsets must shift. The shift happens on the {@link SpanIndex} - * side, not by record rebuild. - * - *

Without this invariant, path A doesn't pay off: rebuilding right-of-edit - * siblings is exactly what production {@code TreeSplicer.spliceAndShift} - * already does, and the bench would just re-measure the existing 0.4.3 cost - * with extra primitive-array overhead. - */ -final class OffsetDecoupledSplicerTest { - private static final List NO_TRIVIA = List.of(); - - private static OffsetDecoupledNode.Terminal terminal(IdGenerator gen, String text) { - return new OffsetDecoupledNode.Terminal(gen.next(), "T", text, NO_TRIVIA, NO_TRIVIA); - } - - private static OffsetDecoupledNode.NonTerminal nonTerminal( - IdGenerator gen, String rule, List children) { - return new OffsetDecoupledNode.NonTerminal(gen.next(), rule, children, NO_TRIVIA, NO_TRIVIA); - } - - @Nested - @DisplayName("identity preservation") - class IdentityTests { - - @Test - @DisplayName("Right-of-edit siblings preserve identity AND have shifted spans") - void rightOfEditSiblingsPreserveIdentity() { - // Tree: Root[A, target, C] - // A spans [0, 5) - // target spans [5, 10) - // C spans [10, 20) <-- right of edit - // Edit: replace target with newPivot ([5, 13)) — delta = +3, editEnd = 10. - // Expectation: - // - newRoot.children().get(0) == A (left, identity preserved) - // - newRoot.children().get(2) == C (RIGHT, identity preserved — path A claim) - // - newSpans.startOffset(C.id()) == 13 (10 + 3) - // - newSpans.endOffset(C.id()) == 23 (20 + 3) - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "A"); - var target = terminal(gen, "target"); - var c = terminal(gen, "C"); - var root = nonTerminal(gen, "Root", List.of(a, target, c)); - - var oldSpans = new SpanIndex(16); - oldSpans.put(a.id(), 0, 5); - oldSpans.put(target.id(), 5, 10); - oldSpans.put(c.id(), 10, 20); - oldSpans.put(root.id(), 0, 20); - - var newPivot = terminal(gen, "newTarget"); - // Caller registers newPivot's span before splicing. - oldSpans.put(newPivot.id(), 5, 13); - - var splicer = new OffsetDecoupledSplicer(gen); - var result = splicer.splice(oldSpans, List.of(root, target), newPivot, /*editEnd=*/10, /*delta=*/3); - - // Root has fresh ID — different record. - var newRoot = (OffsetDecoupledNode.NonTerminal) result.newRoot(); - assertThat(newRoot.id()).isNotEqualTo(root.id()); - assertThat(newRoot.children()).hasSize(3); - - // Identity preservation — left and RIGHT. - assertThat(newRoot.children().get(0)).isSameAs(a); - assertThat(newRoot.children().get(1)).isSameAs(newPivot); - assertThat(newRoot.children().get(2)).isSameAs(c); // *** THE PATH-A CLAIM *** - - // Spans for surviving siblings shifted on the SpanIndex side. - var newSpans = result.newSpans(); - assertThat(newSpans.startOffset(a.id())).isEqualTo(0); // left, untouched - assertThat(newSpans.endOffset(a.id())).isEqualTo(5); - assertThat(newSpans.startOffset(c.id())).isEqualTo(13); // RIGHT, shifted - assertThat(newSpans.endOffset(c.id())).isEqualTo(23); - - // newPivot span: caller-registered, NOT in shift range (start=5 < editEnd=10). - assertThat(newSpans.startOffset(newPivot.id())).isEqualTo(5); - assertThat(newSpans.endOffset(newPivot.id())).isEqualTo(13); - - // Ancestor span: start unchanged, end extended by delta. - assertThat(newSpans.startOffset(newRoot.id())).isEqualTo(0); - assertThat(newSpans.endOffset(newRoot.id())).isEqualTo(23); - } - - @Test - @DisplayName("Old SpanIndex receiver remains valid (independent copy)") - void receiver_independence() { - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "A"); - var target = terminal(gen, "target"); - var root = nonTerminal(gen, "Root", List.of(a, target)); - - var oldSpans = new SpanIndex(8); - oldSpans.put(a.id(), 0, 5); - oldSpans.put(target.id(), 5, 10); - oldSpans.put(root.id(), 0, 10); - - var newPivot = terminal(gen, "newTarget"); - oldSpans.put(newPivot.id(), 5, 12); - - var splicer = new OffsetDecoupledSplicer(gen); - splicer.splice(oldSpans, List.of(root, target), newPivot, 10, 2); - - // Receiver still reports pre-edit offsets. - assertThat(oldSpans.startOffset(target.id())).isEqualTo(5); - assertThat(oldSpans.endOffset(target.id())).isEqualTo(10); - } - } - - @Nested - @DisplayName("edge cases") - class EdgeCases { - - @Test - @DisplayName("Pivot is root → returns newPivot, no ancestor rebuild, but shift still applied") - void pivot_as_root() { - var gen = new IdGenerator.PerSessionCounter(); - var oldRoot = terminal(gen, "old"); - - var oldSpans = new SpanIndex(4); - oldSpans.put(oldRoot.id(), 0, 5); - - var newPivot = terminal(gen, "new"); - oldSpans.put(newPivot.id(), 0, 7); - - var splicer = new OffsetDecoupledSplicer(gen); - var result = splicer.splice(oldSpans, List.of(oldRoot), newPivot, 5, 2); - - assertThat(result.newRoot()).isSameAs(newPivot); - assertThat(result.newPath()).hasSize(1); - assertThat(result.newPath().get(0)).isSameAs(newPivot); - // Pivot's own registered span is unchanged (start < editEnd). - assertThat(result.newSpans().startOffset(newPivot.id())).isEqualTo(0); - assertThat(result.newSpans().endOffset(newPivot.id())).isEqualTo(7); - } - - @Test - @DisplayName("delta = 0 → spans unchanged, splice still rebuilds path with fresh ids") - void zero_delta_path() { - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "A"); - var target = terminal(gen, "target"); - var c = terminal(gen, "C"); - var root = nonTerminal(gen, "Root", List.of(a, target, c)); - - var oldSpans = new SpanIndex(8); - oldSpans.put(a.id(), 0, 5); - oldSpans.put(target.id(), 5, 10); - oldSpans.put(c.id(), 10, 20); - oldSpans.put(root.id(), 0, 20); - - // Replace target with same-length newPivot. - var newPivot = terminal(gen, "same"); - oldSpans.put(newPivot.id(), 5, 10); - - var splicer = new OffsetDecoupledSplicer(gen); - var result = splicer.splice(oldSpans, List.of(root, target), newPivot, 10, 0); - - var newRoot = (OffsetDecoupledNode.NonTerminal) result.newRoot(); - assertThat(newRoot.id()).isNotEqualTo(root.id()); // fresh ancestor id - assertThat(newRoot.children().get(0)).isSameAs(a); - assertThat(newRoot.children().get(2)).isSameAs(c); - - // Spans unchanged. - assertThat(result.newSpans().startOffset(c.id())).isEqualTo(10); - assertThat(result.newSpans().endOffset(c.id())).isEqualTo(20); - assertThat(result.newSpans().startOffset(newRoot.id())).isEqualTo(0); - assertThat(result.newSpans().endOffset(newRoot.id())).isEqualTo(20); - } - - @Test - @DisplayName("Multi-level splice: every ancestor on the path is rebuilt with fresh id") - void multi_level_splice() { - // Root[Mid[Inner[target], Sibling]] - // ^ leaf right of edit - // Splice replaces target. Verify Inner, Mid, Root all have fresh ids - // and that Sibling identity is preserved at the Mid level. - var gen = new IdGenerator.PerSessionCounter(); - var target = terminal(gen, "t"); - var inner = nonTerminal(gen, "Inner", List.of(target)); - var sibling = terminal(gen, "S"); - var mid = nonTerminal(gen, "Mid", List.of(inner, sibling)); - var root = nonTerminal(gen, "Root", List.of(mid)); - - var oldSpans = new SpanIndex(16); - oldSpans.put(target.id(), 0, 3); - oldSpans.put(inner.id(), 0, 3); - oldSpans.put(sibling.id(), 3, 5); - oldSpans.put(mid.id(), 0, 5); - oldSpans.put(root.id(), 0, 5); - - var newPivot = terminal(gen, "t'"); - oldSpans.put(newPivot.id(), 0, 4); - - var splicer = new OffsetDecoupledSplicer(gen); - var result = splicer.splice(oldSpans, List.of(root, mid, inner, target), newPivot, 3, 1); - - var newRoot = (OffsetDecoupledNode.NonTerminal) result.newRoot(); - assertThat(newRoot.id()).isNotEqualTo(root.id()); - - var newMid = (OffsetDecoupledNode.NonTerminal) newRoot.children().get(0); - assertThat(newMid.id()).isNotEqualTo(mid.id()); - assertThat(newMid.children().get(1)).isSameAs(sibling); // RIGHT of edit, identity preserved - - var newInner = (OffsetDecoupledNode.NonTerminal) newMid.children().get(0); - assertThat(newInner.id()).isNotEqualTo(inner.id()); - assertThat(newInner.children().get(0)).isSameAs(newPivot); - - // newPath: root → mid → inner → newPivot, all fresh except pivot. - assertThat(result.newPath()).hasSize(4); - assertThat(result.newPath().get(0)).isSameAs(newRoot); - assertThat(result.newPath().get(1)).isSameAs(newMid); - assertThat(result.newPath().get(2)).isSameAs(newInner); - assertThat(result.newPath().get(3)).isSameAs(newPivot); - - // Sibling span shifted (start 3 >= editEnd 3, so shifts). - assertThat(result.newSpans().startOffset(sibling.id())).isEqualTo(4); - assertThat(result.newSpans().endOffset(sibling.id())).isEqualTo(6); - // Mid and Root: ends extended; starts unchanged. - assertThat(result.newSpans().startOffset(newMid.id())).isEqualTo(0); - assertThat(result.newSpans().endOffset(newMid.id())).isEqualTo(6); - assertThat(result.newSpans().startOffset(newRoot.id())).isEqualTo(0); - assertThat(result.newSpans().endOffset(newRoot.id())).isEqualTo(6); - } - - @Test - @DisplayName("Empty oldPath rejected") - void empty_path_rejected() { - var gen = new IdGenerator.PerSessionCounter(); - var pivot = terminal(gen, "x"); - var splicer = new OffsetDecoupledSplicer(gen); - assertThatThrownBy(() -> splicer.splice(new SpanIndex(4), List.of(), pivot, 0, 0)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - @DisplayName("Null newPivot rejected") - void null_pivot_rejected() { - var gen = new IdGenerator.PerSessionCounter(); - var root = terminal(gen, "x"); - var splicer = new OffsetDecoupledSplicer(gen); - assertThatThrownBy(() -> splicer.splice(new SpanIndex(4), List.of(root), null, 0, 0)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - @DisplayName("Non-NonTerminal ancestor rejected (broken path)") - void non_nonterminal_ancestor_rejected() { - var gen = new IdGenerator.PerSessionCounter(); - // Construct a fake path where a Terminal pretends to be an ancestor. - var fakeAncestor = terminal(gen, "fake"); - var pivot = terminal(gen, "p"); - var oldSpans = new SpanIndex(4); - oldSpans.put(fakeAncestor.id(), 0, 1); - oldSpans.put(pivot.id(), 0, 1); - var splicer = new OffsetDecoupledSplicer(gen); - - assertThatThrownBy(() -> splicer.splice(oldSpans, List.of(fakeAncestor, pivot), pivot, 0, 0)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("not a NonTerminal"); - } - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java deleted file mode 100644 index b8ee1e5..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/SpanIndexTest.java +++ /dev/null @@ -1,254 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; -import java.util.Random; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Tests for {@link SpanIndex} — the path-A external offset store. - * - *

Validates the round-trip, the in-place shift semantics, copy isolation, - * and stress under random workloads. - */ -final class SpanIndexTest { - - @Nested - @DisplayName("put / accessors") - class PutTests { - - @Test - @DisplayName("Round-trip: put then read back start/end") - void put_round_trip() { - var index = new SpanIndex(8); - index.put(42L, 100, 200); - - assertThat(index.contains(42L)).isTrue(); - assertThat(index.startOffset(42L)).isEqualTo(100); - assertThat(index.endOffset(42L)).isEqualTo(200); - assertThat(index.size()).isEqualTo(1); - } - - @Test - @DisplayName("Multiple distinct ids: each round-trips independently") - void multiple_ids_independent() { - var index = new SpanIndex(8); - index.put(1L, 0, 10); - index.put(2L, 10, 20); - index.put(3L, 20, 30); - - assertThat(index.startOffset(1L)).isEqualTo(0); - assertThat(index.endOffset(1L)).isEqualTo(10); - assertThat(index.startOffset(2L)).isEqualTo(10); - assertThat(index.endOffset(2L)).isEqualTo(20); - assertThat(index.startOffset(3L)).isEqualTo(20); - assertThat(index.endOffset(3L)).isEqualTo(30); - assertThat(index.size()).isEqualTo(3); - } - - @Test - @DisplayName("Overwrite same id replaces the offsets, size unchanged") - void overwrite_same_id() { - var index = new SpanIndex(8); - index.put(1L, 0, 10); - index.put(1L, 100, 200); - - assertThat(index.startOffset(1L)).isEqualTo(100); - assertThat(index.endOffset(1L)).isEqualTo(200); - assertThat(index.size()).isEqualTo(1); - } - - @Test - @DisplayName("Absent id: contains() false, accessors throw IllegalStateException") - void absent_id_throws() { - var index = new SpanIndex(8); - assertThat(index.contains(99L)).isFalse(); - assertThatThrownBy(() -> index.startOffset(99L)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("99"); - assertThatThrownBy(() -> index.endOffset(99L)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("99"); - } - - @Test - @DisplayName("Zero offsets are valid (not confused with MISSING sentinel)") - void zero_offsets_valid() { - var index = new SpanIndex(8); - index.put(1L, 0, 0); - - assertThat(index.contains(1L)).isTrue(); - assertThat(index.startOffset(1L)).isEqualTo(0); - assertThat(index.endOffset(1L)).isEqualTo(0); - } - } - - @Nested - @DisplayName("shift") - class ShiftTests { - - @Test - @DisplayName("Updates entries with start >= afterOffset, leaves others untouched") - void shift_partition() { - var index = new SpanIndex(8); - index.put(1L, 0, 5); // entirely before edit - index.put(2L, 5, 10); // start equals edit boundary — INCLUDED - index.put(3L, 10, 20); // entirely after edit - - index.shift(5, 100); - - // id=1: untouched - assertThat(index.startOffset(1L)).isEqualTo(0); - assertThat(index.endOffset(1L)).isEqualTo(5); - // id=2: shifted - assertThat(index.startOffset(2L)).isEqualTo(105); - assertThat(index.endOffset(2L)).isEqualTo(110); - // id=3: shifted - assertThat(index.startOffset(3L)).isEqualTo(110); - assertThat(index.endOffset(3L)).isEqualTo(120); - } - - @Test - @DisplayName("delta=0 is a no-op (no walk performed; values unchanged)") - void shift_zero_delta_noop() { - var index = new SpanIndex(8); - index.put(1L, 0, 5); - index.put(2L, 5, 10); - - index.shift(0, 0); - - assertThat(index.startOffset(1L)).isEqualTo(0); - assertThat(index.endOffset(1L)).isEqualTo(5); - assertThat(index.startOffset(2L)).isEqualTo(5); - assertThat(index.endOffset(2L)).isEqualTo(10); - } - - @Test - @DisplayName("Negative delta shifts left (deletion case)") - void shift_negative_delta() { - var index = new SpanIndex(8); - index.put(1L, 0, 5); - index.put(2L, 10, 20); - - index.shift(10, -3); - - assertThat(index.startOffset(1L)).isEqualTo(0); - assertThat(index.endOffset(1L)).isEqualTo(5); - assertThat(index.startOffset(2L)).isEqualTo(7); - assertThat(index.endOffset(2L)).isEqualTo(17); - } - - @Test - @DisplayName("All entries before afterOffset → no entry touched") - void shift_all_before_no_op() { - var index = new SpanIndex(8); - index.put(1L, 0, 5); - index.put(2L, 5, 8); - - index.shift(100, 50); - - assertThat(index.startOffset(1L)).isEqualTo(0); - assertThat(index.endOffset(1L)).isEqualTo(5); - assertThat(index.startOffset(2L)).isEqualTo(5); - assertThat(index.endOffset(2L)).isEqualTo(8); - } - } - - @Nested - @DisplayName("copy") - class CopyTests { - - @Test - @DisplayName("Independent state: mutations on copy do not affect original") - void copy_independence_forward() { - var original = new SpanIndex(8); - original.put(1L, 0, 5); - original.put(2L, 5, 10); - - var copy = original.copy(); - copy.put(3L, 10, 20); - copy.put(1L, 999, 1000); - copy.shift(0, 100); - - // Original unchanged - assertThat(original.size()).isEqualTo(2); - assertThat(original.startOffset(1L)).isEqualTo(0); - assertThat(original.endOffset(1L)).isEqualTo(5); - assertThat(original.startOffset(2L)).isEqualTo(5); - assertThat(original.endOffset(2L)).isEqualTo(10); - assertThat(original.contains(3L)).isFalse(); - } - - @Test - @DisplayName("Independent state: mutations on original do not affect copy") - void copy_independence_reverse() { - var original = new SpanIndex(8); - original.put(1L, 0, 5); - - var copy = original.copy(); - original.put(1L, 100, 200); - - // Copy retains the snapshot. - assertThat(copy.startOffset(1L)).isEqualTo(0); - assertThat(copy.endOffset(1L)).isEqualTo(5); - } - } - - @Nested - @DisplayName("stress") - class StressTests { - - @Test - @DisplayName("1000 distinct random ids: insert, shift, verify each readback") - void stress_thousand_entries() { - var rng = new Random(0xC0FFEEL); - var index = new SpanIndex(2048); - var oracle = new HashMap(); // id -> [start, end] - - // Insert 1000 distinct ids with random non-overlapping-ish offsets. - // (Overlap doesn't matter for the index — it's a flat map — but - // we want predictable shift partitioning.) - for (int i = 0; i < 1000; i++) { - long id; - do { - id = rng.nextLong(); - } while (oracle.containsKey(id)); - int start = i * 10; - int end = start + 5 + rng.nextInt(5); - index.put(id, start, end); - oracle.put(id, new int[]{start, end}); - } - assertThat(index.size()).isEqualTo(1000); - - // Verify all readbacks pre-shift. - for (Map.Entry e : oracle.entrySet()) { - assertThat(index.startOffset(e.getKey())).isEqualTo(e.getValue()[0]); - assertThat(index.endOffset(e.getKey())).isEqualTo(e.getValue()[1]); - } - - // Shift at offset 5000 (mid-buffer) by +1000. - int afterOffset = 5000; - int delta = 1000; - index.shift(afterOffset, delta); - for (Map.Entry e : oracle.entrySet()) { - int s = e.getValue()[0]; - if (s >= afterOffset) { - e.getValue()[0] = s + delta; - e.getValue()[1] = e.getValue()[1] + delta; - } - } - - // Verify all readbacks post-shift. - for (Map.Entry e : oracle.entrySet()) { - assertThat(index.startOffset(e.getKey())).isEqualTo(e.getValue()[0]); - assertThat(index.endOffset(e.getKey())).isEqualTo(e.getValue()[1]); - } - } - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java deleted file mode 100644 index 1676258..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdNodeIndexTest.java +++ /dev/null @@ -1,279 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Path D — tests for {@link StableIdNodeIndex}. Central correctness claim: - * after {@link StableIdNodeIndex#applyIncremental} on a tree spliced via - * {@link StableIdSplicer}, the resulting parents map matches what - * {@code StableIdNodeIndex.build(newRoot)} would produce — even though the - * incremental update skips the ancestor-removal and sibling-rewire steps that - * {@link IdNodeIndex#applyIncremental} performs. - * - *

Central perf claim: the {@code parents.put} + {@code parents.remove} call - * counts during {@code applyIncremental} are bounded by - * {@code oldPivotSize + newPivotSize + small constant}, independent of N. - */ -final class StableIdNodeIndexTest { - private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); - private static final List NO_TRIVIA = List.of(); - - private static IdCstNode.Terminal terminal(IdGenerator gen, String text) { - return new IdCstNode.Terminal(gen.next(), SPAN, "T", text, NO_TRIVIA, NO_TRIVIA); - } - - private static IdCstNode.NonTerminal nonTerminal(IdGenerator gen, String rule, List children) { - return new IdCstNode.NonTerminal(gen.next(), SPAN, rule, children, NO_TRIVIA, NO_TRIVIA); - } - - @Nested - @DisplayName("build") - class BuildTests { - @Test - @DisplayName("Equivalence to IdNodeIndex.build: same parents map on the same tree") - void build_equivalent_to_idnodeindex() { - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "a"); - var b = terminal(gen, "b"); - var c = terminal(gen, "c"); - IdCstNode inner = nonTerminal(gen, "Inner", List.of(b, c)); - IdCstNode root = nonTerminal(gen, "Root", List.of(a, inner)); - - var stableIndex = StableIdNodeIndex.build(root); - var idIndex = IdNodeIndex.build(root); - - assertThat(stableIndex.size()).isEqualTo(idIndex.size()); - for (var node : flatten(root)) { - assertThat(stableIndex.parentIdOf(node.id())) - .as("node id %d (rule %s) parent must agree", node.id(), node.rule()) - .isEqualTo(idIndex.parentIdOf(node.id())); - } - assertThat(stableIndex.root()).isSameAs(root); - } - } - - @Nested - @DisplayName("applyIncremental") - class ApplyIncrementalTests { - - @Test - @DisplayName("Equivalence to full rebuild after StableIdSplicer splice") - void equivalence_to_full_rebuild() { - // 3-level tree, splice at depth 2 via StableIdSplicer, verify - // the resulting StableIdNodeIndex matches build(newRoot) exactly. - var gen = new IdGenerator.PerSessionCounter(); - var leaf1 = terminal(gen, "1"); - var leaf2 = terminal(gen, "2"); - var leaf3 = terminal(gen, "3"); - var leaf4 = terminal(gen, "4"); - IdCstNode oldChildA = nonTerminal(gen, "A", List.of(leaf1, leaf2)); - IdCstNode oldChildB = nonTerminal(gen, "B", List.of(leaf3, leaf4)); - IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(oldChildA, oldChildB)); - - var index = StableIdNodeIndex.build(oldRoot); - - // Replace child B with a fresh subtree using StableIdSplicer. - var newLeaf5 = terminal(gen, "5"); - var newLeaf6 = terminal(gen, "6"); - var newLeaf7 = terminal(gen, "7"); - IdCstNode newChildB = nonTerminal(gen, "B", List.of(newLeaf5, newLeaf6, newLeaf7)); - - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(List.of(oldRoot, oldChildB), newChildB); - - var oldPath = List.of(oldRoot, oldChildB); - var newPath = result.newPath(); - - var incremental = index.applyIncremental(result.newRoot(), oldPath, newPath); - var rebuilt = StableIdNodeIndex.build(result.newRoot()); - - // Both indices must agree on every node in the new tree. - for (var node : flatten(result.newRoot())) { - assertThat(incremental.parentIdOf(node.id())) - .as("parent of node id %d (rule %s)", node.id(), node.rule()) - .isEqualTo(rebuilt.parentIdOf(node.id())); - } - assertThat(incremental.size()).isEqualTo(rebuilt.size()); - // Old pivot and its old descendants are gone. - assertThat(incremental.contains(oldChildB.id())).isFalse(); - assertThat(incremental.contains(leaf3.id())).isFalse(); - assertThat(incremental.contains(leaf4.id())).isFalse(); - // Stable ancestor: the new root has the SAME id as old root. - assertThat(result.newRoot().id()).isEqualTo(oldRoot.id()); - } - - @Test - @DisplayName("Flat tree: 100 children, splice middle; remaining children's parent entries unchanged") - void flat_tree_splice_preserves_sibling_entries() { - // Build a flat tree: root with 100 terminal children. - var gen = new IdGenerator.PerSessionCounter(); - var children = new ArrayList(100); - for (int i = 0; i < 100; i++) { - children.add(terminal(gen, "c" + i)); - } - IdCstNode oldRoot = nonTerminal(gen, "Root", children); - long stableRootId = oldRoot.id(); - - var index = StableIdNodeIndex.build(oldRoot); - assertThat(index.size()).isEqualTo(100); - - // Replace child #50 via StableIdSplicer. - var oldTarget = children.get(50); - IdCstNode newTarget = terminal(gen, "newC50"); - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(List.of(oldRoot, oldTarget), newTarget); - - var newIndex = index.applyIncremental(result.newRoot(), List.of(oldRoot, oldTarget), result.newPath()); - - // Stable root id propagated. - assertThat(result.newRoot().id()).isEqualTo(stableRootId); - // Every remaining child still points to the (stable-id) root. - for (int i = 0; i < 100; i++) { - if (i == 50) { - // old c50 is dead. - assertThat(newIndex.contains(children.get(i).id())).isFalse(); - continue; - } - assertThat(newIndex.parentIdOf(children.get(i).id())) - .as("flat-tree sibling at index %d still points to stable root id", i) - .isEqualTo(org.pragmatica.lang.Option.some(stableRootId)); - } - // The new c50 points to the stable root id. - assertThat(newIndex.parentIdOf(newTarget.id())) - .isEqualTo(org.pragmatica.lang.Option.some(stableRootId)); - assertThat(newIndex.size()).isEqualTo(100); - } - - @Test - @DisplayName("Microcount on flat tree: put + remove counts ≤ oldPivotSize + newPivotSize + small slack") - void microcount_o_delta() { - // Central perf claim: applyIncremental cost is O(oldPivotSize + newPivotSize), - // NOT O(N). Build a 1000-child flat tree, splice one terminal, assert - // microcounts are tiny. - var gen = new IdGenerator.PerSessionCounter(); - int N = 1000; - var children = new ArrayList(N); - for (int i = 0; i < N; i++) { - children.add(terminal(gen, "c" + i)); - } - IdCstNode oldRoot = nonTerminal(gen, "Root", children); - - var index = StableIdNodeIndex.build(oldRoot); - int totalNodes = countAll(oldRoot); // 1001 (root + 1000 children) - assertThat(totalNodes).isEqualTo(N + 1); - - var oldTarget = children.get(N / 2); - IdCstNode newTarget = terminal(gen, "newMid"); - - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(List.of(oldRoot, oldTarget), newTarget); - - var newIndex = index.applyIncremental(result.newRoot(), List.of(oldRoot, oldTarget), result.newPath()); - - // oldPivotSize: oldTarget is a leaf — 0 descendants. - // newPivotSize: newTarget is a leaf — 0 descendants. +1 for pivot up-pointer. - // Removes: 0 descendants + 1 (oldPivot) = 1 - // Puts: 0 internal + 1 pivot up-pointer = 1 - // Slack of 5 for any future implementation detail. - int oldPivotSize = countAll(oldTarget); // 1 (just the leaf itself) - int newPivotSize = countAll(newTarget); // 1 - int microBound = oldPivotSize + newPivotSize + 5; - - int totalMicro = newIndex.lastIncrementalPutCount + newIndex.lastIncrementalRemoveCount; - - assertThat(totalMicro) - .as("put + remove count must be O(oldPivotSize + newPivotSize), NOT O(N=%d). " - + "Got puts=%d, removes=%d (bound=%d)", - totalNodes, newIndex.lastIncrementalPutCount, newIndex.lastIncrementalRemoveCount, - microBound) - .isLessThanOrEqualTo(microBound) - .isLessThan(totalNodes); - - System.out.println("[Path D] flat-tree(N=" + N + ") incremental microcount: puts=" - + newIndex.lastIncrementalPutCount - + " removes=" + newIndex.lastIncrementalRemoveCount - + " (vs full-rebuild N=" + totalNodes + ")"); - } - - @Test - @DisplayName("Deep splice (4-level): equivalence to fresh build, stable ancestor ids preserved") - void deep_splice_equivalence() { - var gen = new IdGenerator.PerSessionCounter(); - var lvl1Sib = terminal(gen, "L1S"); - var midLvl2Sib = terminal(gen, "L2S"); - var oldPivot = terminal(gen, "OLD_PIVOT"); - IdCstNode oldInner = nonTerminal(gen, "Inner", List.of(oldPivot)); - IdCstNode oldMid = nonTerminal(gen, "Mid", List.of(midLvl2Sib, oldInner)); - IdCstNode oldRoot = nonTerminal(gen, "Root", List.of(lvl1Sib, oldMid)); - - var index = StableIdNodeIndex.build(oldRoot); - - IdCstNode newPivot = terminal(gen, "NEW_PIVOT"); - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(List.of(oldRoot, oldMid, oldInner, (IdCstNode) oldPivot), newPivot); - - var oldPath = List.of(oldRoot, oldMid, oldInner, (IdCstNode) oldPivot); - var newIndex = index.applyIncremental(result.newRoot(), oldPath, result.newPath()); - - // Stable ancestor ids: newRoot.id == oldRoot.id, etc. - for (int i = 0; i < oldPath.size() - 1; i++) { - assertThat(result.newPath().get(i).id()) - .as("ancestor at depth %d preserves stable id", i) - .isEqualTo(oldPath.get(i).id()); - } - - // Equivalence to full rebuild. - var fresh = StableIdNodeIndex.build(result.newRoot()); - for (var node : flatten(result.newRoot())) { - assertThat(newIndex.parentIdOf(node.id())) - .as("deep splice: node id %d (rule %s) parent agrees with full build", - node.id(), node.rule()) - .isEqualTo(fresh.parentIdOf(node.id())); - } - assertThat(newIndex.size()).isEqualTo(fresh.size()); - // Old pivot is dead. - assertThat(newIndex.contains(oldPivot.id())).isFalse(); - // BUT old ancestor ids remain LIVE (their records were rebuilt with same ids). - assertThat(newIndex.contains(oldMid.id())).isTrue(); - assertThat(newIndex.contains(oldInner.id())).isTrue(); - // Old root id has no parent entry (it's the root). - assertThat(newIndex.contains(oldRoot.id())).isFalse(); - } - } - - // -- helpers -- - - private static int countAll(IdCstNode node) { - int c = 1; - if (node instanceof IdCstNode.NonTerminal nt) { - for (var ch : nt.children()) { - c += countAll(ch); - } - } - return c; - } - - private static List flatten(IdCstNode root) { - var out = new ArrayList(); - flattenInto(root, out); - return out; - } - - private static void flattenInto(IdCstNode node, List out) { - out.add(node); - if (node instanceof IdCstNode.NonTerminal nt) { - for (var ch : nt.children()) { - flattenInto(ch, out); - } - } - } -} diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java deleted file mode 100644 index ceb0412..0000000 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/experimental/StableIdSplicerTest.java +++ /dev/null @@ -1,182 +0,0 @@ -package org.pragmatica.peg.incremental.experimental; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.pragmatica.peg.tree.SourceSpan; -import org.pragmatica.peg.tree.Trivia; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Path D — tests for {@link StableIdSplicer}. Inherits identity-preservation - * tests from the {@link IdTreeSplicer} pattern, plus tests asserting the - * stable-id invariant: every ancestor on the new path carries the same id as - * its old-path counterpart, despite being a structurally distinct record. - */ -final class StableIdSplicerTest { - private static final SourceSpan SPAN = new SourceSpan(1, 1, 0, 1, 1, 0); - private static final List NO_TRIVIA = List.of(); - - private static IdCstNode.Terminal terminal(IdGenerator gen, String text) { - return new IdCstNode.Terminal(gen.next(), SPAN, "T", text, NO_TRIVIA, NO_TRIVIA); - } - - private static IdCstNode.NonTerminal nonTerminal(IdGenerator gen, String rule, List children) { - return new IdCstNode.NonTerminal(gen.next(), SPAN, rule, children, NO_TRIVIA, NO_TRIVIA); - } - - @Nested - @DisplayName("splice") - class SpliceTests { - - @Test - @DisplayName("Pivot is root: returns newPivot directly, newPath = [newPivot]") - void pivot_as_root() { - var gen = new IdGenerator.PerSessionCounter(); - IdCstNode oldRoot = terminal(gen, "old"); - IdCstNode newPivot = terminal(gen, "new"); - - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(List.of(oldRoot), newPivot); - - assertThat(result.newRoot()).isSameAs(newPivot); - assertThat(result.newPath()).hasSize(1); - assertThat(result.newPath().get(0)).isSameAs(newPivot); - } - - @Test - @DisplayName("Single-level splice: ancestor reuses old id; siblings reference-shared") - void single_level_stable_id() { - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "A"); - var b = terminal(gen, "B"); - var c = terminal(gen, "C"); - IdCstNode root = nonTerminal(gen, "Root", List.of(a, b, c)); - IdCstNode bPrime = terminal(gen, "B'"); - - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(List.of(root, b), bPrime); - - // Stable id: new root's id matches old root's id. - assertThat(result.newRoot().id()) - .as("ancestor id reused on splice path") - .isEqualTo(root.id()); - // But the records are structurally distinct (different children list). - assertThat(result.newRoot()).isNotSameAs(root); - - var newRootNT = (IdCstNode.NonTerminal) result.newRoot(); - assertThat(newRootNT.rule()).isEqualTo("Root"); - // Siblings are reference-shared (identity invariant). - assertThat(newRootNT.children().get(0)).isSameAs(a); - assertThat(newRootNT.children().get(1)).isSameAs(bPrime); - assertThat(newRootNT.children().get(2)).isSameAs(c); - // newPath = [newRoot, newPivot]. - assertThat(result.newPath()).hasSize(2); - assertThat(result.newPath().get(0)).isSameAs(result.newRoot()); - assertThat(result.newPath().get(1)).isSameAs(bPrime); - } - - @Test - @DisplayName("Deep splice: every ancestor on path reuses its old id; pivot keeps caller-supplied id") - void deep_splice_stable_ids() { - // 4-level tree: root → mid → inner → pivot, with siblings at each level. - var gen = new IdGenerator.PerSessionCounter(); - var lvl1Sib = terminal(gen, "lvl1Sib"); - var midSib = terminal(gen, "midSib"); - var innerSib = terminal(gen, "innerSib"); - var pivot = terminal(gen, "PIVOT"); - IdCstNode inner = nonTerminal(gen, "Inner", List.of(innerSib, pivot)); - IdCstNode mid = nonTerminal(gen, "Mid", List.of(midSib, inner)); - IdCstNode root = nonTerminal(gen, "Root", List.of(lvl1Sib, mid)); - - IdCstNode newPivot = terminal(gen, "NEW_PIVOT"); - var oldPath = List.of(root, mid, inner, (IdCstNode) pivot); - - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(oldPath, newPivot); - - assertThat(result.newPath()).hasSize(4); - // Ancestors at indices [0, oldPath.size() - 2) carry stable ids. - for (int i = 0; i < oldPath.size() - 1; i++) { - assertThat(result.newPath().get(i).id()) - .as("ancestor at depth %d must reuse old id", i) - .isEqualTo(oldPath.get(i).id()); - // ...but the records are structurally distinct. - assertThat(result.newPath().get(i)) - .as("ancestor at depth %d is a fresh record", i) - .isNotSameAs(oldPath.get(i)); - } - // The pivot slot carries the caller-supplied newPivot, with whatever - // id the caller gave it (typically fresh). - assertThat(result.newPath().get(3)).isSameAs(newPivot); - assertThat(result.newPath().get(3).id()) - .as("pivot id is whatever the caller built it with") - .isEqualTo(newPivot.id()); - // Sibling identity preservation (inherited invariant). - var newRootNT = (IdCstNode.NonTerminal) result.newRoot(); - assertThat(newRootNT.children().get(0)).isSameAs(lvl1Sib); - var newMidNT = (IdCstNode.NonTerminal) result.newPath().get(1); - assertThat(newMidNT.children().get(0)).isSameAs(midSib); - var newInnerNT = (IdCstNode.NonTerminal) result.newPath().get(2); - assertThat(newInnerNT.children().get(0)).isSameAs(innerSib); - assertThat(newInnerNT.children().get(1)).isSameAs(newPivot); - } - - @Test - @DisplayName("Structural inequality despite ID equality: new ancestor records are not == old, but ids match") - void structural_inequality_with_id_equality() { - // Verify the central Path D claim spelled out: same id, different - // record. This is the exact invariant that lets StableIdNodeIndex - // skip the ancestor-removal and sibling-rewire steps. - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "A"); - var b = terminal(gen, "B"); - IdCstNode root = nonTerminal(gen, "Root", List.of(a, b)); - IdCstNode bPrime = terminal(gen, "B'"); - - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(List.of(root, b), bPrime); - - assertThat(result.newPath().get(0)) - .as("structural inequality: different record") - .isNotSameAs(root); - assertThat(result.newPath().get(0).id()) - .as("ID equality: same logical node") - .isEqualTo(root.id()); - // The children lists differ — that's the structural delta. - var newRootNT = (IdCstNode.NonTerminal) result.newPath().get(0); - var oldRootNT = (IdCstNode.NonTerminal) root; - assertThat(newRootNT.children()).isNotEqualTo(oldRootNT.children()); - } - - @Test - @DisplayName("Generator is not consumed for ancestor rebuild — unlike IdTreeSplicer") - void generator_unused_for_ancestor_rebuild() { - // IdTreeSplicer would have called gen.next() for the new root; - // StableIdSplicer reuses the old root's id. The generator's state - // must not advance during the splice itself. - var gen = new IdGenerator.PerSessionCounter(); - var a = terminal(gen, "A"); - var b = terminal(gen, "B"); - IdCstNode root = nonTerminal(gen, "Root", List.of(a, b)); - // Caller pre-allocates the newPivot's id (consumes gen). - IdCstNode bPrime = terminal(gen, "B'"); - - long preSpliceCounter = gen.next(); - // Reset the gen to the same state by capturing pre-splice value - // and asserting post-splice gen returns the very next value. - // (IdGenerator has no peek(); we just verify monotonic +1.) - var splicer = new StableIdSplicer(gen); - var result = splicer.splice(List.of(root, b), bPrime); - - long postSpliceCounter = gen.next(); - assertThat(postSpliceCounter) - .as("StableIdSplicer must not consume the generator during ancestor rebuild") - .isEqualTo(preSpliceCounter + 1L); - assertThat(result.newRoot().id()).isEqualTo(root.id()); - } - } -} From 4f06046e8a6174c0ee33399e9b4620b8e0195190 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 20:32:39 +0200 Subject: [PATCH 19/46] =?UTF-8?q?feat:=20Lever=20D=20=E2=80=94=20Cursor=20?= =?UTF-8?q?extracted=20from=20Session=20record=20(p99=20-53%,=20frame=20bu?= =?UTF-8?q?dget=20+1.1pp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bench/IncrementalBenchmark.java | 14 +- .../bench/IncrementalSessionBench.java | 37 ++- .../pragmatica/peg/incremental/Cursor.java | 70 ++++ .../peg/incremental/EditOutcome.java | 20 ++ .../peg/incremental/IncrementalParser.java | 35 +- .../peg/incremental/InitialSession.java | 22 ++ .../peg/incremental/ReparseOutcome.java | 18 + .../pragmatica/peg/incremental/Session.java | 105 +++--- .../internal/IncrementalSession.java | 310 ++++++------------ .../peg/incremental/internal/NodeIndex.java | 16 + .../incremental/internal/SessionFactory.java | 13 +- .../peg/incremental/package-info.java | 13 +- .../BackReferenceFallbackTest.java | 5 +- .../peg/incremental/IdempotencyTest.java | 54 +-- .../incremental/IncrementalParityTest.java | 11 +- .../IncrementalTriviaParityTest.java | 11 +- .../peg/incremental/ReparseBoundaryTest.java | 58 ++-- .../peg/incremental/SessionApiTest.java | 114 ++++--- .../incremental/TriviaRedistributionTest.java | 77 +++-- .../examples/BasicEditorLoopExample.java | 27 +- 20 files changed, 566 insertions(+), 464 deletions(-) create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Cursor.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/EditOutcome.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/InitialSession.java create mode 100644 peglib-incremental/src/main/java/org/pragmatica/peg/incremental/ReparseOutcome.java diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalBenchmark.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalBenchmark.java index 47695eb..b3d18f4 100644 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalBenchmark.java +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalBenchmark.java @@ -14,6 +14,7 @@ import org.openjdk.jmh.annotations.Warmup; import org.pragmatica.peg.grammar.Grammar; import org.pragmatica.peg.grammar.GrammarParser; +import org.pragmatica.peg.incremental.Cursor; import org.pragmatica.peg.incremental.Edit; import org.pragmatica.peg.incremental.IncrementalParser; import org.pragmatica.peg.incremental.Session; @@ -78,6 +79,7 @@ public class IncrementalBenchmark { // For per-edit variants: a warm session at trial level, edited at invocation level. private Session warmSession; + private Cursor warmCursor; // Saved predecessor for undoRestore. private Session savedSession; // Edit configuration per variant. @@ -96,7 +98,9 @@ public void setup() throws Exception { g -> g); parser = IncrementalParser.create(grammar); - warmSession = parser.initialize(fixtureSource, fixtureSource.length() / 2); + var init = parser.initialize(fixtureSource, fixtureSource.length() / 2); + warmSession = init.session(); + warmCursor = init.cursor(); savedSession = warmSession; // Pick edit offsets that land in the file's body (not in the @@ -128,10 +132,10 @@ private static int pickInteriorOffset(String text, double frac) { public Object run() { return switch (variant) { case "initialize" -> parser.initialize(fixtureSource, 0); - case "singleCharEdit" -> warmSession.edit(new Edit(singleCharOffset, 0, "x")); - case "wordEdit" -> warmSession.edit(new Edit(wordOffset, 0, wordReplacement)); - case "lineEdit" -> warmSession.edit(new Edit(lineOffset, 0, lineInsertion)); - case "fullReparse" -> warmSession.reparseAll(); + case "singleCharEdit" -> warmSession.edit(warmCursor, new Edit(singleCharOffset, 0, "x")); + case "wordEdit" -> warmSession.edit(warmCursor, new Edit(wordOffset, 0, wordReplacement)); + case "lineEdit" -> warmSession.edit(warmCursor, new Edit(lineOffset, 0, lineInsertion)); + case "fullReparse" -> warmSession.reparseAll(warmCursor); case "undoRestore" -> savedSession; default -> throw new IllegalArgumentException("Unknown variant: " + variant); }; diff --git a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java index 6749e28..4927973 100644 --- a/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java +++ b/peglib-incremental/src/jmh/java/org/pragmatica/peg/incremental/bench/IncrementalSessionBench.java @@ -1,7 +1,9 @@ package org.pragmatica.peg.incremental.bench; import org.pragmatica.peg.grammar.GrammarParser; +import org.pragmatica.peg.incremental.Cursor; import org.pragmatica.peg.incremental.Edit; +import org.pragmatica.peg.incremental.EditOutcome; import org.pragmatica.peg.incremental.IncrementalParser; import org.pragmatica.peg.incremental.Session; @@ -166,7 +168,9 @@ private static Measurement[] runRegime(IncrementalParser parser, String fixtureSource, List plan, boolean moveCursor) { - var session = parser.initialize(fixtureSource, 0); + var init = parser.initialize(fixtureSource, 0); + Session session = init.session(); + Cursor cursor = init.cursor(); int prevFallbacks = session.stats() .fullReparseCount(); var out = new Measurement[plan.size()]; @@ -192,17 +196,22 @@ private static Measurement[] runRegime(IncrementalParser parser, 0); continue; } + // Lever D: cursor moves are pure (no Session allocation). For Regime B + // we move the cursor BEFORE timing — but the move is now a 16-byte + // record alloc, so include or exclude makes no measurable difference; + // we keep the move inside the timed window for fidelity to the prior + // bench shape. long t0 = System.nanoTime(); - Session next = moveCursor - ? session.moveCursor(edit.offset()) - .edit(edit) - : session.edit(edit); + Cursor editCursor = moveCursor + ? cursor.moveTo(edit.offset(), session.index()) + : cursor; + EditOutcome outcome = session.edit(editCursor, edit); long t1 = System.nanoTime(); long latencyNs = t1 - t0; + Session next = outcome.newSession(); // Post-edit validation: if the parser rejected the new buffer, Session.edit // returns a degraded session whose parseSuccessful() is false. Treat the - // edit as INVALIDATED, keep the previous session, and exclude the latency - // sample from the timing buckets (mirrors the prior pre-validation skip). + // edit as INVALIDATED, keep the previous session and cursor. if (!next.parseSuccessful()) { out[i] = new Measurement(ce.cls(), - 1L, @@ -233,19 +242,23 @@ private static Measurement[] runRegime(IncrementalParser parser, stats.lastReparsedRule(), stats.lastReparsedNodeCount()); session = next; + cursor = outcome.newCursor(); } return out; } private static void warmJit(IncrementalParser parser, String fixtureSource) { - var s = parser.initialize(fixtureSource, fixtureSource.length() / 2); + var init = parser.initialize(fixtureSource, fixtureSource.length() / 2); + Session s = init.session(); + Cursor c = init.cursor(); for (int i = 0; i < 20; i++) { int off = (i * 37 + 100) % s.text() .length(); - // Post-migration: edit never throws on parse failure; degraded sessions - // are harmless during JIT warmup so we don't bother inspecting parseSuccessful(). - s = s.moveCursor(off) - .edit(new Edit(off, 0, "x")); + // Post-migration: edit never throws on parse failure. + c = c.moveTo(off, s.index()); + var outcome = s.edit(c, new Edit(off, 0, "x")); + s = outcome.newSession(); + c = outcome.newCursor(); } } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Cursor.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Cursor.java new file mode 100644 index 0000000..eb2e657 --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Cursor.java @@ -0,0 +1,70 @@ +package org.pragmatica.peg.incremental; + +import org.pragmatica.peg.incremental.internal.NodeIndex; +import org.pragmatica.peg.tree.CstNode; + +/** + * Editor-style cursor anchored over a {@link Session}'s buffer. + * + *

0.5.0 (Lever D, SPEC §5): the cursor is split out of {@link Session} so + * cursor moves don't allocate a new Session. A {@code Cursor} is two longs + + * an int (the JVM word-aligns to 16 bytes): an offset and the stable + * {@link CstNode#id() id} of the smallest enclosing CST node at that offset. + * + *

Phase 1's stable-id invariant ({@link CstNode}'s {@code long id} survives + * incremental splices for ancestors that aren't wholesale-replaced) lets a + * cursor outlive the {@link Session} it was created with: as long as the + * enclosing node still exists in the post-edit tree, the cursor's + * {@link #enclosingNodeId} resolves to a current record via + * {@link NodeIndex#nodeById(long)}. When that resolution fails (the enclosing + * node was inside a replaced subtree), boundary-walking code falls back to + * top-down descent from the new root. + * + *

This makes undo/redo cleaner: save {@code (Session, Cursor)} snapshots + * independently. The Session lineage shares CST structure; the cursor stack + * is just a 16-byte-per-entry array. + * + * @since 0.5.0 + */ +public record Cursor(int offset, long enclosingNodeId) { + + /** + * Construct a {@code Cursor} pointing at {@code offset} in the tree + * indexed by {@code index}. The {@link #enclosingNodeId} is resolved by + * {@link NodeIndex#smallestContaining(int)}; when {@code offset} lies + * outside the root span (e.g., past EOF), the cursor's enclosing falls + * back to the root. + * + *

{@code offset} is clamped to {@code [0, root.span().endOffset()]}. + */ + public static Cursor at(int offset, NodeIndex index) { + var root = index.root(); + int clamped = clamp(offset, root); + var enclosing = index.smallestContaining(clamped) + .or(root); + return new Cursor(clamped, enclosing.id()); + } + + /** + * Move the cursor to {@code newOffset} against the same {@code index}. + * Pure — no Session involvement, no allocation beyond the new + * {@code Cursor} record itself (16 bytes). Resolves the new enclosing + * node by top-down descent through {@code index}. + */ + public Cursor moveTo(int newOffset, NodeIndex index) { + return Cursor.at(newOffset, index); + } + + private static int clamp(int offset, CstNode root) { + int max = root.span() + .end() + .offset(); + if (offset < 0) { + return 0; + } + if (offset > max) { + return max; + } + return offset; + } +} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/EditOutcome.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/EditOutcome.java new file mode 100644 index 0000000..8830e5f --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/EditOutcome.java @@ -0,0 +1,20 @@ +package org.pragmatica.peg.incremental; + +/** + * Result of {@link Session#edit(Cursor, Edit)} — a paired + * {@code (Session, Cursor)} so the caller can update both references in a + * single assignment without forgetting one. + * + *

0.5.0 (Lever D): cursor state was split out of {@link Session}; this + * record bundles the post-edit Session and the post-edit Cursor that arises + * from the SPEC §5.5 cursor-shift rules (before edit → unchanged; inside edit + * → snapped to end of replacement; after edit → shifted by + * {@link Edit#delta()}). + * + * @param newSession the post-edit Session (never {@code null}) + * @param newCursor the post-edit Cursor, anchored against + * {@code newSession.index()} (never {@code null}) + * + * @since 0.5.0 + */ +public record EditOutcome(Session newSession, Cursor newCursor) {} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/IncrementalParser.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/IncrementalParser.java index a680e4f..ec963dd 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/IncrementalParser.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/IncrementalParser.java @@ -25,13 +25,9 @@ * known to this grammar. * * - *

Sessions produced by {@link #initialize(String)} / - * {@link #initialize(String, int)} and their descendants share these - * resources. Thread-safe to share across threads: all state held here is - * either immutable or uses concurrent collections. - * - *

v1 scope per {@code docs/incremental/SPEC.md} §9: CST-only, wholesale - * packrat invalidation on every edit, no incremental action execution. + *

0.5.0 (Lever D): {@link #initialize(String, int)} returns an + * {@link InitialSession} bundling the Session and the initial {@link Cursor}. + * Cursor state is no longer carried inside the Session record — see SPEC §5. * * @since 0.3.1 */ @@ -46,9 +42,7 @@ static IncrementalParser create(Grammar grammar) { /** * Create an incremental parser over {@code grammar} with a specific - * runtime parser configuration. The configuration's packrat / - * recovery / trivia flags are honoured for both full parses and - * incremental partial-parse calls. + * runtime parser configuration. */ static IncrementalParser create(Grammar grammar, ParserConfig config) { return SessionFactory.sessionFactory(grammar, config); @@ -56,15 +50,7 @@ static IncrementalParser create(Grammar grammar, ParserConfig config) { /** * Create an incremental parser with explicit control over the SPEC §5.4 - * v2 trivia-only fast-path. When {@code triviaFastPathEnabled} is true, - * edits whose range fits inside a single trivia run skip the parser and - * rewrite the trivia in-place. The fast-path is correct only for - * grammars where in-trivia edits cannot change the tokenisation of - * adjacent tokens (simple grammars qualify; the Java 25 grammar does - * NOT — e.g. {@code >>} vs {@code > >}). - * - *

Default is {@code false} for safety; the structural reparse path - * always preserves correctness regardless of grammar shape. + * v2 trivia-only fast-path. * * @since 0.3.2 */ @@ -73,17 +59,20 @@ static IncrementalParser create(Grammar grammar, ParserConfig config, boolean tr } /** - * Produce the initial {@link Session} over {@code buffer} with the + * Produce the initial {@link InitialSession} over {@code buffer} with the * cursor at offset 0. */ - default Session initialize(String buffer) { + default InitialSession initialize(String buffer) { return initialize(buffer, 0); } /** - * Produce the initial {@link Session} over {@code buffer} with the + * Produce the initial {@link InitialSession} over {@code buffer} with the * cursor at {@code cursorOffset}. The cursor is clamped to * {@code [0, buffer.length()]}. + * + *

0.5.0 (Lever D): returns a paired {@code (Session, Cursor)} — + * cursor state lives outside the Session record. */ - Session initialize(String buffer, int cursorOffset); + InitialSession initialize(String buffer, int cursorOffset); } diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/InitialSession.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/InitialSession.java new file mode 100644 index 0000000..0d179ea --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/InitialSession.java @@ -0,0 +1,22 @@ +package org.pragmatica.peg.incremental; + +/** + * Result of {@link IncrementalParser#initialize(String, int)} — the freshly + * built {@link Session} paired with an initial {@link Cursor}. + * + *

0.5.0 (Lever D): {@link Session} no longer carries cursor state; the + * factory therefore returns both objects so the caller wires them up in a + * single statement. Subsequent state transitions go through + * {@link EditOutcome} (from {@link Session#edit(Cursor, Edit)}) and + * {@link ReparseOutcome} (from {@link Session#reparseAll(Cursor)}). + * + * @param session the initial Session over the supplied buffer (never + * {@code null}; may be a degraded Session if the parser + * rejected the buffer — inspect + * {@link Session#parseSuccessful()}) + * @param cursor the initial Cursor at {@code cursorOffset}, anchored against + * {@code session.index()} (never {@code null}) + * + * @since 0.5.0 + */ +public record InitialSession(Session session, Cursor cursor) {} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/ReparseOutcome.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/ReparseOutcome.java new file mode 100644 index 0000000..b1f9f7a --- /dev/null +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/ReparseOutcome.java @@ -0,0 +1,18 @@ +package org.pragmatica.peg.incremental; + +/** + * Result of {@link Session#reparseAll(Cursor)} — a paired + * {@code (Session, Cursor)} mirroring {@link EditOutcome}'s shape so callers + * can pattern-match either kind of state transition uniformly. + * + *

The cursor is preserved at its incoming offset and re-resolved against + * the freshly built {@link org.pragmatica.peg.incremental.internal.NodeIndex}, + * since reparseAll discards the previous CST. + * + * @param newSession the post-reparse Session (never {@code null}) + * @param newCursor the post-reparse Cursor, anchored against + * {@code newSession.index()} (never {@code null}) + * + * @since 0.5.0 + */ +public record ReparseOutcome(Session newSession, Cursor newCursor) {} diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Session.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Session.java index 4b0bb2e..fe14071 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Session.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/Session.java @@ -1,27 +1,32 @@ package org.pragmatica.peg.incremental; import org.pragmatica.lang.Option; +import org.pragmatica.peg.incremental.internal.NodeIndex; import org.pragmatica.peg.tree.CstNode; /** - * Immutable view over a parsed buffer at a specific cursor position. + * Immutable view over a parsed buffer. * - *

Every {@link #edit(Edit)} / {@link #moveCursor(int)} / {@link #reparseAll()} - * call returns a fresh {@code Session}; existing {@code Session} values are - * never mutated. This makes {@code Session} references safe to retain for - * undo stacks, diagnostic snapshots, and cross-thread reads. + *

0.5.0 (Lever D, SPEC §5): cursor state has been split out of + * {@code Session} into the standalone {@link Cursor} record. The Session + * holds {@link #text()}, {@link #root()}, {@link #index()}, and {@link #stats()}; + * the Cursor holds the editor pointer ({@code offset} + stable + * {@code enclosingNodeId}). State transitions return paired + * {@code (Session, Cursor)} outcomes — {@link EditOutcome} from + * {@link #edit(Cursor, Edit)}, {@link ReparseOutcome} from + * {@link #reparseAll(Cursor)}. + * + *

Cursor moves no longer allocate a Session. A {@link Cursor} can outlive + * the Session it was created with (Phase 1's stable IDs survive incremental + * splices for ancestors that aren't wholesale-replaced). * *

Two sessions produced from the same lineage share structure through - * untouched {@link CstNode} references (records are value objects; the - * incremental splice only reallocates the subtree that reparsed plus its - * ancestor spine — SPEC §4.2). + * untouched {@link CstNode} references; the splice only reallocates the + * subtree that reparsed plus its ancestor spine — SPEC §4.2. * *

Sessions are not thread-safe for concurrent reads and writes - * against the same instance — two concurrent {@code edit(...)} calls each - * produce an independent new session, which is well-defined, but neither - * instance observes the other's edit. Concurrent read-only access - * ({@link #root()}, {@link #text()}, {@link #cursor()}, {@link #stats()}) - * is safe. + * against the same instance. Concurrent read-only access ({@link #root()}, + * {@link #text()}, {@link #stats()}, {@link #index()}) is safe. * * @since 0.3.1 */ @@ -32,67 +37,69 @@ public interface Session { /** Current buffer text. */ String text(); - /** - * Current cursor offset, in the range {@code [0, text().length()]}. The - * cursor moves automatically with edits per SPEC §5.5: before the edit → - * unchanged; inside the edit region → snapped to the end of the - * replacement; after the edit → shifted by {@link Edit#delta()}. - */ - int cursor(); - /** Per-session diagnostic counters. */ Stats stats(); /** - * Apply an edit. Returns a new {@code Session}. + * The index over {@link #root()} used by {@link Cursor} for boundary + * resolution and by the engine for pivot lookup. + * + *

0.5.0 (Lever D): exposed as a public surface because {@link Cursor} + * needs an index to anchor an offset to an enclosing-node id, and callers + * need access to that index when constructing or moving a Cursor that + * outlives the session it was minted in. + */ + NodeIndex index(); + + /** + * Apply an edit. Returns a new {@link EditOutcome} carrying the post-edit + * Session and a Cursor whose offset has been shifted per SPEC §5.5 + * (before the edit → unchanged; inside the edit region → snapped to end + * of replacement; after the edit → shifted by {@link Edit#delta()}). * - *

No-op edits ({@code oldLen == 0 && newText.isEmpty()}) return - * {@code this} unchanged (SPEC §4.4 "Bounded reparse on no-op"). + *

No-op edits ({@code oldLen == 0 && newText.isEmpty()}) return an + * EditOutcome whose {@code newSession} is identical to {@code this} and + * whose {@code newCursor} is the supplied cursor (SPEC §4.4 "Bounded + * reparse on no-op"). * + * @param cursor the cursor before the edit; used as the warm pointer for + * boundary detection and as the source of the post-edit + * cursor offset. + * @param edit the edit to apply. * @throws IllegalArgumentException when the edit's offset or oldLen - * falls outside the current buffer. + * falls outside the current buffer, or when {@code edit} is + * {@code null}. */ - Session edit(Edit edit); + EditOutcome edit(Cursor cursor, Edit edit); - /** Convenience: {@code edit(new Edit(offset, oldLen, newText))}. */ - default Session edit(int offset, int oldLen, String newText) { - return edit(new Edit(offset, oldLen, newText)); + /** Convenience: {@code edit(cursor, new Edit(offset, oldLen, newText))}. */ + default EditOutcome edit(Cursor cursor, int offset, int oldLen, String newText) { + return edit(cursor, new Edit(offset, oldLen, newText)); } - /** - * Move the cursor to {@code newOffset} without changing the buffer or - * the tree. Clamps to {@code [0, text().length()]}. - */ - Session moveCursor(int newOffset); - /** * Discard the current CST and packrat cache, parse {@link #text()} * afresh. Diagnostic escape hatch + explicit recovery path after any * suspected incremental divergence. * - *

Returns a new {@code Session} whose {@link Stats#fullReparseCount} - * is incremented by one. + *

Returns a new {@link ReparseOutcome} whose + * {@code newSession.stats().fullReparseCount} is incremented by one. + * The cursor's {@link Cursor#offset()} is preserved; its + * {@link Cursor#enclosingNodeId} is re-resolved against the freshly built + * {@link NodeIndex}. */ - Session reparseAll(); + ReparseOutcome reparseAll(Cursor cursor); /** * 0.5.0 (Path A) — {@code true} when the most recent full parse (or the * splice-and-validate of an incremental reparse) produced a structurally - * valid CST; {@code false} when {@link #edit(Edit)} or {@link #reparseAll()} - * fell through to the degraded-Session synthesis path because the backing - * parser rejected the buffer. + * valid CST; {@code false} when {@link #edit(Cursor, Edit)} or + * {@link #reparseAll(Cursor)} fell through to the degraded-Session + * synthesis path because the backing parser rejected the buffer. * *

Default: {@code true}. The package-private degraded session produced * by {@code SessionFactory} on parse failure overrides this to surface the * failure without resorting to exceptional control flow. - * - *

Editor-style callers that need to distinguish "edit applied to a - * valid buffer" from "edit applied to a buffer the grammar rejects" - * inspect this flag (and {@link #lastParseError()} for the cause message). - * Callers that don't care continue to use {@link #root()} and - * {@link #text()} as before — the degraded session still has a non-null - * root and text, just a single {@link CstNode.Error} root carrying the - * rejected buffer. */ default boolean parseSuccessful() { return true; diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java index 06e1811..4e20920 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/IncrementalSession.java @@ -2,7 +2,10 @@ import org.pragmatica.lang.Option; import org.pragmatica.lang.Result; +import org.pragmatica.peg.incremental.Cursor; import org.pragmatica.peg.incremental.Edit; +import org.pragmatica.peg.incremental.EditOutcome; +import org.pragmatica.peg.incremental.ReparseOutcome; import org.pragmatica.peg.incremental.Session; import org.pragmatica.peg.incremental.SessionError; import org.pragmatica.peg.incremental.Stats; @@ -15,25 +18,19 @@ import java.util.List; /** - * Package-private {@link Session} implementation carrying the SPEC §5.1 - * state: text, root, cursor, enclosing-node pointer, and stats. + * Package-private {@link Session} implementation. * - *

The reparse-boundary algorithm (SPEC §5.3) is implemented inline in - * {@link #edit(Edit)}; it walks outward from the cached enclosing-node - * pointer until a containing rule node is found, invokes - * {@code parseRuleAt} at that boundary, and splices the result back in - * through {@link TreeSplicer}. On any failure mode the algorithm falls - * through to a full reparse. + *

0.5.0 (Lever D): cursor state ({@code offset}, {@code enclosingNode}) was + * split out of this record into the public {@link Cursor} type. The Session + * now carries only buffer state ({@code text}, {@code root}, {@code index}, + * {@code idGen}, {@code stats}, {@code lastError}) plus its owning + * {@link SessionFactory}. * - *

v1 cache-invalidation decision: every edit runs against a fresh - * {@code Parser}-internal packrat cache (the {@code parseRuleAt} call - * allocates a new {@code ParsingContext}). No cross-edit cache persistence. - * SPEC §5.4 v1 choice. - * - *

0.4.0 — converted from {@code SessionImpl} class to a {@code record} to - * remove the {@code Impl} anti-pattern. The seven components are internal - * implementation state; callers continue to consume the {@link Session} - * interface, which keeps the public surface narrow. + *

The reparse-boundary algorithm (SPEC §5.3) walks outward from the cursor's + * {@link Cursor#enclosingNodeId}-resolved warm pointer until a containing rule + * node is found, invokes {@code parseRuleAt} at that boundary, and splices the + * result back in through {@link TreeSplicer}. On any failure mode the + * algorithm falls through to a full reparse. * * @since 0.3.1 */ @@ -41,8 +38,6 @@ record IncrementalSession( SessionFactory factory, String text, CstNode root, - int cursor, - CstNode enclosingNode, NodeIndex index, IdGenerator idGen, Stats stats, @@ -50,13 +45,10 @@ record IncrementalSession( /** Build the initial session after a fresh full parse. */ static IncrementalSession initial(SessionFactory factory, String text, - int cursor, CstNode root, IdGenerator idGen) { var index = NodeIndex.build(root); - var enclosing = index.smallestContaining(cursor) - .or(root); - return new IncrementalSession(factory, text, root, cursor, enclosing, index, idGen, Stats.INITIAL, Option.none()); + return new IncrementalSession(factory, text, root, index, idGen, Stats.INITIAL, Option.none()); } /** @@ -65,35 +57,20 @@ static IncrementalSession initial(SessionFactory factory, * {@link SessionFactory#initialize(String, int)}. The resulting session * has a single {@link CstNode.Error} root carrying the rejected buffer * and an {@link Option#some(Object) Option.some(error)} for - * {@link Session#lastParseError()}. Stats start at {@code INITIAL}; no - * fallback count is incremented because the failure was on the very first - * parse, not on a fallback from incremental reparse. + * {@link Session#lastParseError()}. */ static IncrementalSession degradedInitial(SessionFactory factory, String text, - int cursor, IdGenerator idGen, SessionError error) { var root = degradedRoot(text, idGen, error); var index = NodeIndex.build(root); - var enclosing = index.smallestContaining(cursor) - .or(root); - return new IncrementalSession(factory, - text, - root, - cursor, - enclosing, - index, - idGen, - Stats.INITIAL, - Option.some(error)); + return new IncrementalSession(factory, text, root, index, idGen, Stats.INITIAL, Option.some(error)); } /** * Build the single-{@link CstNode.Error}-root that represents a degraded - * Session per Path A. The error span covers the entire buffer; the - * {@code skippedText} is the rejected buffer; {@code expected} is the - * {@link SessionError#message()}. Trivia lists are empty. + * Session per Path A. */ private static CstNode degradedRoot(String text, IdGenerator idGen, SessionError error) { var start = SourceLocation.START; @@ -113,7 +90,10 @@ public Option lastParseError() { } @Override - public Session edit(Edit edit) { + public EditOutcome edit(Cursor cursor, Edit edit) { + if (cursor == null) { + throw new IllegalArgumentException("cursor must not be null"); + } if (edit == null) { throw new IllegalArgumentException("edit must not be null"); } @@ -126,79 +106,55 @@ public Session edit(Edit edit) { "edit range [" + edit.offset() + ", " + (edit.offset() + edit.oldLen()) + ") exceeds text length " + text.length()); } if (edit.isNoOp()) { - return this; + return new EditOutcome(this, cursor); } long t0 = System.nanoTime(); var newText = applyEdit(edit); - int newCursor = shiftCursor(cursor, edit); - // 0.3.2 v2: trivia-only fast-path. Edits whose range is entirely - // contained in a single trivia run (whitespace or comment body) and - // whose replacement remains legal trivia content are handled without - // invoking the parser at all. The fast-path is gated by a per-factory - // {@code triviaFastPathEnabled} flag because the path is only safe - // for grammars where in-trivia edits cannot change tokenisation - // decisions of adjacent tokens (e.g. simple PEG grammars; not the - // Java grammar where {@code >>} vs {@code > >} parse differently). - // See TriviaRedistribution for the SPEC §5.4 v2 design notes and the - // v2.5 follow-up that aims to make this safe for all grammars. + int newCursorOffset = shiftCursor(cursor.offset(), edit); + // 0.3.2 v2: trivia-only fast-path. See TriviaRedistribution for the + // SPEC §5.4 v2 design notes. if (factory.triviaFastPathEnabled()) { var triviaRoot = TriviaRedistribution.tryTriviaOnlyEdit(root, newText, edit); if (triviaRoot != null) { var nextStats = new Stats( stats.reparseCount() + 1, stats.fullReparseCount(), "", 0, System.nanoTime() - t0); var nextIndex = NodeIndex.build(triviaRoot); - var nextEnclosing = nextIndex.smallestContaining(newCursor) - .or(triviaRoot); - return new IncrementalSession(factory, - newText, - triviaRoot, - newCursor, - nextEnclosing, - nextIndex, - idGen, - nextStats, - Option.none()); + var nextSession = new IncrementalSession(factory, + newText, + triviaRoot, + nextIndex, + idGen, + nextStats, + Option.none()); + return new EditOutcome(nextSession, Cursor.at(newCursorOffset, nextIndex)); } } // Try incremental reparse next. - var incremental = tryIncrementalReparse(newText, edit); + var incremental = tryIncrementalReparse(cursor, newText, edit); if (incremental.isPresent()) { - return applyIncremental(incremental.unwrap(), newText, newCursor, t0); + return applyIncremental(incremental.unwrap(), newText, newCursorOffset, t0); } // Fall back to a full reparse. 0.5.0 (Path A): on parse failure, // synthesise a degraded Session — never throw. - return fallback(newText, newCursor, t0) - .fold(cause -> degradedFallback(newText, newCursor, t0, (SessionError) cause), - session -> session); + return fallback(newText, newCursorOffset, t0) + .fold(cause -> degradedFallback(newText, newCursorOffset, t0, (SessionError) cause), + outcome -> outcome); } @Override - public Session moveCursor(int newOffset) { - int clamped = Math.max(0, - Math.min(newOffset, text.length())); - if (clamped == cursor) { - return this; + public ReparseOutcome reparseAll(Cursor cursor) { + if (cursor == null) { + throw new IllegalArgumentException("cursor must not be null"); } - var newEnclosing = index.smallestContainingFrom(enclosingNode, clamped) - .or(root); - return new IncrementalSession(factory, text, root, clamped, newEnclosing, index, idGen, stats, lastError); - } - - @Override - public Session reparseAll() { long t0 = System.nanoTime(); - // 0.5.0 (Path A): reparseAll is the diagnostic escape hatch. On parse - // failure synthesise a degraded Session rather than throw — preserves - // the public non-Result contract; callers inspect parseSuccessful(). + // 0.5.0 (Path A): reparseAll is the diagnostic escape hatch. return factory.parseFull(text, idGen) - .fold(cause -> degradedReparseAll(t0, (SessionError) cause), - fresh -> reparseAllSuccess(fresh, t0)); + .fold(cause -> degradedReparseAll(cursor, t0, (SessionError) cause), + fresh -> reparseAllSuccess(fresh, cursor, t0)); } - private Session reparseAllSuccess(CstNode fresh, long t0) { + private ReparseOutcome reparseAllSuccess(CstNode fresh, Cursor cursor, long t0) { var freshIndex = NodeIndex.build(fresh); - var enclosing = freshIndex.smallestContaining(cursor) - .or(fresh); var nextStats = new Stats( stats.reparseCount() + 1, stats.fullReparseCount() + 1, @@ -206,14 +162,13 @@ private Session reparseAllSuccess(CstNode fresh, long t0) { NodeIndex.flatten(fresh) .size(), System.nanoTime() - t0); - return new IncrementalSession(factory, text, fresh, cursor, enclosing, freshIndex, idGen, nextStats, Option.none()); + var nextSession = new IncrementalSession(factory, text, fresh, freshIndex, idGen, nextStats, Option.none()); + return new ReparseOutcome(nextSession, Cursor.at(cursor.offset(), freshIndex)); } - private Session degradedReparseAll(long t0, SessionError error) { + private ReparseOutcome degradedReparseAll(Cursor cursor, long t0, SessionError error) { var fresh = degradedRoot(text, idGen, error); var freshIndex = NodeIndex.build(fresh); - var enclosing = freshIndex.smallestContaining(cursor) - .or(fresh); var nextStats = new Stats( stats.reparseCount() + 1, stats.fullReparseCount() + 1, @@ -221,33 +176,26 @@ private Session degradedReparseAll(long t0, SessionError error) { NodeIndex.flatten(fresh) .size(), System.nanoTime() - t0); - return new IncrementalSession(factory, - text, - fresh, - cursor, - enclosing, - freshIndex, - idGen, - nextStats, - Option.some(error)); + var nextSession = new IncrementalSession(factory, + text, + fresh, + freshIndex, + idGen, + nextStats, + Option.some(error)); + return new ReparseOutcome(nextSession, Cursor.at(cursor.offset(), freshIndex)); } /** - * 0.5.0 — full-reparse fallback now returns {@code Result}. - * Parse success yields a healthy Session; parse failure is propagated as - * a {@link SessionError} {@link Result#failure(org.pragmatica.lang.Cause) failure} - * for the caller in {@link #edit(Edit)} to materialise as a degraded - * Session per Path A. + * 0.5.0 — full-reparse fallback now returns {@code Result}. */ - private Result fallback(String newText, int newCursor, long t0) { + private Result fallback(String newText, int newCursorOffset, long t0) { return factory.parseFull(newText, idGen) - .map(fresh -> fallbackSuccess(fresh, newText, newCursor, t0)); + .map(fresh -> fallbackSuccess(fresh, newText, newCursorOffset, t0)); } - private Session fallbackSuccess(CstNode fresh, String newText, int newCursor, long t0) { + private EditOutcome fallbackSuccess(CstNode fresh, String newText, int newCursorOffset, long t0) { var freshIndex = NodeIndex.build(fresh); - var enclosing = freshIndex.smallestContaining(newCursor) - .or(fresh); var nextStats = new Stats( stats.reparseCount() + 1, stats.fullReparseCount() + 1, @@ -255,28 +203,23 @@ private Session fallbackSuccess(CstNode fresh, String newText, int newCursor, lo NodeIndex.flatten(fresh) .size(), System.nanoTime() - t0); - return new IncrementalSession(factory, - newText, - fresh, - newCursor, - enclosing, - freshIndex, - idGen, - nextStats, - Option.none()); + var nextSession = new IncrementalSession(factory, + newText, + fresh, + freshIndex, + idGen, + nextStats, + Option.none()); + return new EditOutcome(nextSession, Cursor.at(newCursorOffset, freshIndex)); } /** * 0.5.0 (Path A) — synthesise the degraded Session for the {@code edit} - * fallback path. Mirrors {@link #fallbackSuccess} but with a - * single-{@link CstNode.Error}-root tree and {@code Option.some(error)} - * recorded in {@link #lastError}. + * fallback path. */ - private Session degradedFallback(String newText, int newCursor, long t0, SessionError error) { + private EditOutcome degradedFallback(String newText, int newCursorOffset, long t0, SessionError error) { var fresh = degradedRoot(newText, idGen, error); var freshIndex = NodeIndex.build(fresh); - var enclosing = freshIndex.smallestContaining(newCursor) - .or(fresh); var nextStats = new Stats( stats.reparseCount() + 1, stats.fullReparseCount() + 1, @@ -284,36 +227,25 @@ private Session degradedFallback(String newText, int newCursor, long t0, Session NodeIndex.flatten(fresh) .size(), System.nanoTime() - t0); - return new IncrementalSession(factory, - newText, - fresh, - newCursor, - enclosing, - freshIndex, - idGen, - nextStats, - Option.some(error)); + var nextSession = new IncrementalSession(factory, + newText, + fresh, + freshIndex, + idGen, + nextStats, + Option.some(error)); + return new EditOutcome(nextSession, Cursor.at(newCursorOffset, freshIndex)); } /** * Apply a successful incremental reparse: normalise trivia, update the - * NodeIndex, and return the next session snapshot. Extracted so the - * caller in {@link #edit(Edit)} stays a single Result-style branch. + * NodeIndex, and return the next {@link EditOutcome}. * *

Phase 1.6 (v0.5.0): Path D — calls * {@link NodeIndex#applyIncremental(CstNode, java.util.List, java.util.List)} - * instead of {@link NodeIndex#build(CstNode)}. Cost drops from O(N) to - * O(oldPivotSize + newPivotSize). The receiver index is invalidated; we - * use only the returned instance. - * - *

Trivia-redistribution caveat. When - * {@link TriviaRedistribution#normalizeSplicedTrivia} mutates the spliced - * subtree (currently a no-op for the leading-trivia direction; the seam - * exists for v2.5+), the {@code newPath} we computed before normalisation - * may carry stale record references for the pivot. We fall back to a full - * {@link NodeIndex#build} on that path so structural sharing isn't broken. + * instead of {@link NodeIndex#build(CstNode)}. */ - private Session applyIncremental(IncrementalResult incremental, String newText, int newCursor, long t0) { + private EditOutcome applyIncremental(IncrementalResult incremental, String newText, int newCursorOffset, long t0) { var normalized = TriviaRedistribution.normalizeSplicedTrivia( incremental.newRoot, incremental.spliced); var nextStats = new Stats( @@ -325,52 +257,29 @@ private Session applyIncremental(IncrementalResult incremental, String newText, System.nanoTime() - t0); NodeIndex nextIndex; if (normalized == incremental.newRoot && incremental.oldPath != null && incremental.newPath != null) { - // Path D fast-path: trivia normalisation was a no-op (the common - // case today) — apply the optimised O(oldPivotSize + newPivotSize) - // index update. nextIndex = index.applyIncremental(normalized, incremental.oldPath, incremental.newPath); }else { - // Trivia normalisation mutated the tree, OR the pivot was the root - // (newPath == null path-was-root branch in buildIncrementalResult). - // Fall back to a full rebuild — correct, just not Path-D-fast. nextIndex = NodeIndex.build(normalized); } - var nextEnclosing = nextIndex.smallestContaining(newCursor) - .or(normalized); - // Successful incremental reparse — never carries a parse-error flag; - // the splice already validated against the rule's expected end offset. - return new IncrementalSession(factory, - newText, - normalized, - newCursor, - nextEnclosing, - nextIndex, - idGen, - nextStats, - Option.none()); + var nextSession = new IncrementalSession(factory, + newText, + normalized, + nextIndex, + idGen, + nextStats, + Option.none()); + return new EditOutcome(nextSession, Cursor.at(newCursorOffset, nextIndex)); } /** * Attempt the incremental reparse per SPEC §5.3. Returns {@code Option.none()} * when the edit should fall back to a full reparse. - * - *

Walks outward from the enclosing-node pointer to the smallest - * {@code NonTerminal} ancestor whose span fully contains the edit - * region, then calls {@code parseRuleAt} on that ancestor's rule. If - * the partial parse succeeds and ends exactly at the ancestor's - * expected new-end offset ({@code oldEnd + delta}), the result is - * spliced in. Otherwise we walk one more level up and retry; reaching - * the root aborts to full reparse. - * - *

Any ancestor whose rule is listed in {@link SessionFactory#fallbackRules()} - * short-circuits to full reparse. */ - private Option tryIncrementalReparse(String newText, Edit edit) { + private Option tryIncrementalReparse(Cursor cursor, String newText, Edit edit) { int editStart = edit.offset(); int editEnd = edit.offset() + edit.oldLen(); int delta = edit.delta(); - // Find the smallest NonTerminal containing [editStart, editEnd] in the pre-edit buffer. - var current = Option.some(findBoundaryCandidate(editStart, editEnd)); + var current = Option.some(findBoundaryCandidate(cursor, editStart, editEnd)); while (current.isPresent()) { var pivot = current.unwrap(); if (! (pivot instanceof CstNode.NonTerminal nt)) { @@ -396,9 +305,6 @@ private IncrementalResult buildIncrementalResult(CstNode.NonTerminal nt, int delta) { var path = index.pathTo(nt); if (path.isEmpty()) { - // pivot == root — reparsed subtree replaces root wholesale. No - // path information for Path D; the caller falls back to - // NodeIndex.build via the null-newPath branch in applyIncremental. return new IncrementalResult(reparsedNode, reparsedNode, nt.rule(), null, null); } var oldPath = List.copyOf(path); @@ -407,17 +313,19 @@ private IncrementalResult buildIncrementalResult(CstNode.NonTerminal nt, } /** - * Walk outward from the enclosing-node pointer to the smallest node - * whose span fully contains {@code [editStart, editEnd]} in the pre-edit - * buffer. Falls back to {@link #root} when the edit exceeds the root - * (e.g., append past EOF) so the caller always gets a non-null pivot. + * Walk outward from the cursor's enclosing-node pointer to the smallest + * node whose span fully contains {@code [editStart, editEnd]} in the + * pre-edit buffer. Falls back to {@link #root} when: + *

    + *
  • the cursor's {@code enclosingNodeId} can't be resolved against + * the current index (cursor was minted against a different + * lineage, or its enclosing was wholesale-replaced);
  • + *
  • the edit exceeds the root span (e.g., append past EOF).
  • + *
*/ - private CstNode findBoundaryCandidate(int editStart, int editEnd) { - // 0.4.0 — Option.option() defends against a (theoretically) null - // {@code enclosingNode} record component; falls back to {@code root} - // so the walk is well-defined. - var current = Option.option(enclosingNode) - .orElse(Option.some(root)); + private CstNode findBoundaryCandidate(Cursor cursor, int editStart, int editEnd) { + var current = index.nodeById(cursor.enclosingNodeId()) + .orElse(Option.some(root)); while (current.isPresent()) { var cursorNode = current.unwrap(); int spanStart = cursorNode.span() @@ -431,7 +339,6 @@ private CstNode findBoundaryCandidate(int editStart, int editEnd) { } current = index.parentOf(cursorNode); } - // Root didn't contain the edit (e.g., append past EOF): pivot at root itself. return root; } @@ -444,10 +351,6 @@ private Option reparseAt(CstNode.NonTerminal nt, String newText, int de .offset() + delta; var ruleId = factory.registry() .classFor(nt.rule()); - // Phase 1.5 (v0.5.0): route through the id-aware overload so the - // spliced subtree's CstNode ids come from THIS session's counter, - // not a fresh per-call one. Path D's NodeIndex.applyIncremental - // requires every node in the lineage to share the same id space. var engine = (PegEngine) factory.parser(); var partial = engine.parseRuleAt(ruleId, newText, startOffset, idGen); return partial.option() @@ -458,10 +361,7 @@ private Option reparseAt(CstNode.NonTerminal nt, String newText, int de .map(p -> p.node()); } - /** - * Apply the edit to {@link #text} and return the new buffer. Uses - * {@link StringBuilder} to keep the allocation count explicit. - */ + /** Apply the edit to {@link #text} and return the new buffer. */ private String applyEdit(Edit edit) { var sb = new StringBuilder(text.length() + edit.delta()); sb.append(text, 0, edit.offset()); diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java index 287664e..8c93eb5 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/NodeIndex.java @@ -220,6 +220,22 @@ public CstNode root() { return root; } + /** + * Resolve a stable {@link CstNode#id() id} to its current record reference + * in the post-edit tree. Returns {@link Option#none()} when the id is not + * known to this index — including the case where the id was valid in a + * predecessor index but its node was wholesale-replaced by an incremental + * splice (in which case the carrying record is gone with that splice). + * + *

0.5.0 (Lever D): used by {@link org.pragmatica.peg.incremental.Cursor} + * to convert its persistent {@code enclosingNodeId} back into a warm + * pointer for the boundary walk in + * {@link org.pragmatica.peg.incremental.internal.IncrementalSession}. + */ + public Option nodeById(long id) { + return Option.option(nodesById.get(id)); + } + /** * The parent of {@code node}, or empty {@link Option} when {@code node} * is the root (or not present in this index). diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java index 1489e51..fc60c01 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/internal/SessionFactory.java @@ -4,7 +4,9 @@ import org.pragmatica.peg.PegParser; import org.pragmatica.peg.action.RuleId; import org.pragmatica.peg.grammar.Grammar; +import org.pragmatica.peg.incremental.Cursor; import org.pragmatica.peg.incremental.IncrementalParser; +import org.pragmatica.peg.incremental.InitialSession; import org.pragmatica.peg.incremental.Session; import org.pragmatica.peg.incremental.SessionError; import org.pragmatica.peg.parser.Parser; @@ -100,7 +102,7 @@ public static IncrementalParser sessionFactory(Grammar grammar, } @Override - public Session initialize(String buffer, int cursorOffset) { + public InitialSession initialize(String buffer, int cursorOffset) { if (buffer == null) { throw new IllegalArgumentException("buffer must not be null"); } @@ -114,9 +116,12 @@ public Session initialize(String buffer, int cursorOffset) { // degraded Session per Path A: caller's contract on initialize is // non-Result, so wrap the failure in a CstNode.Error root and surface // it through Session#parseSuccessful()/lastParseError(). - return parseFull(buffer, idGen) - .fold(cause -> IncrementalSession.degradedInitial(this, buffer, clampedCursor, idGen, (SessionError) cause), - root -> IncrementalSession.initial(this, buffer, clampedCursor, root, idGen)); + // 0.5.0 (Lever D): return Session paired with an initial Cursor. + IncrementalSession session = parseFull(buffer, idGen) + .fold(cause -> IncrementalSession.degradedInitial(this, buffer, idGen, (SessionError) cause), + root -> IncrementalSession.initial(this, buffer, root, idGen)); + var cursor = Cursor.at(clampedCursor, session.index()); + return new InitialSession(session, cursor); } Grammar grammar() { diff --git a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/package-info.java b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/package-info.java index 02af4ce..6399f2e 100644 --- a/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/package-info.java +++ b/peglib-incremental/src/main/java/org/pragmatica/peg/incremental/package-info.java @@ -7,11 +7,20 @@ * turns a {@link org.pragmatica.peg.grammar.Grammar} into an incremental * parser. *

  • {@link org.pragmatica.peg.incremental.Session} — immutable per-buffer - * state; every {@code edit(...)} / {@code moveCursor(...)} call returns a - * new {@code Session} sharing untouched CST subtrees with its predecessor.
  • + * state ({@code text}, {@code root}, {@code index}, {@code stats}). + * Every {@code edit(...)} / {@code reparseAll(...)} call returns a new + * Session sharing untouched CST subtrees with its predecessor. + *
  • {@link org.pragmatica.peg.incremental.Cursor} — 0.5.0 (Lever D): + * editor-style cursor, split out of {@code Session}; carries an offset + * and the stable id of its enclosing CST node so cursor moves don't + * allocate a Session.
  • *
  • {@link org.pragmatica.peg.incremental.Edit} — record describing a single * splice over the current buffer ({@code offset}, {@code oldLen}, * {@code newText}).
  • + *
  • {@link org.pragmatica.peg.incremental.EditOutcome} / + * {@link org.pragmatica.peg.incremental.ReparseOutcome} / + * {@link org.pragmatica.peg.incremental.InitialSession} — paired + * {@code (Session, Cursor)} return shapes from edit / reparse / initialize.
  • *
  • {@link org.pragmatica.peg.incremental.Stats} — per-session bookkeeping * exposed for diagnostics / JMH.
  • * diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/BackReferenceFallbackTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/BackReferenceFallbackTest.java index ab4fb73..1952912 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/BackReferenceFallbackTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/BackReferenceFallbackTest.java @@ -42,8 +42,9 @@ void scan_flags_unsafe() { @DisplayName("Edit inside back-ref-bearing rule falls back to full reparse") void edit_triggers_full_reparse() { var parser = IncrementalParser.create(grammar()); - var s0 = parser.initialize("content"); - var s1 = s0.edit(3, 0, "ab"); + var init = parser.initialize("content"); + var s0 = init.session(); + var s1 = s0.edit(init.cursor(), 3, 0, "ab").newSession(); assertThat(s1.stats().fullReparseCount()).isEqualTo(1); // Parity still holds. var oracle = PegParser.fromGrammar(grammar()).fold( diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IdempotencyTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IdempotencyTest.java index 6551cd9..9483a6b 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IdempotencyTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IdempotencyTest.java @@ -16,9 +16,6 @@ */ final class IdempotencyTest { - // Permissive grammar: intermediate buffers stay parseable after a - // partial edit, so inverse-edit parity tests aren't forced through the - // full-reparse fallback by an unparseable intermediate. private static final String GRAMMAR = """ Program <- Token* Token <- Word / Punct @@ -37,47 +34,51 @@ private static IncrementalParser parser() { @Test @DisplayName("Insert then delete restores the CST hash") void insert_then_delete() { - var s0 = parser().initialize("let x = 1;"); + var init = parser().initialize("let x = 1;"); + var s0 = init.session(); long hash0 = CstHash.cstHash(s0.root()); - var s1 = s0.edit(9, 0, "42"); - var s2 = s1.edit(9, 2, ""); - assertThat(CstHash.cstHash(s2.root())).isEqualTo(hash0); - assertThat(s2.text()).isEqualTo(s0.text()); + var o1 = s0.edit(init.cursor(), 9, 0, "42"); + var o2 = o1.newSession().edit(o1.newCursor(), 9, 2, ""); + assertThat(CstHash.cstHash(o2.newSession().root())).isEqualTo(hash0); + assertThat(o2.newSession().text()).isEqualTo(s0.text()); } @Test @DisplayName("Delete then insert restores the CST hash") void delete_then_insert() { - var s0 = parser().initialize("let x = 123;"); + var init = parser().initialize("let x = 123;"); + var s0 = init.session(); long hash0 = CstHash.cstHash(s0.root()); - var s1 = s0.edit(8, 3, ""); - var s2 = s1.edit(8, 0, "123"); - assertThat(CstHash.cstHash(s2.root())).isEqualTo(hash0); - assertThat(s2.text()).isEqualTo(s0.text()); + var o1 = s0.edit(init.cursor(), 8, 3, ""); + var o2 = o1.newSession().edit(o1.newCursor(), 8, 0, "123"); + assertThat(CstHash.cstHash(o2.newSession().root())).isEqualTo(hash0); + assertThat(o2.newSession().text()).isEqualTo(s0.text()); } @Test @DisplayName("Replace then restore via inverse replacement") void replace_then_restore() { - var s0 = parser().initialize("let x = 1; let y = 2;"); + var init = parser().initialize("let x = 1; let y = 2;"); + var s0 = init.session(); long hash0 = CstHash.cstHash(s0.root()); - var s1 = s0.edit(4, 1, "foo"); - var s2 = s1.edit(4, 3, "x"); - assertThat(CstHash.cstHash(s2.root())).isEqualTo(hash0); + var o1 = s0.edit(init.cursor(), 4, 1, "foo"); + var o2 = o1.newSession().edit(o1.newCursor(), 4, 3, "x"); + assertThat(CstHash.cstHash(o2.newSession().root())).isEqualTo(hash0); } @Test @DisplayName("Undo via saved session reference is O(1)") void undo_via_session_reference() { - var s0 = parser().initialize("let x = 1;"); - var s1 = s0.edit(9, 0, "42"); + var init = parser().initialize("let x = 1;"); + var s0 = init.session(); + var o1 = s0.edit(init.cursor(), 9, 0, "42"); // Simulate undo: restore s0. Tree reference is unchanged. assertThat(s0.root()).isNotNull(); assertThat(s0.text()).isEqualTo("let x = 1;"); - assertThat(s1.text()).isEqualTo("let x = 142;"); - // Re-edit from s0 works independently of s1. - var s2 = s0.edit(9, 0, "99"); - assertThat(s2.text()).isEqualTo("let x = 199;"); + assertThat(o1.newSession().text()).isEqualTo("let x = 142;"); + // Re-edit from s0 works independently of o1. + var o2 = s0.edit(init.cursor(), 9, 0, "99"); + assertThat(o2.newSession().text()).isEqualTo("let x = 199;"); } @Test @@ -85,9 +86,10 @@ void undo_via_session_reference() { void session_forking() { var oracle = PegParser.fromGrammar(GRAMMAR).fold( cause -> { throw new IllegalStateException(cause.message()); }, p -> p); - var s0 = parser().initialize("let x = 1;"); - var branchA = s0.edit(9, 0, "42"); - var branchB = s0.edit(0, 0, "let z = 0; "); + var init = parser().initialize("let x = 1;"); + var s0 = init.session(); + var branchA = s0.edit(init.cursor(), 9, 0, "42").newSession(); + var branchB = s0.edit(init.cursor(), 0, 0, "let z = 0; ").newSession(); assertThat(CstHash.cstHash(branchA.root())) .isEqualTo(CstHash.cstHash(oracle.parseCst(branchA.text()).unwrap())); assertThat(CstHash.cstHash(branchB.root())) diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IncrementalParityTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IncrementalParityTest.java index 4a5b93f..3602c17 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IncrementalParityTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IncrementalParityTest.java @@ -116,7 +116,9 @@ void parity(Path file) throws IOException { return; } - Session session = incremental.initialize(initialText, 0); + var init = incremental.initialize(initialText, 0); + Session session = init.session(); + Cursor cursor = init.cursor(); long oracleHash = CstHash.cstHash(oracleInitial.unwrap()); assertThat(CstHash.cstHash(session.root())) .as("initial parity for %s", file.getFileName()) @@ -128,9 +130,9 @@ void parity(Path file) throws IOException { if (edit == null) { continue; } - Session next; + EditOutcome outcome; try { - next = session.edit(edit); + outcome = session.edit(cursor, edit); } catch (RuntimeException ex) { // Full-parse failure under the oracle typically points at the // grammar rejecting an intermediate fuzzed buffer. Skip rather @@ -138,6 +140,7 @@ void parity(Path file) throws IOException { // robustness. continue; } + var next = outcome.newSession(); var oracleResult = oracleParser.parseCst(next.text()); if (oracleResult.isFailure()) { // Intermediate fuzz produced text the grammar rejects (e.g., @@ -148,6 +151,7 @@ void parity(Path file) throws IOException { // that's a legitimate divergence: assert it does not happen // by retaining the prior session. session = next; + cursor = outcome.newCursor(); continue; } long expected = CstHash.cstHash(oracleResult.unwrap()); @@ -156,6 +160,7 @@ void parity(Path file) throws IOException { .as("parity after edit %d in %s (edit=%s)", i, file.getFileName(), edit) .isEqualTo(expected); session = next; + cursor = outcome.newCursor(); } } diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IncrementalTriviaParityTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IncrementalTriviaParityTest.java index f283360..47f4ffd 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IncrementalTriviaParityTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/IncrementalTriviaParityTest.java @@ -104,7 +104,9 @@ void parity(Path file) throws IOException { return; } - Session session = incremental.initialize(initialText, 0); + var init = incremental.initialize(initialText, 0); + Session session = init.session(); + Cursor cursor = init.cursor(); long oracleHash = CstHash.cstHash(oracleInitial.unwrap()); assertThat(CstHash.cstHash(session.root())) .as("initial parity for %s", file.getFileName()) @@ -116,15 +118,17 @@ void parity(Path file) throws IOException { if (edit == null) { continue; } - Session next; + EditOutcome outcome; try { - next = session.edit(edit); + outcome = session.edit(cursor, edit); } catch (RuntimeException ex) { continue; } + var next = outcome.newSession(); var oracleResult = oracleParser.parseCst(next.text()); if (oracleResult.isFailure()) { session = next; + cursor = outcome.newCursor(); continue; } long expected = CstHash.cstHash(oracleResult.unwrap()); @@ -133,6 +137,7 @@ void parity(Path file) throws IOException { .as("trivia-biased parity after edit %d in %s (edit=%s)", i, file.getFileName(), edit) .isEqualTo(expected); session = next; + cursor = outcome.newCursor(); } } diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/ReparseBoundaryTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/ReparseBoundaryTest.java index f76a816..aaad855 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/ReparseBoundaryTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/ReparseBoundaryTest.java @@ -15,11 +15,6 @@ */ final class ReparseBoundaryTest { - // Permissive grammar: most boundary-expansion edge cases involve edits - // that temporarily violate a stricter grammar. Using a permissive - // grammar keeps the focus on the reparse-boundary algorithm (shape - // changes, straddles, trivia-internal edits, etc.) rather than on - // strict input-rejection semantics. private static final String GRAMMAR = """ Program <- Token* Token <- Word / Punct @@ -38,6 +33,14 @@ private static IncrementalParser parser() { return IncrementalParser.create(grammar()); } + /** + * Apply a single edit to a freshly initialised session over {@code text}. + */ + private static Session editFromInit(String text, int offset, int oldLen, String newText) { + var init = parser().initialize(text); + return init.session().edit(init.cursor(), offset, oldLen, newText).newSession(); + } + private static void assertParity(Session session) { var oracle = PegParser.fromGrammar(grammar()) .fold(cause -> { throw new IllegalStateException(cause.message()); }, p -> p); @@ -51,7 +54,7 @@ private static void assertParity(Session session) { @Test @DisplayName("Edit at buffer start") void edit_at_buffer_start() { - var s = parser().initialize("let x = 1;").edit(0, 0, "let a = 99; "); + var s = editFromInit("let x = 1;", 0, 0, "let a = 99; "); assertParity(s); } @@ -59,84 +62,89 @@ void edit_at_buffer_start() { @DisplayName("Edit at buffer end") void edit_at_buffer_end() { var text = "let x = 1;"; - var s = parser().initialize(text).edit(text.length(), 0, " let y = 2;"); + var s = editFromInit(text, text.length(), 0, " let y = 2;"); assertParity(s); } @Test @DisplayName("Insert a whole new top-level rule") void insert_new_statement() { - var s = parser().initialize("let x = 1;").edit(0, 0, "let new = 42; "); + var s = editFromInit("let x = 1;", 0, 0, "let new = 42; "); assertParity(s); } @Test @DisplayName("Delete a whole statement shrinks the tree") void delete_statement() { - var s = parser().initialize("let x = 1; let y = 2;").edit(0, 11, ""); + var s = editFromInit("let x = 1; let y = 2;", 0, 11, ""); assertParity(s); } @Test @DisplayName("Edit inside a number leaf") void edit_inside_number() { - var s = parser().initialize("let x = 1;").edit(8, 1, "42"); + var s = editFromInit("let x = 1;", 8, 1, "42"); assertParity(s); } @Test @DisplayName("Edit inside an identifier leaf") void edit_inside_ident() { - var s = parser().initialize("let x = 1;").edit(4, 1, "abc"); + var s = editFromInit("let x = 1;", 4, 1, "abc"); assertParity(s); } @Test @DisplayName("Edit entirely within whitespace trivia") void edit_within_whitespace() { - var s = parser().initialize("let x = 1;").edit(7, 1, " "); + var s = editFromInit("let x = 1;", 7, 1, " "); assertParity(s); } @Test @DisplayName("Edit that straddles two statements") void edit_straddles_statements() { - var s = parser().initialize("let x = 1; let y = 2;").edit(9, 4, "99; let z = "); + var s = editFromInit("let x = 1; let y = 2;", 9, 4, "99; let z = "); assertParity(s); } @Test @DisplayName("Edit that increases nesting via wrapper parens — here by retyping ident longer") void edit_changes_rule_shape() { - var s = parser().initialize("let x = 1;").edit(4, 1, "longername"); + var s = editFromInit("let x = 1;", 4, 1, "longername"); assertParity(s); } @Test @DisplayName("Pure insertion at interior offset") void pure_insertion_interior() { - var s = parser().initialize("let x = 1; let y = 2;").edit(11, 0, "let z = 3; "); + var s = editFromInit("let x = 1; let y = 2;", 11, 0, "let z = 3; "); assertParity(s); } @Test @DisplayName("Several sequential edits produce parity at every step") void sequential_edits_parity() { - var session = parser().initialize("let x = 1;"); - session = session.edit(session.text().length(), 0, " let y = 2;"); - assertParity(session); - session = session.edit(session.text().length(), 0, " let z = 3;"); - assertParity(session); - session = session.edit(0, 0, "let a = 0; "); - assertParity(session); - session = session.edit(session.text().indexOf("1"), 1, "99"); - assertParity(session); + var init = parser().initialize("let x = 1;"); + var session = init.session(); + var cursor = init.cursor(); + var o1 = session.edit(cursor, session.text().length(), 0, " let y = 2;"); + assertParity(o1.newSession()); + var o2 = o1.newSession().edit(o1.newCursor(), o1.newSession().text().length(), 0, " let z = 3;"); + assertParity(o2.newSession()); + var o3 = o2.newSession().edit(o2.newCursor(), 0, 0, "let a = 0; "); + assertParity(o3.newSession()); + var o4 = o3.newSession().edit(o3.newCursor(), + o3.newSession().text().indexOf("1"), + 1, + "99"); + assertParity(o4.newSession()); } @Test @DisplayName("Buffer that becomes empty after a deletion") void delete_entire_buffer() { - var s = parser().initialize("let x = 1;").edit(0, "let x = 1;".length(), ""); + var s = editFromInit("let x = 1;", 0, "let x = 1;".length(), ""); assertThat(s.text()).isEmpty(); assertParity(s); } diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/SessionApiTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/SessionApiTest.java index f47db62..a4f88f9 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/SessionApiTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/SessionApiTest.java @@ -11,14 +11,11 @@ /** * Tests covering the public {@code Session} / {@code IncrementalParser} / - * {@code Edit} API shape: no-op edits, cursor clamping, cursor shift rules, - * and the invariants in SPEC §4.4. + * {@code Edit} / {@code Cursor} API shape: no-op edits, cursor clamping, + * cursor shift rules, and the invariants in SPEC §4.4 / §5. */ final class SessionApiTest { - // Permissive grammar: accepts any sequence of tokens + punctuation so - // intermediate fuzz / edited buffers still parse. The session API tests - // care about offsets, cursor shifts, stats — not about grammar precision. private static final String GRAMMAR = """ Program <- Token* Token <- Word / Punct @@ -84,28 +81,28 @@ void initialize_rejects_null_buffer() { @Test void initialize_default_cursor_is_zero() { - var s = parser().initialize("let x = 1;"); - assertThat(s.cursor()).isEqualTo(0); - assertThat(s.text()).isEqualTo("let x = 1;"); + var init = parser().initialize("let x = 1;"); + assertThat(init.cursor().offset()).isEqualTo(0); + assertThat(init.session().text()).isEqualTo("let x = 1;"); } @Test void initialize_clamps_cursor_over_end() { - var s = parser().initialize("let x = 1;", 9999); - assertThat(s.cursor()).isEqualTo("let x = 1;".length()); + var init = parser().initialize("let x = 1;", 9999); + assertThat(init.cursor().offset()).isEqualTo("let x = 1;".length()); } @Test void initialize_clamps_cursor_negative() { - var s = parser().initialize("let x = 1;", -5); - assertThat(s.cursor()).isEqualTo(0); + var init = parser().initialize("let x = 1;", -5); + assertThat(init.cursor().offset()).isEqualTo(0); } @Test void initialize_emits_tree_root() { - var s = parser().initialize("let x = 1;"); - assertThat(s.root()).isNotNull(); - assertThat(s.stats().reparseCount()).isEqualTo(0); + var init = parser().initialize("let x = 1;"); + assertThat(init.session().root()).isNotNull(); + assertThat(init.session().stats().reparseCount()).isEqualTo(0); } } @@ -114,37 +111,38 @@ void initialize_emits_tree_root() { class NoOp { @Test void no_op_edit_returns_same_session() { - var s0 = parser().initialize("let x = 1;"); - var s1 = s0.edit(0, 0, ""); - assertThat(s1).isSameAs(s0); - assertThat(s0.stats().reparseCount()).isEqualTo(0); + var init = parser().initialize("let x = 1;"); + var outcome = init.session().edit(init.cursor(), 0, 0, ""); + assertThat(outcome.newSession()).isSameAs(init.session()); + assertThat(outcome.newCursor()).isSameAs(init.cursor()); + assertThat(init.session().stats().reparseCount()).isEqualTo(0); } } @Nested - @DisplayName("Cursor movement") + @DisplayName("Cursor movement (Lever D — pure, no Session allocation)") class CursorMove { @Test void move_cursor_pure_does_not_change_tree() { - var s0 = parser().initialize("let x = 1;", 0); - var s1 = s0.moveCursor(4); - assertThat(s1.cursor()).isEqualTo(4); - assertThat(s1.text()).isEqualTo(s0.text()); - assertThat(s1.root()).isSameAs(s0.root()); + var init = parser().initialize("let x = 1;", 0); + var moved = init.cursor().moveTo(4, init.session().index()); + assertThat(moved.offset()).isEqualTo(4); + // Session was not touched; same root, same text. + assertThat(init.session().text()).isEqualTo("let x = 1;"); } @Test void move_cursor_clamps_to_buffer() { - var s0 = parser().initialize("let x = 1;"); - var s1 = s0.moveCursor(1000); - assertThat(s1.cursor()).isEqualTo("let x = 1;".length()); + var init = parser().initialize("let x = 1;"); + var moved = init.cursor().moveTo(1000, init.session().index()); + assertThat(moved.offset()).isEqualTo("let x = 1;".length()); } @Test - void move_cursor_same_position_returns_same_session() { - var s0 = parser().initialize("let x = 1;", 5); - var s1 = s0.moveCursor(5); - assertThat(s1).isSameAs(s0); + void move_cursor_to_same_offset_yields_equal_record() { + var init = parser().initialize("let x = 1;", 5); + var moved = init.cursor().moveTo(5, init.session().index()); + assertThat(moved).isEqualTo(init.cursor()); } } @@ -153,23 +151,23 @@ void move_cursor_same_position_returns_same_session() { class CursorShift { @Test void cursor_before_edit_stays_put() { - var s0 = parser().initialize("let x = 1;", 2); // cursor at 'l' | 'et' - var s1 = s0.edit(6, 0, "y"); // insert after 'x ' - assertThat(s1.cursor()).isEqualTo(2); + var init = parser().initialize("let x = 1;", 2); // cursor at 'l' | 'et' + var outcome = init.session().edit(init.cursor(), 6, 0, "y"); // insert after 'x ' + assertThat(outcome.newCursor().offset()).isEqualTo(2); } @Test void cursor_after_edit_shifts_by_delta() { - var s0 = parser().initialize("let x = 1;", 9); // near end - var s1 = s0.edit(4, 1, "yyy"); // replace 'x' with 'yyy' -> delta +2 - assertThat(s1.cursor()).isEqualTo(11); + var init = parser().initialize("let x = 1;", 9); // near end + var outcome = init.session().edit(init.cursor(), 4, 1, "yyy"); // replace 'x' with 'yyy' + assertThat(outcome.newCursor().offset()).isEqualTo(11); } @Test void cursor_inside_edit_snaps_to_end_of_replacement() { - var s0 = parser().initialize("let x = 1;", 5); // cursor between 'x' and ' ' - var s1 = s0.edit(4, 4, "foo = "); // replace 'x = ' with 'foo = ' - assertThat(s1.cursor()).isEqualTo(4 + "foo = ".length()); + var init = parser().initialize("let x = 1;", 5); // cursor between 'x' and ' ' + var outcome = init.session().edit(init.cursor(), 4, 4, "foo = "); // replace 'x = ' with 'foo = ' + assertThat(outcome.newCursor().offset()).isEqualTo(4 + "foo = ".length()); } } @@ -178,15 +176,15 @@ void cursor_inside_edit_snaps_to_end_of_replacement() { class OutOfRange { @Test void edit_offset_past_end_rejected() { - var s = parser().initialize("abc"); - assertThatThrownBy(() -> s.edit(100, 0, "x")) + var init = parser().initialize("abc"); + assertThatThrownBy(() -> init.session().edit(init.cursor(), 100, 0, "x")) .isInstanceOf(IllegalArgumentException.class); } @Test void edit_range_past_end_rejected() { - var s = parser().initialize("abc"); - assertThatThrownBy(() -> s.edit(1, 100, "")) + var init = parser().initialize("abc"); + assertThatThrownBy(() -> init.session().edit(init.cursor(), 1, 100, "")) .isInstanceOf(IllegalArgumentException.class); } } @@ -196,18 +194,18 @@ void edit_range_past_end_rejected() { class ReparseAll { @Test void reparse_all_increments_fullReparseCount() { - var s0 = parser().initialize("let x = 1;"); - var s1 = s0.reparseAll(); - assertThat(s1.stats().fullReparseCount()).isEqualTo(1); - assertThat(s1.stats().reparseCount()).isEqualTo(1); + var init = parser().initialize("let x = 1;"); + var outcome = init.session().reparseAll(init.cursor()); + assertThat(outcome.newSession().stats().fullReparseCount()).isEqualTo(1); + assertThat(outcome.newSession().stats().reparseCount()).isEqualTo(1); } @Test void reparse_all_preserves_text_and_cursor() { - var s0 = parser().initialize("let x = 1;", 5); - var s1 = s0.reparseAll(); - assertThat(s1.text()).isEqualTo(s0.text()); - assertThat(s1.cursor()).isEqualTo(5); + var init = parser().initialize("let x = 1;", 5); + var outcome = init.session().reparseAll(init.cursor()); + assertThat(outcome.newSession().text()).isEqualTo(init.session().text()); + assertThat(outcome.newCursor().offset()).isEqualTo(5); } } @@ -216,15 +214,15 @@ void reparse_all_preserves_text_and_cursor() { class StatsChecks { @Test void initial_stats_zero() { - var s = parser().initialize("let x = 1;"); - assertThat(s.stats()).isEqualTo(Stats.INITIAL); + var init = parser().initialize("let x = 1;"); + assertThat(init.session().stats()).isEqualTo(Stats.INITIAL); } @Test void edit_increments_reparseCount() { - var s0 = parser().initialize("let x = 1;"); - var s1 = s0.edit(9, 0, "2"); - assertThat(s1.stats().reparseCount()).isEqualTo(1); + var init = parser().initialize("let x = 1;"); + var outcome = init.session().edit(init.cursor(), 9, 0, "2"); + assertThat(outcome.newSession().stats().reparseCount()).isEqualTo(1); } @Test diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/TriviaRedistributionTest.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/TriviaRedistributionTest.java index 02dba64..43617c1 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/TriviaRedistributionTest.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/TriviaRedistributionTest.java @@ -43,12 +43,20 @@ private static Grammar grammar() { } private static IncrementalParser parser() { - // 0.3.2 v2: trivia fast-path opt-in. Safe for this grammar because no - // adjacent-token tokenisation depends on intervening whitespace - // count (Word and Punct are character-class disjoint). + // 0.3.2 v2: trivia fast-path opt-in. return IncrementalParser.create(grammar(), ParserConfig.DEFAULT, true); } + /** + * Apply a single edit to a freshly initialised session over {@code text} + * and return the post-edit Session. Lever D: takes the cursor from the + * InitialSession, threads it through the edit. + */ + private static Session editFromInit(String text, int offset, int oldLen, String newText) { + var init = parser().initialize(text); + return init.session().edit(init.cursor(), offset, oldLen, newText).newSession(); + } + private static void assertParity(Session session) { var oracle = PegParser.fromGrammar(grammar()) .fold(cause -> { throw new IllegalStateException(cause.message()); }, p -> p); @@ -66,17 +74,16 @@ final class TriviaOnly { @Test @DisplayName("Insert a single space inside an existing whitespace run") void insert_space_in_whitespace() { - var s = parser().initialize("let x = 1;").edit(7, 0, " "); + var s = editFromInit("let x = 1;", 7, 0, " "); assertParity(s); assertThat(s.text()).isEqualTo("let x = 1;"); - // Fast-path tag: stats.lastReparsedRule should be "". assertThat(s.stats().lastReparsedRule()).isEqualTo(""); } @Test @DisplayName("Delete a space inside an existing whitespace run") void delete_space_in_whitespace() { - var s = parser().initialize("let x = 1;").edit(7, 1, ""); + var s = editFromInit("let x = 1;", 7, 1, ""); assertParity(s); assertThat(s.text()).isEqualTo("let x = 1;"); assertThat(s.stats().lastReparsedRule()).isEqualTo(""); @@ -85,7 +92,7 @@ void delete_space_in_whitespace() { @Test @DisplayName("Replace whitespace run with different whitespace mixture") void replace_whitespace_with_whitespace() { - var s = parser().initialize("let x = 1;").edit(6, 1, "\t"); + var s = editFromInit("let x = 1;", 6, 1, "\t"); assertParity(s); assertThat(s.stats().lastReparsedRule()).isEqualTo(""); } @@ -93,7 +100,7 @@ void replace_whitespace_with_whitespace() { @Test @DisplayName("Insert a newline (still whitespace) into a whitespace run") void insert_newline_in_whitespace() { - var s = parser().initialize("let x = 1;\n let y = 2;").edit(11, 0, "\n"); + var s = editFromInit("let x = 1;\n let y = 2;", 11, 0, "\n"); assertParity(s); assertThat(s.stats().lastReparsedRule()).isEqualTo(""); } @@ -101,11 +108,7 @@ void insert_newline_in_whitespace() { @Test @DisplayName("Delete a single character from a multi-line whitespace run") void delete_blank_line_between_statements() { - // Whitespace tokenisation in 0.2.4 makes each char a separate - // trivia entry, so delete-of-a-single-whitespace-char fits the - // single-run fast-path; a multi-char delete spans entries and - // intentionally falls through to structural reparse. - var s = parser().initialize("let x = 1;\n\n\nlet y = 2;").edit(11, 1, ""); + var s = editFromInit("let x = 1;\n\n\nlet y = 2;", 11, 1, ""); assertParity(s); assertThat(s.text()).isEqualTo("let x = 1;\n\nlet y = 2;"); assertThat(s.stats().lastReparsedRule()).isEqualTo(""); @@ -119,21 +122,16 @@ final class StructuralFallthrough { @Test @DisplayName("Edit straddling whitespace + token: must NOT take fast-path") void edit_spans_trivia_and_token() { - var s = parser().initialize("let x = 1;").edit(5, 3, "y ="); + var s = editFromInit("let x = 1;", 5, 3, "y ="); assertParity(s); - // The edit overlaps the identifier 'x' so the trivia fast-path - // must decline; lastReparsedRule should be a real rule, not the - // trivia sentinel. assertThat(s.stats().lastReparsedRule()).isNotEqualTo(""); } @Test @DisplayName("Insert non-whitespace into a whitespace run: declines fast-path") void insert_non_whitespace_in_whitespace() { - var s = parser().initialize("let x = 1;").edit(6, 0, "y"); + var s = editFromInit("let x = 1;", 6, 0, "y"); assertParity(s); - // Inserting 'y' inside whitespace introduces a new identifier — the - // fast-path's same-class check rejects this; structural reparse runs. assertThat(s.stats().lastReparsedRule()).isNotEqualTo(""); } } @@ -145,19 +143,21 @@ final class DeletedTrivia { @Test @DisplayName("Sequential trivia deletes still produce parity at every step") void multiple_trivia_deletes() { - var session = parser().initialize("let x = 1 ;"); - session = session.edit(5, 1, ""); - assertParity(session); - session = session.edit(7, 1, ""); - assertParity(session); - session = session.edit(9, 1, ""); - assertParity(session); + var init = parser().initialize("let x = 1 ;"); + var session = init.session(); + var cursor = init.cursor(); + var o1 = session.edit(cursor, 5, 1, ""); + assertParity(o1.newSession()); + var o2 = o1.newSession().edit(o1.newCursor(), 7, 1, ""); + assertParity(o2.newSession()); + var o3 = o2.newSession().edit(o2.newCursor(), 9, 1, ""); + assertParity(o3.newSession()); } @Test @DisplayName("Delete entire whitespace run between two tokens") void delete_whitespace_entirely() { - var s = parser().initialize("let x = 1; let y = 2;").edit(10, 1, ""); + var s = editFromInit("let x = 1; let y = 2;", 10, 1, ""); assertParity(s); assertThat(s.text()).isEqualTo("let x = 1;let y = 2;"); } @@ -170,23 +170,22 @@ final class InsertedTrivia { @Test @DisplayName("Insert trivia between two tokens that previously had no whitespace") void insert_trivia_between_adjacent_tokens() { - // No whitespace between '1' and ';' originally — inserting a space - // there does NOT lie inside an existing trivia run, so the - // fast-path declines and structural reparse runs. Parity must hold. - var s = parser().initialize("let x=1;").edit(7, 0, " "); + var s = editFromInit("let x=1;", 7, 0, " "); assertParity(s); } @Test @DisplayName("Repeated trivia inserts sustain parity at every step") void repeated_trivia_inserts() { - var session = parser().initialize("let x = 1; let y = 2;"); - session = session.edit(5, 0, " "); - assertParity(session); - session = session.edit(7, 0, " "); - assertParity(session); - session = session.edit(11, 0, "\n"); - assertParity(session); + var init = parser().initialize("let x = 1; let y = 2;"); + var session = init.session(); + var cursor = init.cursor(); + var o1 = session.edit(cursor, 5, 0, " "); + assertParity(o1.newSession()); + var o2 = o1.newSession().edit(o1.newCursor(), 7, 0, " "); + assertParity(o2.newSession()); + var o3 = o2.newSession().edit(o2.newCursor(), 11, 0, "\n"); + assertParity(o3.newSession()); } } } diff --git a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/examples/BasicEditorLoopExample.java b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/examples/BasicEditorLoopExample.java index f37a65e..765cb3d 100644 --- a/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/examples/BasicEditorLoopExample.java +++ b/peglib-incremental/src/test/java/org/pragmatica/peg/incremental/examples/BasicEditorLoopExample.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.pragmatica.peg.grammar.Grammar; import org.pragmatica.peg.grammar.GrammarParser; +import org.pragmatica.peg.incremental.Cursor; import org.pragmatica.peg.incremental.Edit; import org.pragmatica.peg.incremental.IncrementalParser; import org.pragmatica.peg.incremental.Session; @@ -15,6 +16,10 @@ * {@link IncrementalParser}: initialize a session over the initial buffer, * apply an {@link Edit} per user keystroke / batch, read the new tree and * stats off the returned {@link Session}. + * + *

    0.5.0 (Lever D): cursor state is split out of the Session into the + * standalone {@link Cursor} record, and {@code edit(...)} returns a paired + * {@code (Session, Cursor)} {@link org.pragmatica.peg.incremental.EditOutcome}. */ final class BasicEditorLoopExample { @@ -33,24 +38,30 @@ void editor_loop() { cause -> { throw new IllegalStateException(cause.message()); }, g -> g); IncrementalParser parser = IncrementalParser.create(grammar); - Session session = parser.initialize("let x = 1;", 0); + var init = parser.initialize("let x = 1;", 0); + Session session = init.session(); + Cursor cursor = init.cursor(); assertThat(session.root()).isNotNull(); assertThat(session.text()).isEqualTo("let x = 1;"); // User types ' 42' after the '1' (offset 9). - session = session.edit(new Edit(9, 0, "42")); + var o1 = session.edit(cursor, new Edit(9, 0, "42")); + session = o1.newSession(); + cursor = o1.newCursor(); assertThat(session.text()).isEqualTo("let x = 142;"); assertThat(session.stats().reparseCount()).isEqualTo(1); // User pastes a whole new statement at the start. - session = session.edit(0, 0, "let y = 7; "); + var o2 = session.edit(cursor, 0, 0, "let y = 7; "); + session = o2.newSession(); + cursor = o2.newCursor(); assertThat(session.text()).isEqualTo("let y = 7; let x = 142;"); assertThat(session.stats().reparseCount()).isEqualTo(2); - // User moves cursor — no reparse. - var before = session; - session = session.moveCursor(5); - assertThat(session.cursor()).isEqualTo(5); - assertThat(session.stats().reparseCount()).isEqualTo(before.stats().reparseCount()); + // User moves cursor — no reparse, no Session allocation. + var beforeReparseCount = session.stats().reparseCount(); + cursor = cursor.moveTo(5, session.index()); + assertThat(cursor.offset()).isEqualTo(5); + assertThat(session.stats().reparseCount()).isEqualTo(beforeReparseCount); } } From 1e524e24c6e7c034e7ccad84752bbe5e86c4c098 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 20:34:28 +0200 Subject: [PATCH 20/46] =?UTF-8?q?docs:=20post-Lever-D=20status=20=E2=80=94?= =?UTF-8?q?=20bench+JBCT=20cleanup,=20Lever=20B=20blocked,=20Lever=20C=20n?= =?UTF-8?q?ext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 +++++++++++++++- docs/HANDOVER.md | 84 ++++++++++++++++++++++++++++++------------------ 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3eb007..a4b7f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,32 @@ Median + p95 + frame-budget hit rate clearly improved. p99/max regressed for lar See [`docs/incremental/PHASE-1-RESULTS.md`](docs/incremental/PHASE-1-RESULTS.md) for full sub-phase summary and bench caveats. -Phase 2 (Lever B top-down pivot search) is the next-session entry point. +### Phase 2 attempted, rolled back; bench + JBCT cleanup; Lever D Cursor split (2026-05-07) + +**Lever B (top-down pivot)** — investigated and deferred. Two empirical iterations both fail `IncrementalTriviaParityTest`: +- "Strict literal-prefix safe-pivot" (per spec §3): correctness-sound but cost 4× perf — only ~30/133 Java25 rules admitted, forcing walk-up to Block/RecordBody pivots. +- "Boundary-touch walk-up": pivot's internal child boundaries still produce trivia divergence. + +Root cause: trivia attribution is context-sensitive — Lever B retry blocked on trivia attribution refactor. `SafePivotAnalyzer` + `NodeIndex.smallestEnclosing` preserved as dormant infrastructure. Phase 2 wiring rolled back; Phase 1.7 algorithm restored. + +**Bench harness** — `IncrementalSessionBench` now validates each edit post-application; if the resulting buffer is syntactically invalid, the bench rolls back the session and skips the edit. Eliminates the 746-exception buffer-corruption tail that polluted prior bench logs. 41% faster wallclock. + +**JBCT cleanup** — `SessionFactory.parseFull` now returns `Result` instead of throwing `IllegalStateException`. New `SessionError` sealed Cause type. Public `Session.edit` always returns a Session; parse failures surface via new `Session.parseSuccessful()` instead of exceptional control flow. Bench's dead try/catch removed. Aligns with project JBCT mandate (Result for failure-return, no exceptions in business logic). + +**Sandbox cleanup** — Phase 0/1 prove-out code (the `experimental/` package + 5 sandbox JMH benches) removed. Production has equivalents (`IdGenerator` in `peglib-core/tree`, `LongLongMap` in `peglib-incremental/internal`). -5463 LOC across 31 files. Git history preserves the journey. + +**Lever D — Cursor/Session split** (per spec §5). Cursor state (offset + enclosingNodeId) extracted from `IncrementalSession` record into a new public `Cursor` record. New `EditOutcome(Session, Cursor)` and `ReparseOutcome(Session, Cursor)` records returned by `edit` / `reparseAll`. `Cursor.moveTo(int newOffset, NodeIndex index)` is pure — no Session allocation. `InitialSession(Session, Cursor)` returned by `IncrementalParser.initialize`. Cursor uses Phase 1's stable `long enclosingNodeId` so it survives session churn. + +Bench post-Lever-D vs Phase 1.7 baseline (Regime B): + +| Metric | Phase 1.7 | Post-Lever-D | +|---|---:|---:| +| Median | 5.5 ms | **5.0 ms** (-9%) | +| p95 | 14.6 ms | **11.2 ms** (-23%) | +| p99 | 192.7 ms | **90.5 ms** (-53%) | +| % under 16 ms | 95.4% | **96.5%** (+1.1 pp) | + +Lever C (peglib-rt IR unification per spec §4) is the next-session entry point. ## [0.4.3] - 2026-05-06 diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 8ddb382..fafa3e5 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -270,50 +270,70 @@ Regen via: ## 11. Recommended next session -**Phase 1 production migration of Path D landed on 2026-05-07.** Median 1.9× faster than 0.4.3, p95 35% better, frame-budget hit rate +4.4pp. p99/max regressed on large-pivot edits — accepted trade vs Path A's much-larger-scope refactor. Read `docs/incremental/PHASE-1-RESULTS.md` for the full verdict + bench numbers + caveats before starting Phase 2. +**Phase 1 production migration of Path D + Lever D Cursor split landed on 2026-05-07.** Bench harness fixed; parseFull migrated to Result; sandbox cleaned up; Lever B investigated and deferred. -State of `release-0.5.0` branch (15 commits past `1619604` chore — local only, not pushed): +Headline numbers vs 0.4.3 baseline (`IncrementalSessionBench`, 1900-LOC fixture, Regime B cursor-moved-to-edit): -**Phase 0 — spike (sandbox):** -- `d00eaa1` 0a — `IdGenerator` + `LongLongMap` foundation -- `f0696a1` 0b — sandbox `IdCstNode` + `IdCstNodeBuilder` -- `849b4ba` 0c — sandbox `IdNodeIndex` with O(splicedSize+depth) incremental update -- `9b55253` 0d.1 — `IdTreeSplicer` + identity-invariant + trivia-edit gates -- `a2dd8ac` 0d.2 — JMH `Phase0SpikeBench` + results note (38-67× balanced) -- `a8c6efe` 0e — GO verdict doc + CHANGELOG entry +| Metric | 0.4.3 | 0.5.0 (current branch) | Change | +|---|---:|---:|---:| +| Median | 10.8 ms | **5.0 ms** | **-54% (2.2× faster)** | +| p95 | 22.4 ms | **11.2 ms** | **-50%** | +| p99 | 53.3 ms | 90.5 ms | +70% (large-pivot tail, deferred) | +| % under 16 ms | 91.5% | **96.5%** | **+5 pp** | +| Exceptions in bench log | n/a | **0** | clean signal | + +State of `release-0.5.0` branch (20 commits past `1619604` chore — local only, **not pushed**): + +**Phase 0 — spike (sandbox; later deleted in cleanup):** +- `d00eaa1` 0a / `f0696a1` 0b / `849b4ba` 0c / `9b55253` 0d.1 / `a2dd8ac` 0d.2 / `a8c6efe` 0e GO verdict **Phase 1 — prove-out + production migration:** -- `8f844eb` Path A prove-out — SpanIndex / offset decoupling (RED, 1.10-1.29×) -- `8b27dd6` Path D prove-out — stable-id ancestor preservation (GREEN, 96-604×) -- `4043ddc` docs — phase 1 prove-out summary -- `2443779` 1.2 — production CstNode gains long id (BREAKING) -- `39e11f9` 1.5/1.6 — NodeIndex LongLongMap + Path D applyIncremental + tombstone fix -- `65a719f` 1.7 — refresh nodesById after shiftAll + bench exception diagnostics +- `8f844eb` Path A prove-out (SpanIndex / offset decoupling — RED, 1.10-1.29×) +- `8b27dd6` Path D prove-out (stable-id ancestor preservation — GREEN, 96-604×) +- `4043ddc` docs prove-out summary +- `2443779` 1.2 production CstNode gains long id (BREAKING) +- `39e11f9` 1.5/1.6 NodeIndex LongLongMap + Path D applyIncremental + tombstone fix +- `65a719f` 1.7 refresh nodesById after shiftAll +- `43baaf8` Phase 1 results doc + +**Phase 2 attempted, rolled back:** +- `e038e4f` Lever B "strict literal-prefix" cost 4× perf — wiring rolled back, SafePivotAnalyzer + smallestEnclosing kept dormant + +**Bench + JBCT cleanup:** +- `0ea98af` bench post-edit validation (0 exceptions, 41% faster) +- `4ad5824` parseFull → Result + Session.parseSuccessful() (no more thrown exceptions on parse failure) + +**Sandbox + Lever D:** +- `5275d86` sandbox cleanup (-5463 LOC, 31 files removed) +- `4f06046` Lever D Cursor extracted from Session record (p99 -53%, frame budget +1.1pp) -Tests: 993 green (699 core + 196 incremental + 66 formatter + 5 maven-plugin + 27 playground). IncrementalParityTest 22×100 green throughout. 0 skipped, 0 failures, 0 errors. +Tests: 922 green (699 core + 125 incremental + 66 formatter + 5 maven-plugin + 27 playground). IncrementalParityTest + IncrementalTriviaParityTest green throughout. -### Phase 2 — Lever B top-down pivot search (next session) +### Lever B status — blocked on trivia attribution -Per spec §3 — should dissolve the §6.2 lever-1 puzzle into a 30-line method now that Phase 1's stable IDs make descent-from-root cheap (O(depth × branching) via id-keyed `nodesById` lookups instead of O(N) tree walks). +Two empirical iterations (Phase 2 strict literal-prefix; Lever B v2 boundary-touch walk-up) both fail `IncrementalTriviaParityTest`. Root cause: trivia attribution is **context-sensitive** — when an edit lands at a trivia/non-trivia seam, in-isolation reparse attaches trivia differently than full reparse. Walking up doesn't fix internal seams. -Two concrete motivators from Phase 1.7's bench data (per `PHASE-1-RESULTS.md` §"Bench caveats"): +Lever B retry must wait for **trivia attribution rework** — likely comparable scope to Lever C. SafePivotAnalyzer + NodeIndex.smallestEnclosing live as dormant infrastructure. -1. **Cursor-aware regime asymmetry, 57 edits.** Regime A (cursor-pinned) reports 622 successful edits; Regime B (cursor-moved-to-edit) reports 565. The 57-edit gap likely reflects cases where the warm-pointer pivot search picks a slightly different (less safe) pivot in Regime B. Lever B's top-down descent + safe-pivot heuristic should dissolve this. +The 57-edit cursor-asymmetry (Regime A 622 vs Regime B 571 applied) survived Lever D — confirms it's a pivot-selection issue, not a Session-storage issue. Will dissolve when Lever B retries. -2. **Large-pivot p99 tail.** The p99/max regression (138 ms / 391 ms vs 0.4.3's 53 ms / 99 ms) localizes to edits where the boundary algorithm chose a 2k-7k-node interior pivot. `TreeSplicer.shiftAll` then deep-copies all right-of-edit descendants. Lever B's smarter pivot selection should pick smaller pivots more often, reducing the tail. +### Recommended next moves (in priority order) -Phase 2 deliverables (per spec §6 Phase 2, ~3 days): -- `IncrementalSession.findBoundaryCandidate` replaced with top-down descent from root using `NodeIndex.nodesById` (O(depth × branching)) -- Safe-pivot detection added to grammar analysis (rules whose `parseRuleAt` provably matches full reparse — rules starting with unambiguous terminals like Block `{`, Stmt mixed delim) -- `BackReferenceFallbackTest.edit_triggers_full_reparse` — currently passes via warm-pointer cursor-spine accident; verify it still passes via the descent + safe-pivot exclusion -- IncrementalParityTest stays green at 22×100 -- Re-run IncrementalSessionBench; gate on Regime B success count reaching parity with Regime A AND p99 falling below 100 ms +1. **Push the branch + tag 0.5.0-alpha** — bookmark this work as a public marker. The branch is shippable: median 2.2× faster than 0.4.3, frame budget 96.5%, 0 exceptions, clean API. -### Bench harness improvement (high priority alongside Phase 2) +2. **Lever C — IR-based interpreter/generator unification** (spec §4, ~1-2 weeks). Two extraction options: + - **α full**: new `peglib-rt` module, both PegEngine + ParserGenerator migrate. Generated parsers gain a peglib-rt dependency (50KB jar). + - **β phased**: `ParseRuntime` extracted inside peglib-core first, PegEngine refactored to delegate (~3-4 days, low risk). Generator migration as second pass. -`IncrementalSessionBench` produces edit sequences that occasionally corrupt buffer syntax over time. 398 of 435 Regime B exceptions in Phase 1.7 are the same syntax error from a recurring corruption (per `PHASE-1-RESULTS.md` §"Bench caveats" item 1). Phase 2 should: -- Gate edit application on parse validity before committing the edit to the bench's cumulative buffer -- Or, treat fall-through-to-fallback as a soft outcome (record but don't count as skipped) + Eliminates the "every fix paid twice" pattern (interpreter + emission templates duplicate algorithm). Reduces 7,440 LOC across PegEngine + ParserGenerator to ~1,700. + +3. **Trivia attribution rework** — context-independent attachment (always-left or always-right). Unblocks Lever B retry. Comparable scope to Lever C. Could be combined. + +4. **Lever B retry** — after trivia rework. The literal-prefix gate may not be needed; lighter check suffices. + +5. **Phase 5 release** — migration guide, CHANGELOG cleanup, 0.5.0 tag. + +### Items now superseded ### Items now superseded @@ -325,7 +345,7 @@ Do NOT pursue further allocation reduction in the 0.4.x interpreter — see prio --- -**Last updated:** 2026-05-07, after Phase 1 production migration landed. Path D's stable-id-ancestor architecture is in place and validated against the 0.4.3 baseline. Branch is local-only at 15 commits past chore; recommend pushing as a public Phase 1 marker before Phase 2 work begins. +**Last updated:** 2026-05-07, after Phase 1 production migration + Lever D Cursor split + bench/JBCT cleanup. Lever B blocked on trivia attribution rework. Branch is local-only at 20 commits past chore (last `4f06046`); recommend pushing as 0.5.0-alpha marker before Lever C work begins. ### Items now superseded From 4ea7da4128299a37f020fae46ac0f52c0b840cf4 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 21:54:22 +0200 Subject: [PATCH 21/46] =?UTF-8?q?docs:=20G0=20=E2=80=94=20generator=20thro?= =?UTF-8?q?ughput=20profiling=20baseline=20(150=20MB/op,=2026.6%=20in=20GC?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generator-profile-baseline.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/bench-results/generator-profile-baseline.md diff --git a/docs/bench-results/generator-profile-baseline.md b/docs/bench-results/generator-profile-baseline.md new file mode 100644 index 0000000..f0307dd --- /dev/null +++ b/docs/bench-results/generator-profile-baseline.md @@ -0,0 +1,135 @@ +# Generator (Throughput Engine) — Profiling Baseline + +**Date:** 2026-05-07 +**Branch:** release-0.5.0 (post-Lever-D, commit `1e524e2`) +**Bench:** `Java25ParseBenchmark.parse`, variant `phase1_allStructural` (current best gen-time config) +**Fixture:** `large/FactoryClassGenerator.java.txt` (1900 LOC, 101178 chars) +**JVM:** OpenJDK 25.0.2 (Apple Silicon, Homebrew) +**Tools:** JMH 1.37 + async-profiler 4.4 (`/opt/homebrew/lib/libasyncProfiler.dylib`) + +## Headline numbers + +| Metric | Value | +|---|---:| +| Mean parse time (avgt, 5 iter / 1 fork) | **76.2 ms** ± 20.2 (CI 99.9%) | +| Throughput | 13 ops/sec | +| **Allocation rate** | **1,884 MB/sec** | +| **Allocated per parse (norm)** | **150 MB/op** | +| GC count over bench | 164 across ~10 seconds → ~16/sec | +| GC time as fraction of bench | **26.6%** | + +**Allocation is the dominant lever.** 150 MB allocated to parse a 1900-LOC file = ~80 KB per source line = ~800 bytes per character. 26.6% of bench time spent in GC. Algorithmic / micro-optimization wins are a second-order concern compared to reducing allocation pressure. + +## Allocation profile (top sites by sample count, async-profiler `-e alloc`) + +| Samples | Class | +|---:|---| +| **3,190** | `pragmatica.lang.Option$Some` — Option-boxing (every `Option.option(x)` call) | +| 3,008 | `Object[]` — generic backing arrays (ArrayList, varargs, etc.) | +| **2,574** | `CstParseResult` — per-parse-attempt result record (allocated even when backtracked) | +| **2,367** | `SourceLocation` — position records (line, column, offset) | +| 2,191 | `byte[]` — string backing | +| 1,096 | `SourceSpan` — span records (lazily reconstructed from int triples since 0.4.3) | +| 1,027 | `Long` — boxing | +| 893 | `ArrayList` — children lists, scratch buffers | +| 868 | `String` — various | +| 622 | `HashMap.Node` — packrat cache entries | +| 587 | `CstNode.NonTerminal` — RETAINED CST nodes | +| 553 | `CstNode.Terminal` — RETAINED CST nodes | +| 174 | `HashMap.Node[]` — packrat cache backing | +| 111 | `Trivia.Whitespace` — trivia records | +| 75 | `CstNode.Token` — RETAINED CST nodes | + +**Reading this:** +- Top 4 allocators (Option$Some, Object[], CstParseResult, SourceLocation) account for >50% of allocation samples. None of these are user-visible CST output. +- Retained CST = 1,215 records (NonTerminal + Terminal + Token). Allocation profile suggests roughly 3-5× more CST-shaped intermediate records are allocated and discarded during speculative parsing. +- Packrat cache (HashMap.Node + HashMap.Node[]) = 796 samples — meaningful but not dominant. + +## CPU profile (top leaf frames, async-profiler `-e cpu`, application frames only) + +JIT compilation activity dominates raw samples (the parser is large and the JIT works hard during the 3-iter warmup). After filtering JVM/JIT internals: + +| Samples | Frame | +|---:|---| +| 41 | `java.util.HashMap.resize` — packrat cache resize during fill | +| 36 | `java.lang.String.indexOf` — terminator scanning (already an intrinsic) | +| 27 | `java.util.Arrays.copyOf` — backing array growth | +| 25 | `generated.parse_Stmt` — the most-sampled rule body | +| 11 | `generated.parse_Class.skipWhitespace` | +| 11 | `CstParseResult.success` — result construction | +| 11 | `StringUTF16.compress` — string construction inside the parser | +| 10 | `CstParseResult.` — result-record construction | +| 9 | `Option.some` — Option boxing | +| 9 | `HashMap.putVal` — packrat cache writes | + +The CPU picture is consistent with the allocation picture: hot code = result-record construction + Option boxing + packrat HashMap operations. Pure parsing CPU (parse_Stmt, skipWhitespace) is only a small fraction of the sampled time. + +## Architectural moves, ranked by data-driven payoff + +### Tier 1 — design-level, addresses dominant allocation + +**A. Eliminate `Option` boxing on the parse hot path** (~25-30% allocation reduction) +- Top allocator (3,190 Option$Some samples). Every per-attempt success/failure currently wraps in Option. +- Replace with sentinel-based primitive return: parse method returns `int` (matched-end-offset) or `-1` (no-match), with mutable thread-local scratch state for the actual result data. +- Compatible with CST output — only changes how parse-result-flow is plumbed internally. + +**B. Mutable parse state replacing per-attempt `CstParseResult` records** (~20-25% allocation reduction) +- 2nd-largest allocator (2,574 CstParseResult samples). Every parse attempt — successful or backtracked — allocates one of these. +- Generator emits a parse method that returns primitive end-offset; on success, mutates a thread-local state struct (current rule's children, trivia accumulator, etc.). On backtrack, the state is rolled back via a checkpoint mechanism. +- This is essentially the "unsafe-generator" idea from HANDOVER §6.4 — but resurrected with a clear single-purpose justification (throughput) and the data to back it. + +**C. Lazy CST construction** (~15-20% allocation reduction; CST output identical) +- Currently CstNode records are allocated as we parse; speculative-branch nodes get GC'd on backtrack. +- Change: parser emits a compact **trace** during parsing (16-byte records: rule-id, start, end, child-count). CST materialization is a post-parse pass over the committed trace. +- Backtracking cost shifts from "allocate-and-discard CstNode" to "rewind trace position pointer" — much cheaper. +- Trace-walking pass also opens a clean home for trivia attribution rework (currently embedded in PegEngine + emission templates). + +**D. Pack SourceLocation/SourceSpan into primitives** (~15-20% allocation reduction) +- 3,463 combined samples (SourceLocation + SourceSpan). Every `node.span().start()` accessor currently allocates a fresh SourceLocation. +- Store spans as `long[]` aligned by node-id (or as packed-long fields on CstNode records), eliminating the lazy reconstruction path. +- Already partially started in 0.4.3 (SourceSpan as int triples) — finish the job by removing SourceLocation as a record entirely; expose primitive-returning accessors. + +### Tier 2 — algorithmic, packrat-focused + +**E. Replace HashMap packrat with `long[][]` indexed by `(ruleId, pos)`** (~5-10% CPU + reduced HashMap allocation) +- 41 + 9 CPU samples on HashMap operations; 622 + 174 allocation samples on HashMap nodes/buckets. +- Direct array access; no hashing, no resize. Combined with selective memoization (only memoize rules with profile-confirmed re-entry rate > 1). +- Pre-emit packrat array sizes from grammar analysis: `(numRules × textLen)` long entries. + +### Tier 3 — algorithmic, dispatch-focused + +**F. First-set Choice dispatch** (10-30% CPU on Java-shaped grammars) +- Existing `choiceDispatch` flag in ParserConfig — investigate what it does already; may be a partial implementation. +- Static FIRST-set analysis at gen-time. For `a / b / c`, emit `switch (text.charAt(pos))` to skip alternatives whose FIRST excludes the current char. + +**G. DFA-based lexer for token rules** (2-3× on lex-heavy paths) +- Detect token rules at gen-time; compile to DFA; emit DFA scanner alongside parser. +- Bigger architectural lift (~2-3 weeks); evaluate after Tier 1 lands. + +## Recommended sequence + +The data points clearly to **allocation-reduction first**, not algorithmic micro-opts. Suggested order: + +1. **Investigate the existing `choiceDispatch` flag** — quick read of ParserConfig + ParserGenerator emission. If it's already first-set dispatch, F is partially done; if not, that's a near-term tactical win. +2. **Tier 1 combined**: A + B + C + D land as a coordinated pass. Together they target the top 4 allocators (Option$Some, CstParseResult, SourceLocation, Object[]). Goal: reduce 150 MB/op to <30 MB/op (5× reduction). +3. **Re-profile** after Tier 1 to confirm gains and identify next bottleneck. +4. **Tier 2 (E)** if profile shows packrat still dominant. +5. **Tier 3 (F, G)** based on remaining hot paths. + +## Caveats + +- **JIT warmup**: 3 warmup iterations may be insufficient; CPU profile shows JIT compilation activity dominating sample count. Production runs (not bench) will see fewer compile samples and proportionally more parse samples. +- **150 MB/op figure includes all allocations** — JIT compilation, JMH harness, GC structures. Application allocation rate is somewhat lower; the relative breakdown remains directionally accurate. +- **Sample-based profiling**: async-profiler samples; not exhaustive accounting. Top-N rankings are reliable; absolute byte counts in the table are sample counts, not bytes. +- **`phase1_allStructural` is already the best of 7 gen-time variants.** The "none" baseline (all gen-time flags off) would show worse numbers. Worth running for a comparison if we want to verify Phase 1 flags' contribution. + +## Files + +- Flame graphs (HTML, viewable in browser): + - `/tmp/peglib-profile/.../flame-cpu-{forward,reverse}.html` + - `/tmp/peglib-profile/.../flame-alloc-{forward,reverse}.html` +- Collapsed text profiles for analysis: + - `/tmp/peglib-profile-collapsed/.../collapsed-alloc.csv` (~67 MB) + - `/tmp/peglib-profile-cpu/.../collapsed-cpu.csv` + +These artifacts are **not committed** — `/tmp` only. Re-run via the commands at the top of this doc. From 0ed2dcd702d2c63507c45e750fb5a7d5839f6f11 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 22:18:33 +0200 Subject: [PATCH 22/46] =?UTF-8?q?exp:=20A=20spike=20=E2=80=94=20opt-in=20m?= =?UTF-8?q?utableParseResult=20flag=20(Option=20-90.5%,=20bytes/op=20-16%,?= =?UTF-8?q?=20GO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peg/bench/Java25ParseBenchmark.java | 17 +- .../peg/bench/PackratStatsProbe.java | 3 +- .../peg/generator/ParserGenerator.java | 343 +++++++++++++----- .../pragmatica/peg/parser/ParserConfig.java | 8 +- .../peg/grammar/LeftRecursionTest.java | 3 +- .../pragmatica/peg/perf/Phase1ParityTest.java | 3 +- .../perf/Phase2AllStructuralParityTest.java | 3 +- ...2ChoiceDispatchAndMarkResetParityTest.java | 3 +- .../perf/Phase2ChoiceDispatchParityTest.java | 3 +- .../perf/Phase2InlineLocationsParityTest.java | 3 +- .../Phase2MarkResetChildrenParityTest.java | 3 +- ...se2SelectivePackratEmptySetParityTest.java | 3 +- .../Phase2SelectivePackratParityTest.java | 3 +- 13 files changed, 298 insertions(+), 100 deletions(-) diff --git a/peglib-core/src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java b/peglib-core/src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java index d997aca..cf795f4 100644 --- a/peglib-core/src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java +++ b/peglib-core/src/jmh/java/org/pragmatica/peg/bench/Java25ParseBenchmark.java @@ -61,6 +61,7 @@ public class Java25ParseBenchmark { "phase1_inlineLocations", "phase1_allStructural", "phase1_allStructural_skipPackrat", + "phase1_allStructural_mutableResult", "interpreter" }) public String variant; @@ -115,6 +116,7 @@ private static ParserConfig configFor(String variant) { case "phase1_allStructural" -> withStructural(true, true, true, false, Set.of()); case "phase1_allStructural_skipPackrat" -> withStructural(true, true, true, true, Set.of("Identifier", "QualifiedName", "Type")); + case "phase1_allStructural_mutableResult" -> withStructural(true, true, true, false, Set.of(), true); default -> throw new IllegalArgumentException("Unknown variant: " + variant); }; } @@ -135,7 +137,8 @@ private static ParserConfig allOff() { false, // markResetChildren false, // inlineLocations false, // selectivePackrat - Set.of()); // packratSkipRules + Set.of(), // packratSkipRules + false); // mutableParseResult } /** @@ -147,6 +150,15 @@ private static ParserConfig withStructural(boolean choiceDispatch, boolean inlineLocations, boolean selectivePackrat, Set packratSkipRules) { + return withStructural(choiceDispatch, markResetChildren, inlineLocations, selectivePackrat, packratSkipRules, false); + } + + private static ParserConfig withStructural(boolean choiceDispatch, + boolean markResetChildren, + boolean inlineLocations, + boolean selectivePackrat, + Set packratSkipRules, + boolean mutableParseResult) { return new ParserConfig( true, // packratEnabled RecoveryStrategy.BASIC, @@ -161,7 +173,8 @@ private static ParserConfig withStructural(boolean choiceDispatch, markResetChildren, inlineLocations, selectivePackrat, - Set.copyOf(packratSkipRules)); + Set.copyOf(packratSkipRules), + mutableParseResult); } static String loadResource(String resourcePath) throws Exception { diff --git a/peglib-core/src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java b/peglib-core/src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java index 8bf7f98..6a66569 100644 --- a/peglib-core/src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java +++ b/peglib-core/src/jmh/java/org/pragmatica/peg/bench/PackratStatsProbe.java @@ -60,7 +60,8 @@ public static void main(String[] args) throws Exception { true, true, true, true, true, true, // phase-1 flags on false, false, false, // structural flags off false, // selectivePackrat - Set.of()); + Set.of(), // packratSkipRules + false); // mutableParseResult var sourceResult = PegParser.generateCstParser( grammarText, PACKAGE_NAME, CLASS_NAME, ErrorReporting.BASIC, config); diff --git a/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java b/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java index 494c20f..61efc35 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java @@ -138,6 +138,37 @@ private static void validateConfig(Grammar grammar, ParserConfig config) { } } + // === Spike A: emission helpers for raw-nullable vs Option CstParseResult === + private String nodeUnwrap(String varName) { + return config.mutableParseResult() + ? varName + ".node" + : varName + ".node.unwrap()"; + } + + private String nodeIsPresent(String varName) { + return config.mutableParseResult() + ? varName + ".node != null" + : varName + ".node.isPresent()"; + } + + private String textOrEmpty(String varName) { + return config.mutableParseResult() + ? "(" + varName + ".text != null ? " + varName + ".text : \"\")" + : varName + ".text.or(\"\")"; + } + + private String endLocationUnwrap(String varName) { + return config.mutableParseResult() + ? varName + ".endLocation" + : varName + ".endLocation.unwrap()"; + } + + private String expectedUnwrap(String varName) { + return config.mutableParseResult() + ? varName + ".expected" + : varName + ".expected.unwrap()"; + } + public static ParserGenerator parserGenerator(Grammar grammar, String packageName, String className) { return new ParserGenerator(grammar, packageName, className, ErrorReporting.BASIC, ParserConfig.DEFAULT); } @@ -2392,6 +2423,12 @@ private void generateCstParseMethods(StringBuilder sb) { .getFirst() .name(); var sanitizedName = sanitize(startRuleName); + var resultExpectedExpr = config.mutableParseResult() + ? "(result.expected != null ? Option.some(result.expected) : Option.none())" + : "result.expected"; + var resultNodeUnwrapExpr = config.mutableParseResult() + ? "result.node" + : "result.node.unwrap()"; sb.append(""" // === Public Parse Methods === @@ -2400,7 +2437,7 @@ public Result parse(String input) { var result = parse_%s(); if (result.isFailure()) { var errorLoc = furthestFailure.or(location()); - var expected = furthestExpected.filter(s -> !s.isEmpty()).or(result.expected.or("valid input")); + var expected = furthestExpected.filter(s -> !s.isEmpty()).or(%s.or("valid input")); return Result.failure(new ParseError(errorLoc, "expected " + expected)); } var trailingTrivia = skipWhitespace(); // Capture trailing trivia @@ -2409,7 +2446,7 @@ public Result parse(String input) { return Result.failure(new ParseError(errorLoc, "unexpected input")); } // Attach trailing trivia to root node - var rootNode = attachTrailingTrivia(result.node.unwrap(), trailingTrivia); + var rootNode = attachTrailingTrivia(%s, trailingTrivia); return Result.success(rootNode); } @@ -2433,7 +2470,7 @@ private AstNode toAst(CstNode cst) { }; } - """.formatted(sanitizedName)); + """.formatted(sanitizedName, resultExpectedExpr, resultNodeUnwrapExpr)); if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" /** @@ -2449,7 +2486,7 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) { // Record the failure and attempt recovery var errorLoc = furthestFailure.or(location()); var errorSpan = SourceSpan.sourceSpan(errorLoc, errorLoc); - var expected = furthestExpected.filter(s -> !s.isEmpty()).or(result.expected.or("valid input")); + var expected = furthestExpected.filter(s -> !s.isEmpty()).or(%s.or("valid input")); addDiagnostic("expected " + expected, errorSpan); // Skip to recovery point and try to continue @@ -2471,18 +2508,18 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) { addDiagnostic("unexpected input", errorSpan, "expected end of input"); // Attach error node to result - var rootNode = attachTrailingTrivia(result.node.unwrap(), trailingTrivia); + var rootNode = attachTrailingTrivia(%s, trailingTrivia); return ParseResultWithDiagnostics.withErrors(Option.some(rootNode), diagnostics, input); } - var rootNode = attachTrailingTrivia(result.node.unwrap(), trailingTrivia); + var rootNode = attachTrailingTrivia(%s, trailingTrivia); if (diagnostics.isEmpty()) { return ParseResultWithDiagnostics.success(rootNode, input); } return ParseResultWithDiagnostics.withErrors(Option.some(rootNode), diagnostics, input); } - """.formatted(sanitizedName)); + """.formatted(sanitizedName, resultExpectedExpr, resultNodeUnwrapExpr, resultNodeUnwrapExpr)); } generateCstParseRuleAt(sb); } @@ -2496,7 +2533,14 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) { * rule's generated {@code parse_()} method. */ private void generateCstParseRuleAt(StringBuilder sb) { - sb.append(""" + var resultExpectedExpr = config.mutableParseResult() + ? "(result.expected != null ? Option.some(result.expected) : Option.none())" + : "result.expected"; + var resultNodeUnwrapExpr = config.mutableParseResult() + ? "result.node" + : "result.node.unwrap()"; + var ruleAtBlock = new StringBuilder(); + ruleAtBlock.append(""" // === PartialParse (0.3.0) === /** @@ -2518,13 +2562,13 @@ Supplier> buildRuleDispatch() { for (var rule : grammar.rules()) { var ruleClassName = toClassName(rule.name()); var methodName = "parse_" + sanitize(rule.name()); - sb.append(" m.put(RuleId.") - .append(ruleClassName) - .append(".class, this::") - .append(methodName) - .append(");\n"); + ruleAtBlock.append(" m.put(RuleId.") + .append(ruleClassName) + .append(".class, this::") + .append(methodName) + .append(");\n"); } - sb.append(""" + ruleAtBlock.append(""" return Map.copyOf(m); } @@ -2560,10 +2604,10 @@ public Result parseRuleAt( var result = supplier.get(); if (result.isFailure()) { var errorLoc = furthestFailure.or(location()); - var expected = furthestExpected.filter(s -> !s.isEmpty()).or(result.expected.or("valid input")); + var expected = furthestExpected.filter(s -> !s.isEmpty()).or(__RESULT_EXPECTED__.or("valid input")); return Result.failure(new ParseError(errorLoc, "expected " + expected)); } - return Result.success(new PartialParse(result.node.unwrap(), pos)); + return Result.success(new PartialParse(__RESULT_NODE__, pos)); } /** @@ -2587,6 +2631,9 @@ private void seekTo(int offset) { } """); + sb.append(ruleAtBlock.toString() + .replace("__RESULT_EXPECTED__", resultExpectedExpr) + .replace("__RESULT_NODE__", resultNodeUnwrapExpr)); } private void generateCstRuleMethods(StringBuilder sb) { @@ -2651,10 +2698,16 @@ private void generateCstRuleMethod(StringBuilder sb, Rule rule, int ruleId) { sb.append(" var hitCarriedLeading = takePendingLeading();\n"); sb.append(" var hitLocalLeading = (tokenBoundaryDepth > 0) ? List.of() : skipWhitespace();\n"); sb.append(" var hitLeading = concatTrivia(hitCarriedLeading, hitLocalLeading);\n"); - sb.append(" var hitEndLoc = cached.endLocation.unwrap();\n"); + sb.append(" var hitEndLoc = ") + .append(endLocationUnwrap("cached")) + .append(";\n"); sb.append(" restoreLocation(hitEndLoc);\n"); - sb.append(" var hitNode = attachLeadingTrivia(cached.node.unwrap(), hitLeading);\n"); - sb.append(" return CstParseResult.success(hitNode, cached.text.or(\"\"), hitEndLoc);\n"); + sb.append(" var hitNode = attachLeadingTrivia(") + .append(nodeUnwrap("cached")) + .append(", hitLeading);\n"); + sb.append(" return CstParseResult.success(hitNode, ") + .append(textOrEmpty("cached")) + .append(", hitEndLoc);\n"); sb.append(" }\n"); sb.append(" return cached;\n"); sb.append(" }\n"); @@ -2731,8 +2784,12 @@ private void generateCstRuleMethod(StringBuilder sb, Rule rule, int ruleId) { sb.append(" cacheNode = attachTrailingToTail(cacheNode, pendingAtExit);\n"); sb.append(" node = attachTrailingToTail(node, pendingAtExit);\n"); sb.append(" }\n"); - sb.append(" cacheableResult = CstParseResult.success(cacheNode, result.text.or(\"\"), endLoc);\n"); - sb.append(" finalResult = CstParseResult.success(node, result.text.or(\"\"), endLoc);\n"); + sb.append(" cacheableResult = CstParseResult.success(cacheNode, ") + .append(textOrEmpty("result")) + .append(", endLoc);\n"); + sb.append(" finalResult = CstParseResult.success(node, ") + .append(textOrEmpty("result")) + .append(", endLoc);\n"); sb.append(" } else {\n"); if (inlineLocations) { // Inline field restore — no SourceLocation allocation on failure path. @@ -2819,10 +2876,16 @@ private void emitCstLeftRecursiveWrapper(StringBuilder sb, Rule rule, int ruleId sb.append(" var hitCarriedLeading = takePendingLeading();\n"); sb.append(" var hitLocalLeading = (tokenBoundaryDepth > 0) ? List.of() : skipWhitespace();\n"); sb.append(" var hitLeading = concatTrivia(hitCarriedLeading, hitLocalLeading);\n"); - sb.append(" var hitEndLoc = cached.endLocation.unwrap();\n"); + sb.append(" var hitEndLoc = ") + .append(endLocationUnwrap("cached")) + .append(";\n"); sb.append(" restoreLocation(hitEndLoc);\n"); - sb.append(" var hitNode = attachLeadingTrivia(cached.node.unwrap(), hitLeading);\n"); - sb.append(" return CstParseResult.success(hitNode, cached.text.or(\"\"), hitEndLoc);\n"); + sb.append(" var hitNode = attachLeadingTrivia(") + .append(nodeUnwrap("cached")) + .append(", hitLeading);\n"); + sb.append(" return CstParseResult.success(hitNode, ") + .append(textOrEmpty("cached")) + .append(", hitEndLoc);\n"); sb.append(" }\n"); sb.append(" return cached;\n"); sb.append(" }\n"); @@ -2833,7 +2896,9 @@ private void emitCstLeftRecursiveWrapper(StringBuilder sb, Rule rule, int ruleId // duplicate leading-trivia per growth iteration. sb.append(" var activeSeed = growingSeeds.get(key);\n"); sb.append(" if (activeSeed != null) {\n"); - sb.append(" if (activeSeed.isSuccess()) restoreLocation(activeSeed.endLocation.unwrap());\n"); + sb.append(" if (activeSeed.isSuccess()) restoreLocation(") + .append(endLocationUnwrap("activeSeed")) + .append(");\n"); sb.append(" return activeSeed;\n"); sb.append(" }\n"); // Capture leading whitespace & carried pending at the outer level. @@ -2866,8 +2931,12 @@ private void emitCstLeftRecursiveWrapper(StringBuilder sb, Rule rule, int ruleId .append("();\n"); sb.append(" if (iter.isCutFailure()) { cutFired = true; break; }\n"); sb.append(" if (iter.isFailure()) break;\n"); - sb.append(" int newEnd = iter.endLocation.unwrap().offset();\n"); - sb.append(" int prevEnd = lastSeed.isSuccess() ? lastSeed.endLocation.unwrap().offset() : bodyStartPos;\n"); + sb.append(" int newEnd = ") + .append(endLocationUnwrap("iter")) + .append(".offset();\n"); + sb.append(" int prevEnd = lastSeed.isSuccess() ? ") + .append(endLocationUnwrap("lastSeed")) + .append(".offset() : bodyStartPos;\n"); sb.append(" if (newEnd <= prevEnd) break;\n"); sb.append(" lastSeed = iter;\n"); sb.append(" growingSeeds.put(key, lastSeed);\n"); @@ -2891,9 +2960,17 @@ private void emitCstLeftRecursiveWrapper(StringBuilder sb, Rule rule, int ruleId // 0.3.6: clear pending override on success path. sb.append(" clearPendingRecoveryOverride();\n"); } - sb.append(" restoreLocation(lastSeed.endLocation.unwrap());\n"); - sb.append(" var node = attachLeadingTrivia(lastSeed.node.unwrap(), leadingTrivia);\n"); - sb.append(" finalResult = CstParseResult.success(node, lastSeed.text.or(\"\"), lastSeed.endLocation.unwrap());\n"); + sb.append(" restoreLocation(") + .append(endLocationUnwrap("lastSeed")) + .append(");\n"); + sb.append(" var node = attachLeadingTrivia(") + .append(nodeUnwrap("lastSeed")) + .append(", leadingTrivia);\n"); + sb.append(" finalResult = CstParseResult.success(node, ") + .append(textOrEmpty("lastSeed")) + .append(", ") + .append(endLocationUnwrap("lastSeed")) + .append(");\n"); // Bug C fix: cache the empty-leading lastSeed (body emission attaches no // leading trivia). The wrapped node above is for return only. Bug C' is // intentionally NOT applied at the LR settle path: PegEngine's LR path @@ -2971,7 +3048,9 @@ private void generateCstRuleBodyMethod(StringBuilder sb, Rule rule, int ruleId) sb.append(" var node = wrapWithRuleName(result, children, span, ") .append(ruleIdConst) .append(", List.of());\n"); - sb.append(" return CstParseResult.success(node, result.text.or(\"\"), endLoc);\n"); + sb.append(" return CstParseResult.success(node, ") + .append(textOrEmpty("result")) + .append(", endLoc);\n"); sb.append(" }\n"); sb.append(" return result;\n"); sb.append(" }\n\n"); @@ -3122,11 +3201,15 @@ private void generateCstExpressionCode(StringBuilder sb, .append(resultVar) .append(".isSuccess() && ") .append(resultVar) - .append(".node.isPresent()) {\n"); + .append(config.mutableParseResult() + ? ".node != null) {\n" + : ".node.isPresent()) {\n"); sb.append(pad) .append(" children.add(") .append(resultVar) - .append(".node.unwrap());\n"); + .append(config.mutableParseResult() + ? ".node);\n" + : ".node.unwrap());\n"); sb.append(pad) .append("}\n"); } @@ -3148,11 +3231,15 @@ private void generateCstExpressionCode(StringBuilder sb, .append(resultVar) .append(".isSuccess() && ") .append(resultVar) - .append(".node.isPresent()) {\n"); + .append(config.mutableParseResult() + ? ".node != null) {\n" + : ".node.isPresent()) {\n"); sb.append(pad) .append(" children.add(") .append(resultVar) - .append(".node.unwrap());\n"); + .append(config.mutableParseResult() + ? ".node);\n" + : ".node.unwrap());\n"); sb.append(pad) .append("}\n"); } @@ -3178,11 +3265,15 @@ private void generateCstExpressionCode(StringBuilder sb, .append(resultVar) .append(".isSuccess() && ") .append(resultVar) - .append(".node.isPresent()) {\n"); + .append(config.mutableParseResult() + ? ".node != null) {\n" + : ".node.isPresent()) {\n"); sb.append(pad) .append(" children.add(") .append(resultVar) - .append(".node.unwrap());\n"); + .append(config.mutableParseResult() + ? ".node);\n" + : ".node.unwrap());\n"); sb.append(pad) .append("}\n"); } @@ -3198,11 +3289,15 @@ private void generateCstExpressionCode(StringBuilder sb, .append(resultVar) .append(".isSuccess() && ") .append(resultVar) - .append(".node.isPresent()) {\n"); + .append(config.mutableParseResult() + ? ".node != null) {\n" + : ".node.isPresent()) {\n"); sb.append(pad) .append(" children.add(") .append(resultVar) - .append(".node.unwrap());\n"); + .append(config.mutableParseResult() + ? ".node);\n" + : ".node.unwrap());\n"); sb.append(pad) .append("}\n"); } @@ -3220,11 +3315,15 @@ private void generateCstExpressionCode(StringBuilder sb, .append(resultVar) .append(".isSuccess() && ") .append(resultVar) - .append(".node.isPresent()) {\n"); + .append(config.mutableParseResult() + ? ".node != null) {\n" + : ".node.isPresent()) {\n"); sb.append(pad) .append(" children.add(") .append(resultVar) - .append(".node.unwrap());\n"); + .append(config.mutableParseResult() + ? ".node);\n" + : ".node.unwrap());\n"); sb.append(pad) .append("}\n"); } @@ -4334,11 +4433,15 @@ private void generateCstExpressionCode(StringBuilder sb, .append(resultVar) .append(".isSuccess() && ") .append(resultVar) - .append(".node.isPresent()) {\n"); + .append(config.mutableParseResult() + ? ".node != null) {\n" + : ".node.isPresent()) {\n"); sb.append(pad) .append(" children.add(") .append(resultVar) - .append(".node.unwrap());\n"); + .append(config.mutableParseResult() + ? ".node);\n" + : ".node.unwrap());\n"); sb.append(pad) .append("}\n"); } @@ -4632,13 +4735,20 @@ private List skipWhitespace() { private CstNode wrapWithRuleName(CstParseResult result, List children, SourceSpan span, RuleId ruleId, List leadingTrivia) { // If result produced a single node (Token or Terminal), re-wrap with rule name and trivia // This matches PegEngine.wrapWithRuleName behavior - if (result.node.isPresent()) { - var inner = result.node.unwrap(); + if (__NODE_PRESENT__) { + var inner = __NODE_UNWRAP__; return switch (inner) { case CstNode.Token tok -> new CstNode.Token(tok.id(), span, ruleId, tok.text(), leadingTrivia, List.of()); case CstNode.Terminal t -> new CstNode.Terminal(t.id(), span, ruleId, t.text(), leadingTrivia, List.of()); case CstNode.NonTerminal nt -> new CstNode.NonTerminal(nt.id(), span, ruleId, nt.children(), leadingTrivia, List.of()); - """); + """.replace("__NODE_PRESENT__", + config.mutableParseResult() + ? "result.node != null" + : "result.node.isPresent()") + .replace("__NODE_UNWRAP__", + config.mutableParseResult() + ? "result.node" + : "result.node.unwrap()")); // Add Error case only for ADVANCED mode if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" @@ -4810,50 +4920,103 @@ private CstNode attachTrailingToTail(CstNode node, List trivia) { sb.append(""" // === CST Parse Result === """); - sb.append(""" + if (config.mutableParseResult()) { + // Spike A: raw nullable fields — no Option boxing on the parse hot path. + // Field names match the Option-typed variant ('node', 'text', 'expected', + // 'endLocation') so emission sites can be uniformly rewritten with raw + // access patterns. CstParseResult instances are still freshly allocated + // per call (cache safety), but the per-call Option$Some allocations are + // gone — that's the spike's primary target. + sb.append(""" - private static final class CstParseResult { - final boolean success; - final Option node; - final Option text; - final Option expected; - final Option endLocation; - final boolean cutFailed; + private static final class CstParseResult { + final boolean success; + final CstNode node; // raw nullable + final String text; // raw nullable + final String expected; // raw nullable + final SourceLocation endLocation; // raw nullable + final boolean cutFailed; + + private CstParseResult(boolean success, CstNode node, String text, String expected, SourceLocation endLocation, boolean cutFailed) { + this.success = success; + this.node = node; + this.text = text; + this.expected = expected; + this.endLocation = endLocation; + this.cutFailed = cutFailed; + } - private CstParseResult(boolean success, Option node, Option text, Option expected, Option endLocation, boolean cutFailed) { - this.success = success; - this.node = node; - this.text = text; - this.expected = expected; - this.endLocation = endLocation; - this.cutFailed = cutFailed; - } + boolean isSuccess() { return success; } + boolean isFailure() { return !success; } + boolean isCutFailure() { return !success && cutFailed; } - boolean isSuccess() { return success; } - boolean isFailure() { return !success; } - boolean isCutFailure() { return !success && cutFailed; } + static CstParseResult success(CstNode node, String text, SourceLocation endLocation) { + return new CstParseResult(true, node, text, null, endLocation, false); + } - static CstParseResult success(CstNode node, String text, SourceLocation endLocation) { - return new CstParseResult(true, Option.option(node), Option.some(text), Option.none(), Option.some(endLocation), false); - } + static CstParseResult failure(String expected) { + return new CstParseResult(false, null, null, expected, null, false); + } - static CstParseResult failure(String expected) { - return new CstParseResult(false, Option.none(), Option.none(), Option.some(expected), Option.none(), false); - } + static CstParseResult cutFailure(String expected) { + return new CstParseResult(false, null, null, expected, null, true); + } - static CstParseResult cutFailure(String expected) { - return new CstParseResult(false, Option.none(), Option.none(), Option.some(expected), Option.none(), true); - } + CstParseResult asCutFailure() { + return cutFailed ? this : new CstParseResult(false, null, null, expected, null, true); + } - CstParseResult asCutFailure() { - return cutFailed ? this : new CstParseResult(false, Option.none(), Option.none(), expected, Option.none(), true); + CstParseResult asRegularFailure() { + return cutFailed ? new CstParseResult(false, null, null, expected, null, false) : this; + } } + """); + }else { + sb.append(""" + + private static final class CstParseResult { + final boolean success; + final Option node; + final Option text; + final Option expected; + final Option endLocation; + final boolean cutFailed; + + private CstParseResult(boolean success, Option node, Option text, Option expected, Option endLocation, boolean cutFailed) { + this.success = success; + this.node = node; + this.text = text; + this.expected = expected; + this.endLocation = endLocation; + this.cutFailed = cutFailed; + } + + boolean isSuccess() { return success; } + boolean isFailure() { return !success; } + boolean isCutFailure() { return !success && cutFailed; } + + static CstParseResult success(CstNode node, String text, SourceLocation endLocation) { + return new CstParseResult(true, Option.option(node), Option.some(text), Option.none(), Option.some(endLocation), false); + } - CstParseResult asRegularFailure() { - return cutFailed ? new CstParseResult(false, Option.none(), Option.none(), expected, Option.none(), false) : this; + static CstParseResult failure(String expected) { + return new CstParseResult(false, Option.none(), Option.none(), Option.some(expected), Option.none(), false); + } + + static CstParseResult cutFailure(String expected) { + return new CstParseResult(false, Option.none(), Option.none(), Option.some(expected), Option.none(), true); + } + + CstParseResult asCutFailure() { + return cutFailed ? this : new CstParseResult(false, Option.none(), Option.none(), expected, Option.none(), true); + } + + CstParseResult asRegularFailure() { + return cutFailed ? new CstParseResult(false, Option.none(), Option.none(), expected, Option.none(), false) : this; + } } - } - """); + """); + } } // === Match-method emitters (phase 1 perf flags) === @@ -4870,7 +5033,9 @@ private void emitMatchLiteralCst(StringBuilder sb) { sb.append(" int len = text.length();\n"); sb.append(" if (input.length() - pos < len) {\n"); sb.append(" var f = literalFailure(text);\n"); - sb.append(" trackFailure(f.expected.unwrap());\n"); + sb.append(" trackFailure(") + .append(expectedUnwrap("f")) + .append(");\n"); sb.append(" return f;\n"); sb.append(" }\n"); sb.append(" var startLoc = location();\n"); @@ -4878,7 +5043,9 @@ private void emitMatchLiteralCst(StringBuilder sb) { sb.append(" for (int i = 0; i < len; i++) {\n"); sb.append(" if (Character.toLowerCase(text.charAt(i)) != Character.toLowerCase(input.charAt(pos + i))) {\n"); sb.append(" var f = literalFailure(text);\n"); - sb.append(" trackFailure(f.expected.unwrap());\n"); + sb.append(" trackFailure(") + .append(expectedUnwrap("f")) + .append(");\n"); sb.append(" return f;\n"); sb.append(" }\n"); sb.append(" }\n"); @@ -4886,7 +5053,9 @@ private void emitMatchLiteralCst(StringBuilder sb) { sb.append(" for (int i = 0; i < len; i++) {\n"); sb.append(" if (text.charAt(i) != input.charAt(pos + i)) {\n"); sb.append(" var f = literalFailure(text);\n"); - sb.append(" trackFailure(f.expected.unwrap());\n"); + sb.append(" trackFailure(") + .append(expectedUnwrap("f")) + .append(");\n"); sb.append(" return f;\n"); sb.append(" }\n"); sb.append(" }\n"); @@ -5006,7 +5175,9 @@ private void emitMatchCharClassCst(StringBuilder sb) { if (config.charClassFailureCache()) { sb.append(" if (isAtEnd()) {\n"); sb.append(" var f = charClassFailure(pattern, negated);\n"); - sb.append(" trackFailure(f.expected.unwrap());\n"); + sb.append(" trackFailure(") + .append(expectedUnwrap("f")) + .append(");\n"); sb.append(" return f;\n"); sb.append(" }\n"); sb.append(" var startLoc = location();\n"); @@ -5015,7 +5186,9 @@ private void emitMatchCharClassCst(StringBuilder sb) { sb.append(" if (negated) matches = !matches;\n"); sb.append(" if (!matches) {\n"); sb.append(" var f = charClassFailure(pattern, negated);\n"); - sb.append(" trackFailure(f.expected.unwrap());\n"); + sb.append(" trackFailure(") + .append(expectedUnwrap("f")) + .append(");\n"); sb.append(" return f;\n"); sb.append(" }\n"); }else { diff --git a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParserConfig.java b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParserConfig.java index 7d0ee1d..0d064db 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/parser/ParserConfig.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/parser/ParserConfig.java @@ -63,9 +63,10 @@ public record ParserConfig( boolean markResetChildren, boolean inlineLocations, boolean selectivePackrat, - Set packratSkipRules) { + Set packratSkipRules, + boolean mutableParseResult) { public static final ParserConfig DEFAULT = new ParserConfig( - true, RecoveryStrategy.BASIC, true, true, true, true, true, true, true, true, false, false, false, Set.of()); + true, RecoveryStrategy.BASIC, true, true, true, true, true, true, true, true, false, false, false, Set.of(), false); /** * Convenience factory for the three-field runtime configuration. Phase 1 @@ -90,6 +91,7 @@ public static ParserConfig parserConfig(boolean packratEnabled, false, false, false, - Set.of()); + Set.of(), + false); } } diff --git a/peglib-core/src/test/java/org/pragmatica/peg/grammar/LeftRecursionTest.java b/peglib-core/src/test/java/org/pragmatica/peg/grammar/LeftRecursionTest.java index 00e2921..a4a624e 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/grammar/LeftRecursionTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/grammar/LeftRecursionTest.java @@ -225,7 +225,8 @@ void lrRuleInPackratSkipRulesIsConfigurationError() { /* markResetChildren */ false, /* inlineLocations */ false, /* selectivePackrat */ true, - /* packratSkipRules */ Set.of("Expr")); + /* packratSkipRules */ Set.of("Expr"), + /* mutableParseResult */ false); var result = PegParser.fromGrammar(""" Expr <- Expr '+' Term / Term diff --git a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase1ParityTest.java b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase1ParityTest.java index f7252bd..3a20425 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase1ParityTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase1ParityTest.java @@ -43,7 +43,8 @@ class Phase1ParityTest { /* markResetChildren */ false, /* inlineLocations */ false, /* selectivePackrat */ false, - /* packratSkipRules */ Set.of() + /* packratSkipRules */ Set.of(), + /* mutableParseResult */ false ); static Stream corpusFiles() throws IOException { diff --git a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2AllStructuralParityTest.java b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2AllStructuralParityTest.java index 29a4cc2..8eb67ec 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2AllStructuralParityTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2AllStructuralParityTest.java @@ -49,7 +49,8 @@ class Phase2AllStructuralParityTest { /* markResetChildren */ true, /* inlineLocations */ true, /* selectivePackrat */ false, - /* packratSkipRules */ Set.of() + /* packratSkipRules */ Set.of(), + /* mutableParseResult */ false ); static Stream corpusFiles() throws IOException { diff --git a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchAndMarkResetParityTest.java b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchAndMarkResetParityTest.java index 96d5ba8..e7b6090 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchAndMarkResetParityTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchAndMarkResetParityTest.java @@ -45,7 +45,8 @@ class Phase2ChoiceDispatchAndMarkResetParityTest { /* markResetChildren */ true, /* inlineLocations */ false, /* selectivePackrat */ false, - /* packratSkipRules */ Set.of() + /* packratSkipRules */ Set.of(), + /* mutableParseResult */ false ); static Stream corpusFiles() throws IOException { diff --git a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchParityTest.java b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchParityTest.java index c4f4a44..52cf0d6 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchParityTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2ChoiceDispatchParityTest.java @@ -43,7 +43,8 @@ class Phase2ChoiceDispatchParityTest { /* markResetChildren */ false, /* inlineLocations */ false, /* selectivePackrat */ false, - /* packratSkipRules */ Set.of() + /* packratSkipRules */ Set.of(), + /* mutableParseResult */ false ); static Stream corpusFiles() throws IOException { diff --git a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2InlineLocationsParityTest.java b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2InlineLocationsParityTest.java index c1fe44a..7f44426 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2InlineLocationsParityTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2InlineLocationsParityTest.java @@ -46,7 +46,8 @@ class Phase2InlineLocationsParityTest { /* markResetChildren */ false, /* inlineLocations */ true, /* selectivePackrat */ false, - /* packratSkipRules */ Set.of() + /* packratSkipRules */ Set.of(), + /* mutableParseResult */ false ); static Stream corpusFiles() throws IOException { diff --git a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2MarkResetChildrenParityTest.java b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2MarkResetChildrenParityTest.java index 425b016..2c7875a 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2MarkResetChildrenParityTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2MarkResetChildrenParityTest.java @@ -46,7 +46,8 @@ class Phase2MarkResetChildrenParityTest { /* markResetChildren */ true, /* inlineLocations */ false, /* selectivePackrat */ false, - /* packratSkipRules */ Set.of() + /* packratSkipRules */ Set.of(), + /* mutableParseResult */ false ); static Stream corpusFiles() throws IOException { diff --git a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratEmptySetParityTest.java b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratEmptySetParityTest.java index ff8fc51..7680577 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratEmptySetParityTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratEmptySetParityTest.java @@ -49,7 +49,8 @@ class Phase2SelectivePackratEmptySetParityTest { /* markResetChildren */ false, /* inlineLocations */ false, /* selectivePackrat */ true, - /* packratSkipRules */ Set.of() + /* packratSkipRules */ Set.of(), + /* mutableParseResult */ false ); static Stream corpusFiles() throws IOException { diff --git a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratParityTest.java b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratParityTest.java index 679d280..b06c249 100644 --- a/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratParityTest.java +++ b/peglib-core/src/test/java/org/pragmatica/peg/perf/Phase2SelectivePackratParityTest.java @@ -52,7 +52,8 @@ class Phase2SelectivePackratParityTest { /* markResetChildren */ false, /* inlineLocations */ false, /* selectivePackrat */ true, - /* packratSkipRules */ Set.of("Identifier", "QualifiedName", "Type") + /* packratSkipRules */ Set.of("Identifier", "QualifiedName", "Type"), + /* mutableParseResult */ false ); static Stream corpusFiles() throws IOException { From 43ac902ba4886077dd48ca1867bb9608325597c9 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 22:20:17 +0200 Subject: [PATCH 23/46] =?UTF-8?q?docs:=20throughput=20engine=20Tier=201=20?= =?UTF-8?q?spec=20=E2=80=94=20A=20proven,=20B/C/D=20sequence=20with=20meas?= =?UTF-8?q?urement=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/incremental/THROUGHPUT-ENGINE-TIER1.md | 236 ++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/incremental/THROUGHPUT-ENGINE-TIER1.md diff --git a/docs/incremental/THROUGHPUT-ENGINE-TIER1.md b/docs/incremental/THROUGHPUT-ENGINE-TIER1.md new file mode 100644 index 0000000..4235fa2 --- /dev/null +++ b/docs/incremental/THROUGHPUT-ENGINE-TIER1.md @@ -0,0 +1,236 @@ +# Throughput Engine — Tier 1 allocation reduction spec + +**Date:** 2026-05-07 +**Branch:** release-0.5.0 (post A-spike GO at commit `0ed2dcd`) +**Profile baseline:** [`docs/bench-results/generator-profile-baseline.md`](../bench-results/generator-profile-baseline.md) +**Naming:** the parser generator is now branded the **throughput engine** — distinct from the **incremental engine** (PegEngine + IncrementalSession). They have different optimization targets, different code shapes, and no shared code. + +--- + +## 1. Motivation + +The throughput engine's profiling baseline shows it allocates **150 MB per parse** of 1900 LOC and spends **26.6% of bench wallclock in GC**. Top allocators are internal plumbing, not user-visible CST: + +| Samples | Class | Tier 1 move addressing it | +|---:|---|---| +| 3,190 | `Option$Some` | **A** (mutable ParseResult — Option boxing eliminated) | +| 3,008 | `Object[]` | C, D (fewer ArrayLists; primitive packing) | +| 2,574 | `CstParseResult` | **B** (mutable scratch — fewer record allocs) | +| 2,367 | `SourceLocation` | **D** (pack into primitive long fields) | +| 1,096 | `SourceSpan` | **D** | +| 587 | `CstNode.NonTerminal` (retained) | **C** (lazy materialization) — 3-5× more allocated than retained | +| 553 | `CstNode.Terminal` (retained) | **C** | + +The use case constraint: **CST output is mandatory** (formatter + linter both consume the lossless tree). Pure-AST mode is off the table. Wins must come from changing *how* the CST is constructed, not whether. + +The A spike already proved the approach is sound: 90.5% reduction in Option$Some samples + 16% reduction in bytes/op, with ZERO algorithmic change. Tier 1 stacks four coordinated moves to compound the win. + +--- + +## 2. Tier 1 moves + +### A — Sentinel-based ParseResult flow (PROVEN) + +**Status:** spike landed at commit `0ed2dcd`. Opt-in via `mutableParseResult` ParserConfig flag. + +**What changed:** the generator's emitted `ParseResult` class became a mutable record-like with raw nullable fields (no `Option value` / `Option expected` wrappers). Emission templates use `result.value != null` instead of `result.value.isPresent()`, etc. + +**Result (A alone vs phase1_allStructural baseline):** +- Option$Some allocations: 6,088 → 577 (**-90.5%**) +- Bytes/op: 150.4M → 126.3M (**-16%**) +- Wallclock: 81.97 → 75.01 ms/op (**-8.5%**, marginal due to CI variance) + +**Open work in A:** the spike covered hot-path emission templates only. Capture, BackReference, Cut, And, Not still emit Option-style code. Coverage extension is a small follow-up — apply the same patterns. Estimated 2-3 hours. + +### B — Mutable parse-state replacing per-attempt CstParseResult records + +**Target allocation:** 2,574 CstParseResult samples per parse. Each successful or backtracked parse-method call currently allocates a fresh CstParseResult record holding (success, value, expected, endPos, endLine, endColumn, cutFailed). + +**Proposal:** replace per-call allocation with a SINGLE shared mutable instance per parser invocation. Every parse method mutates this instance in place; callers read the fields immediately (no aliasing across calls). + +```java +// Before +CstParseResult result = parseRule_X(pos, line, column); +if (result.success) { ... use result.endPos, result.value ... } + +// After (B applied; the parser-instance field `state` is the singleton) +boolean ok = parseRule_X(pos, line, column); // returns boolean (or int end-pos) +if (ok) { ... use state.endPos, state.value ... } +``` + +**Tradeoffs:** +- The singleton is non-aliasable: callers must consume the result fields BEFORE making any subsequent parse call. Emission templates already follow this pattern (parse + immediate read), so the constraint is already satisfied in practice. +- Packrat cache currently stores CstParseResult instances by value. Cache entries CANNOT use the shared mutable singleton — packrat needs immutable snapshots. Fix: when storing a cache entry, copy the singleton's fields into a small CacheEntry record (or directly into a primitive-packed long[]). +- Cut-failure short-circuits across rule calls work because the singleton's `cutFailed` field is checked immediately on return (existing pattern preserves). + +**Expected gain:** -2,574 CstParseResult allocations (down to ~packrat-cache-entry count, which is bounded by rule × position memoization rate). Together with A's reduction in result-record-related allocations, this should knock another ~10-15% off bytes/op. + +**Implementation effort:** ~3-4 days. Modifications to ParserGenerator emission templates: change `return CstParseResult.success(...)` to `state.setSuccess(...); return true;` patterns. Caller emission changes correspondingly. Packrat cache emission changes to copy fields into CacheEntry on put. + +**Gate:** -50% CstParseResult allocations OR -10% wallclock vs (A applied) baseline. + +### C — Lazy CST construction (parse → trace → materialize) + +**Target allocation:** 587 + 553 + 75 = 1,215 retained CstNode records, but profile suggests **3-5× more are allocated and discarded during speculative parsing**. The 1140 retained vs. allocated-and-discarded ratio is what makes this the second-largest design move under the CST-mandatory constraint. + +**Proposal:** decouple parse-time work from CST construction. + +``` +Parse phase (hot loop): + - Each rule that succeeds appends a TraceEntry to a pre-sized growing buffer. + - TraceEntry is a 16-byte primitive record: { ruleId: int, startPos: int, endPos: int, childTraceCount: int }. + - Children are pre-order: a node's TraceEntry is followed by its children's TraceEntries. + - Backtracking = rewind the trace's write-pointer; no allocation rollback needed. + +Materialization phase (post-parse): + - Walk the committed trace top-down; for each TraceEntry, allocate the corresponding CstNode. + - Trivia attribution happens during materialization, with full tree context — not during parse. +``` + +**Why this is high-leverage:** +- Backtracked branches no longer allocate CstNode records — they only advance the trace write pointer, which then gets rewound for free. +- TraceEntry is 16 bytes; the average CstNode (with its children list, span, trivia lists) is 80+ bytes. Even retained nodes pay less during the parse phase (just the TraceEntry). +- CST is materialized exactly once with full structural context — opens a clean home for **trivia attribution rework**, which Lever B is currently waiting on. The "context-sensitive trivia attribution" problem may dissolve here because materialization is single-pass with full tree knowledge. + +**Tradeoffs:** +- Memory profile shifts: the trace buffer needs to grow to ~10× the retained CST size (because of speculative entries). Pre-size based on grammar + input length; growth via amortized doubling. Worst case: a few MB of trace, vs. the current 150 MB allocation pressure. +- Trivia attribution semantics may need careful design. Currently embedded in PegEngine + emission templates; concentrating it into one materialization pass is cleaner BUT changes the timing — trivia decisions are now made AFTER all parsing is done, with full context. **This may be where Lever B's blocker dissolves.** +- More invasive than A or B: emission templates change substantially. ~1 week of focused work. + +**Gate:** -50% CST-related allocations (NonTerminal + Terminal + Token + ArrayList for children) OR -15% wallclock vs (A+B applied) baseline. + +**Bonus payoff:** trace-walking pass becomes a natural place to fix Lever B. Worth investigating during C's implementation. + +### D — Pack SourceLocation / SourceSpan into primitive long fields + +**Target allocation:** 2,367 SourceLocation + 1,096 SourceSpan = 3,463 combined samples per parse. Already partially packed in 0.4.3 (SourceSpan as int triples). Remaining work: eliminate SourceLocation as a record entirely. + +**Current state (per HANDOVER §5 v0.4.3 entry):** +```java +record SourceSpan(int startOffset, int startLine, int startColumn, + int endOffset, int endLine, int endColumn) { + public SourceLocation start() { return new SourceLocation(startLine, startColumn, startOffset); } // ALLOC + public SourceLocation end() { return new SourceLocation(endLine, endColumn, endOffset); } // ALLOC +} +``` + +Every `node.span().start()` accessor currently allocates a SourceLocation. The 2,367 samples are these on-demand reconstructions. + +**Proposal:** eliminate SourceLocation as a record. Replace with primitive accessors on SourceSpan: +```java +record SourceSpan(int startOffset, int startLine, int startColumn, + int endOffset, int endLine, int endColumn) { + public int startOffset() { return startOffset; } + public int startLine() { return startLine; } + public int startColumn() { return startColumn; } + public int endOffset() { return endOffset; } + public int endLine() { return endLine; } + public int endColumn() { return endColumn; } + // start() / end() removed +} +``` + +Callers of `span.start().offset()` / `span.start().line()` change to `span.startOffset()` / `span.startLine()`. Pure mechanical sweep. + +**Tradeoffs:** +- Public API change: SourceLocation may be referenced by user code (the maven-plugin or playground emits diagnostics). Likely small surface; maintain a `SourceLocation` record class but stop *constructing* it from SourceSpan accessors. +- Test sweep: many tests pattern-match on SourceLocation — those need updating. Estimated ~50-100 sites across 5 modules. + +**Implementation effort:** ~3-4 days. Mostly mechanical sweep + test updates. + +**Gate:** -90% SourceLocation allocations + -50% SourceSpan allocations OR -5% wallclock vs (A+B+C applied) baseline. + +--- + +## 3. Combined Tier 1 target + +| Metric | Baseline (current) | Tier 1 target | Reduction | +|---|---:|---:|---:| +| Bytes/op | 150 MB | ≤ 30 MB | **-80%** | +| Wallclock | 76.2 ms | ≤ 50 ms | **-34%** | +| GC time fraction | 26.6% | ≤ 10% | **-62%** | +| Option$Some allocs | 3,190 | < 500 | -85% (A confirmed) | +| CstParseResult allocs | 2,574 | < 200 | -92% (B target) | +| CST allocs (NonTerminal+Terminal+Token) | 1,215 retained, 3-5× discarded | 1,215 retained, 0 discarded | -75% (C target) | +| SourceLocation allocs | 2,367 | < 100 | -95% (D target) | + +**Aggregate target: 5× allocation reduction, 1.5× throughput improvement.** + +--- + +## 4. Implementation sequence + +Each move is GATED by re-profiling. If a move misses its gate, pause and reassess before continuing. + +1. **A coverage extension** (3 hours) — apply the spike's pattern to the remaining emission paths (Capture, BackReference, Cut, And, Not). Re-bench. Close the A gate at the wallclock margin. + +2. **B implementation + parity tests** (3-4 days) — mutable parse-state singleton; emission template rewrite; packrat cache CacheEntry copy. Bench gate: -50% CstParseResult allocs, -10% wallclock. + +3. **Re-profile after A+B** — fresh profile. Confirms Tier 1 is on track. Identifies which move (C or D) to prioritize next based on residual allocator weights. + +4. **C implementation** (1 week) — trace + materialization. **Investigate Lever B retry during C** — if trivia attribution moves into materialization, the literal-prefix gate may become unnecessary. Bench gate: -50% CST allocs, -15% wallclock. + +5. **D implementation + sweep** (3-4 days) — eliminate SourceLocation construction; mechanical caller sweep. Bench gate: -90% SourceLocation allocs. + +6. **Final Tier 1 bench + writeup** (1 day) — JFR + async-profiler runs against the final state; comparison table vs baseline; results doc at `docs/bench-results/throughput-tier1-results.md`. + +**Total elapsed:** 2-3 weeks. Each gate provides a stop-loss; if A+B alone clear the user's actual perf needs, C and D can be deferred. + +--- + +## 5. Risks + +### R1 — Mutable shared state in B introduces aliasing bugs (LOW likelihood, MEDIUM impact) + +The mutable singleton requires "consume immediately, then make next call" discipline. Existing emission already follows this pattern; the risk is in subtle paths where a result is held across calls. + +**Mitigation:** the parity tests (`Phase1ParityTest`, `Phase2*ParityTest`) compare CST output between flag-on and flag-off variants. If aliasing breaks something, parity diverges immediately. + +### R2 — C's trace-buffer architecture is invasive (MEDIUM likelihood, HIGH impact) + +Lazy CST is the largest emission-template change in Tier 1. Subtle bugs in trace materialization (missing children, wrong rule-id mapping, trivia mis-attribution) could break the CST output. + +**Mitigation:** parity tests + RoundTripTest. C's implementation gates on RoundTripTest stays green; if it breaks, fix before merging. + +### R3 — D's SourceLocation removal is a public API change (HIGH likelihood, LOW impact) + +Downstream consumers may pattern-match on SourceLocation records (maven-plugin diagnostics, playground UI rendering). + +**Mitigation:** keep `SourceLocation` as a record class; just stop *constructing* it from SourceSpan accessors. External code that explicitly creates SourceLocation continues to work. + +### R4 — The 5× allocation target is overshoot; may settle at 3-4× (MEDIUM likelihood, MEDIUM impact) + +The 80% reduction target assumes A+B+C+D compound multiplicatively. They likely overlap (same allocations targeted from different angles). Real combined reduction may be 60-70% (still excellent). + +**Mitigation:** measure each gate empirically; gates are sized for individual-move reduction, not the combined target. + +### R5 — Wallclock improvement gates may continue to be CI-variance-bound (MEDIUM likelihood, LOW impact) + +The A spike showed 8.5% wallclock improvement but with ±37 ms CI bands. JIT warmup and GC pauses dominate variance. + +**Mitigation:** when measuring Tier 1, use longer iteration counts (`-i 10 -wi 5 -f 2`) to tighten CI. The bytes/op metric is much less variable; treat it as the primary gate; wallclock as confirmation. + +--- + +## 6. Compatibility commitments + +- **Generator output remains a single self-contained Java file** — depending only on `pragmatica-lite:core`. Tier 1 doesn't introduce a runtime dependency. +- **CST output is byte-identical to current generator** — byte-equality verified by RoundTripTest 22/22. +- **Public Session API unchanged** — Tier 1 is generator-internal. +- **`mutableParseResult` flag** (and any new gates we add) — opt-in for users who want the new emission. Once Tier 1 is fully proven, can flip the default; old behavior preserved as a flag. + +--- + +## 7. Out of scope for Tier 1 + +Deferred items, ranked roughly by next-pass priority: + +- **Packrat as `long[][]` array** (was Tier 2 E in the profile doc). 622 + 174 HashMap allocations + 41 CPU samples. Worth ~5-10% additional after Tier 1. +- **First-set Choice dispatch** (was Tier 3 F). Existing `choiceDispatch` flag — investigate first; may be partial. ~10-30% on Java-shaped grammars. +- **DFA-based lexer for token rules** (was Tier 3 G). Biggest individual gain but biggest architectural lift. 2-3 weeks. Evaluate after Tier 1's residual profile. +- **Vectorized terminator scanning, ASCII whitespace fast path** — small tactical wins; defer. +- **Lever B retry** — gated on C's trivia-attribution rework. May dissolve the literal-prefix gate. + +--- + +**Last updated:** 2026-05-07 by the post-A-spike review. From fedc38950491c1a6b8d51b39182201d1be5b8363 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 22:34:05 +0200 Subject: [PATCH 24/46] =?UTF-8?q?feat:=20A=20coverage=20extension=20?= =?UTF-8?q?=E2=80=94=20Option=20at=200,=20wallclock=20-12.1%=20vs=20baseli?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peg/generator/ParserGenerator.java | 345 +++++++++++++----- 1 file changed, 250 insertions(+), 95 deletions(-) diff --git a/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java b/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java index 61efc35..6fc50b6 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/generator/ParserGenerator.java @@ -2123,8 +2123,23 @@ private void generateCstParseContext(StringBuilder sb) { private int tokenBoundaryDepth; private boolean skippingWhitespace; private boolean packratEnabled = true; - private Option furthestFailure; - private Option furthestExpected; + """); + if (config.mutableParseResult()) { + // A coverage extension: raw nullable for hot-path failure tracking. + // trackFailure() runs on every failed match (millions of times per + // parse on a large input); Option.some(loc)/Option.some(expected) + // dominated the residual Option$Some samples after the spike. + sb.append(""" + private SourceLocation furthestFailure; // raw nullable + private String furthestExpected; // raw nullable + """); + }else { + sb.append(""" + private Option furthestFailure; + private Option furthestExpected; + """); + } + sb.append(""" // Pending leading trivia — trivia captured between sibling // sequence elements that will attach to the following sibling's @@ -2152,39 +2167,75 @@ public void setPackratEnabled(boolean enabled) { // before popping. skipToRecoveryPoint consults the pending field // first (the stack is unwound by the time recovery runs), then // the live stack, then the global default char-set. - sb.append(""" - private final ArrayDeque recoveryOverrideStack = new ArrayDeque<>(); - private Option pendingFailureRecoveryOverride = Option.none(); + if (config.mutableParseResult()) { + sb.append(""" + private final ArrayDeque recoveryOverrideStack = new ArrayDeque<>(); + private String pendingFailureRecoveryOverride; // raw nullable - private void pushRecoveryOverride(String terminator) { - recoveryOverrideStack.push(terminator); - } + private void pushRecoveryOverride(String terminator) { + recoveryOverrideStack.push(terminator); + } + + private void popRecoveryOverride() { + if (!recoveryOverrideStack.isEmpty()) { + recoveryOverrideStack.pop(); + } + } - private void popRecoveryOverride() { - if (!recoveryOverrideStack.isEmpty()) { - recoveryOverrideStack.pop(); + private void recordFailureRecoveryOverride(String terminator) { + if (terminator != null && !terminator.isEmpty() && pendingFailureRecoveryOverride == null) { + pendingFailureRecoveryOverride = terminator; + } } - } - private void recordFailureRecoveryOverride(String terminator) { - if (terminator != null && !terminator.isEmpty() && pendingFailureRecoveryOverride.isEmpty()) { - pendingFailureRecoveryOverride = Option.some(terminator); + private void clearPendingRecoveryOverride() { + pendingFailureRecoveryOverride = null; } - } - private void clearPendingRecoveryOverride() { - pendingFailureRecoveryOverride = Option.none(); - } + private boolean matchesOverrideAt(String term) { + if (remaining() < term.length()) return false; + for (int i = 0; i < term.length(); i++) { + if (peek(i) != term.charAt(i)) return false; + } + return true; + } - private boolean matchesOverrideAt(String term) { - if (remaining() < term.length()) return false; - for (int i = 0; i < term.length(); i++) { - if (peek(i) != term.charAt(i)) return false; + """); + }else { + sb.append(""" + private final ArrayDeque recoveryOverrideStack = new ArrayDeque<>(); + private Option pendingFailureRecoveryOverride = Option.none(); + + private void pushRecoveryOverride(String terminator) { + recoveryOverrideStack.push(terminator); } - return true; - } - """); + private void popRecoveryOverride() { + if (!recoveryOverrideStack.isEmpty()) { + recoveryOverrideStack.pop(); + } + } + + private void recordFailureRecoveryOverride(String terminator) { + if (terminator != null && !terminator.isEmpty() && pendingFailureRecoveryOverride.isEmpty()) { + pendingFailureRecoveryOverride = Option.some(terminator); + } + } + + private void clearPendingRecoveryOverride() { + pendingFailureRecoveryOverride = Option.none(); + } + + private boolean matchesOverrideAt(String term) { + if (remaining() < term.length()) return false; + for (int i = 0; i < term.length(); i++) { + if (peek(i) != term.charAt(i)) return false; + } + return true; + } + + """); + } } if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" @@ -2212,17 +2263,36 @@ private void init(String input) { this.growingSeeds = new HashMap<>(); this.captures = new HashMap<>(); this.tokenBoundaryDepth = 0; - this.furthestFailure = Option.none(); - this.furthestExpected = Option.none(); + """); + if (config.mutableParseResult()) { + sb.append(""" + this.furthestFailure = null; + this.furthestExpected = null; + """); + }else { + sb.append(""" + this.furthestFailure = Option.none(); + this.furthestExpected = Option.none(); + """); + } + sb.append(""" this.pendingLeadingTrivia.clear(); this.idGen = new IdGenerator.PerSessionCounter(); """); if (errorReporting == ErrorReporting.ADVANCED) { - sb.append(""" - this.diagnostics = new ArrayList<>(); - this.recoveryOverrideStack.clear(); - this.pendingFailureRecoveryOverride = Option.none(); - """); + if (config.mutableParseResult()) { + sb.append(""" + this.diagnostics = new ArrayList<>(); + this.recoveryOverrideStack.clear(); + this.pendingFailureRecoveryOverride = null; + """); + }else { + sb.append(""" + this.diagnostics = new ArrayList<>(); + this.recoveryOverrideStack.clear(); + this.pendingFailureRecoveryOverride = Option.none(); + """); + } } sb.append(""" } @@ -2274,37 +2344,75 @@ private void restoreLocation(SourceLocation loc) { """); if (config.fastTrackFailure()) { - sb.append(""" - private void trackFailure(String expected) { - if (!furthestFailure.isEmpty()) { - int furthestOffset = furthestFailure.unwrap().offset(); - if (pos < furthestOffset) return; - if (pos == furthestOffset) { - String existing = furthestExpected.or(""); - if (existing.contains(expected)) return; - furthestExpected = Option.some( - existing.isEmpty() ? expected : existing + " or " + expected); - return; + if (config.mutableParseResult()) { + // A coverage extension: raw-nullable trackFailure — no Option boxing + // on the hot path. Equivalent semantics to the Option version. + sb.append(""" + private void trackFailure(String expected) { + if (furthestFailure != null) { + int furthestOffset = furthestFailure.offset(); + if (pos < furthestOffset) return; + if (pos == furthestOffset) { + String existing = furthestExpected != null ? furthestExpected : ""; + if (existing.contains(expected)) return; + furthestExpected = existing.isEmpty() ? expected : existing + " or " + expected; + return; + } } + furthestFailure = location(); + furthestExpected = expected; } - furthestFailure = Option.some(location()); - furthestExpected = Option.some(expected); - } - """); - }else { - sb.append(""" - private void trackFailure(String expected) { - var loc = location(); - if (furthestFailure.isEmpty() || loc.offset() > furthestFailure.unwrap().offset()) { - furthestFailure = Option.some(loc); + """); + }else { + sb.append(""" + private void trackFailure(String expected) { + if (!furthestFailure.isEmpty()) { + int furthestOffset = furthestFailure.unwrap().offset(); + if (pos < furthestOffset) return; + if (pos == furthestOffset) { + String existing = furthestExpected.or(""); + if (existing.contains(expected)) return; + furthestExpected = Option.some( + existing.isEmpty() ? expected : existing + " or " + expected); + return; + } + } + furthestFailure = Option.some(location()); furthestExpected = Option.some(expected); - } else if (loc.offset() == furthestFailure.unwrap().offset() && !furthestExpected.or("").contains(expected)) { - furthestExpected = Option.some(furthestExpected.or("").isEmpty() ? expected : furthestExpected.or("") + " or " + expected); } - } - """); + """); + } + }else { + if (config.mutableParseResult()) { + sb.append(""" + private void trackFailure(String expected) { + var loc = location(); + if (furthestFailure == null || loc.offset() > furthestFailure.offset()) { + furthestFailure = loc; + furthestExpected = expected; + } else if (loc.offset() == furthestFailure.offset() && !(furthestExpected != null ? furthestExpected : "").contains(expected)) { + String existing = furthestExpected != null ? furthestExpected : ""; + furthestExpected = existing.isEmpty() ? expected : existing + " or " + expected; + } + } + + """); + }else { + sb.append(""" + private void trackFailure(String expected) { + var loc = location(); + if (furthestFailure.isEmpty() || loc.offset() > furthestFailure.unwrap().offset()) { + furthestFailure = Option.some(loc); + furthestExpected = Option.some(expected); + } else if (loc.offset() == furthestFailure.unwrap().offset() && !furthestExpected.or("").contains(expected)) { + furthestExpected = Option.some(furthestExpected.or("").isEmpty() ? expected : furthestExpected.or("") + " or " + expected); + } + } + + """); + } } if (errorReporting == ErrorReporting.ADVANCED) { // 0.3.6: stack-aware recovery — per-rule %recover overrides are @@ -2314,33 +2422,63 @@ private void trackFailure(String expected) { // (the stack is unwound by the time recovery runs), then the // live stack, then the global default char-set. Mirrors // ParsingContext.skipToRecoveryPoint. - sb.append(""" - private SourceSpan skipToRecoveryPoint() { - var start = location(); - String override = null; - if (pendingFailureRecoveryOverride.isPresent()) { - override = pendingFailureRecoveryOverride.unwrap(); - pendingFailureRecoveryOverride = Option.none(); - } else if (!recoveryOverrideStack.isEmpty()) { - override = recoveryOverrideStack.peek(); - } - if (override != null && !override.isEmpty()) { - while (!isAtEnd() && !matchesOverrideAt(override)) { + if (config.mutableParseResult()) { + sb.append(""" + private SourceSpan skipToRecoveryPoint() { + var start = location(); + String override = null; + if (pendingFailureRecoveryOverride != null) { + override = pendingFailureRecoveryOverride; + pendingFailureRecoveryOverride = null; + } else if (!recoveryOverrideStack.isEmpty()) { + override = recoveryOverrideStack.peek(); + } + if (override != null && !override.isEmpty()) { + while (!isAtEnd() && !matchesOverrideAt(override)) { + advance(); + } + return SourceSpan.sourceSpan(start, location()); + } + while (!isAtEnd()) { + char c = peek(); + if (c == '\\n' || c == ';' || c == ',' || c == '}' || c == ')' || c == ']') { + break; + } advance(); } return SourceSpan.sourceSpan(start, location()); } - while (!isAtEnd()) { - char c = peek(); - if (c == '\\n' || c == ';' || c == ',' || c == '}' || c == ')' || c == ']') { - break; + + """); + }else { + sb.append(""" + private SourceSpan skipToRecoveryPoint() { + var start = location(); + String override = null; + if (pendingFailureRecoveryOverride.isPresent()) { + override = pendingFailureRecoveryOverride.unwrap(); + pendingFailureRecoveryOverride = Option.none(); + } else if (!recoveryOverrideStack.isEmpty()) { + override = recoveryOverrideStack.peek(); } - advance(); + if (override != null && !override.isEmpty()) { + while (!isAtEnd() && !matchesOverrideAt(override)) { + advance(); + } + return SourceSpan.sourceSpan(start, location()); + } + while (!isAtEnd()) { + char c = peek(); + if (c == '\\n' || c == ';' || c == ',' || c == '}' || c == ')' || c == ']') { + break; + } + advance(); + } + return SourceSpan.sourceSpan(start, location()); } - return SourceSpan.sourceSpan(start, location()); - } - """); + """); + } sb.append(""" private void addDiagnostic(String message, SourceSpan span) { diagnostics.add(Diagnostic.error(message, span).withTag("error.unexpected-input")); @@ -2423,12 +2561,20 @@ private void generateCstParseMethods(StringBuilder sb) { .getFirst() .name(); var sanitizedName = sanitize(startRuleName); - var resultExpectedExpr = config.mutableParseResult() - ? "(result.expected != null ? Option.some(result.expected) : Option.none())" - : "result.expected"; var resultNodeUnwrapExpr = config.mutableParseResult() ? "result.node" : "result.node.unwrap()"; + // Read-site exprs for furthestFailure / furthestExpected. Under + // mutableParseResult these are raw nullable; emit fallback expressions + // that mirror Option.or(...) semantics without re-introducing boxing. + // The legacy variant continues to use Option.or(...) calls. + var furthestFailureOrLoc = config.mutableParseResult() + ? "(furthestFailure != null ? furthestFailure : location())" + : "furthestFailure.or(location())"; + // expectedExpr matches `furthestExpected.filter(s -> !s.isEmpty()).or(result.expected.or("valid input"))`. + var expectedExpr = config.mutableParseResult() + ? "((furthestExpected != null && !furthestExpected.isEmpty()) ? furthestExpected : (result.expected != null ? result.expected : \"valid input\"))" + : "furthestExpected.filter(s -> !s.isEmpty()).or(result.expected.or(\"valid input\"))"; sb.append(""" // === Public Parse Methods === @@ -2436,13 +2582,13 @@ public Result parse(String input) { init(input); var result = parse_%s(); if (result.isFailure()) { - var errorLoc = furthestFailure.or(location()); - var expected = furthestExpected.filter(s -> !s.isEmpty()).or(%s.or("valid input")); + var errorLoc = %s; + var expected = %s; return Result.failure(new ParseError(errorLoc, "expected " + expected)); } var trailingTrivia = skipWhitespace(); // Capture trailing trivia if (!isAtEnd()) { - var errorLoc = furthestFailure.or(location()); + var errorLoc = %s; return Result.failure(new ParseError(errorLoc, "unexpected input")); } // Attach trailing trivia to root node @@ -2470,7 +2616,7 @@ private AstNode toAst(CstNode cst) { }; } - """.formatted(sanitizedName, resultExpectedExpr, resultNodeUnwrapExpr)); + """.formatted(sanitizedName, furthestFailureOrLoc, expectedExpr, furthestFailureOrLoc, resultNodeUnwrapExpr)); if (errorReporting == ErrorReporting.ADVANCED) { sb.append(""" /** @@ -2484,9 +2630,9 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) { if (result.isFailure()) { // Record the failure and attempt recovery - var errorLoc = furthestFailure.or(location()); + var errorLoc = %s; var errorSpan = SourceSpan.sourceSpan(errorLoc, errorLoc); - var expected = furthestExpected.filter(s -> !s.isEmpty()).or(%s.or("valid input")); + var expected = %s; addDiagnostic("expected " + expected, errorSpan); // Skip to recovery point and try to continue @@ -2502,7 +2648,7 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) { var trailingTrivia = skipWhitespace(); if (!isAtEnd()) { // Unexpected trailing input - use furthest failure position for error - var errorLoc = furthestFailure.or(location()); + var errorLoc = %s; var skippedSpan = skipToRecoveryPoint(); var errorSpan = SourceSpan.sourceSpan(errorLoc, skippedSpan.end()); addDiagnostic("unexpected input", errorSpan, "expected end of input"); @@ -2519,7 +2665,12 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) { return ParseResultWithDiagnostics.withErrors(Option.some(rootNode), diagnostics, input); } - """.formatted(sanitizedName, resultExpectedExpr, resultNodeUnwrapExpr, resultNodeUnwrapExpr)); + """.formatted(sanitizedName, + furthestFailureOrLoc, + expectedExpr, + furthestFailureOrLoc, + resultNodeUnwrapExpr, + resultNodeUnwrapExpr)); } generateCstParseRuleAt(sb); } @@ -2533,12 +2684,15 @@ public ParseResultWithDiagnostics parseWithDiagnostics(String input) { * rule's generated {@code parse_()} method. */ private void generateCstParseRuleAt(StringBuilder sb) { - var resultExpectedExpr = config.mutableParseResult() - ? "(result.expected != null ? Option.some(result.expected) : Option.none())" - : "result.expected"; var resultNodeUnwrapExpr = config.mutableParseResult() ? "result.node" : "result.node.unwrap()"; + var furthestFailureOrLoc = config.mutableParseResult() + ? "(furthestFailure != null ? furthestFailure : location())" + : "furthestFailure.or(location())"; + var expectedExpr = config.mutableParseResult() + ? "((furthestExpected != null && !furthestExpected.isEmpty()) ? furthestExpected : (result.expected != null ? result.expected : \"valid input\"))" + : "furthestExpected.filter(s -> !s.isEmpty()).or(result.expected.or(\"valid input\"))"; var ruleAtBlock = new StringBuilder(); ruleAtBlock.append(""" // === PartialParse (0.3.0) === @@ -2603,8 +2757,8 @@ public Result parseRuleAt( seekTo(offset); var result = supplier.get(); if (result.isFailure()) { - var errorLoc = furthestFailure.or(location()); - var expected = furthestExpected.filter(s -> !s.isEmpty()).or(__RESULT_EXPECTED__.or("valid input")); + var errorLoc = __FURTHEST_FAILURE_OR_LOC__; + var expected = __EXPECTED_EXPR__; return Result.failure(new ParseError(errorLoc, "expected " + expected)); } return Result.success(new PartialParse(__RESULT_NODE__, pos)); @@ -2632,7 +2786,8 @@ private void seekTo(int offset) { """); sb.append(ruleAtBlock.toString() - .replace("__RESULT_EXPECTED__", resultExpectedExpr) + .replace("__FURTHEST_FAILURE_OR_LOC__", furthestFailureOrLoc) + .replace("__EXPECTED_EXPR__", expectedExpr) .replace("__RESULT_NODE__", resultNodeUnwrapExpr)); } From 478b89b225a9f7d02289468a38b5f28e8d162201 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 7 May 2026 22:55:28 +0200 Subject: [PATCH 25/46] =?UTF-8?q?refactor:=20D-pass=20sweep=20=E2=80=94=20?= =?UTF-8?q?replace=20.span().start().X()=20with=20primitive=20accessors=20?= =?UTF-8?q?(cleanup;=20bench-neutral)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/pragmatica/peg/error/Diagnostic.java | 53 ++++++++----------- .../peg/generator/ParserGenerator.java | 9 ++-- .../org/pragmatica/peg/parser/PegEngine.java | 12 +++-- .../peg/formatter/FormatContext.java | 8 +-- .../pragmatica/peg/formatter/Formatter.java | 8 +-- .../peg/incremental/internal/TreeSplicer.java | 18 +++---- .../internal/TriviaRedistribution.java | 47 +++++----------- 7 files changed, 56 insertions(+), 99 deletions(-) diff --git a/peglib-core/src/main/java/org/pragmatica/peg/error/Diagnostic.java b/peglib-core/src/main/java/org/pragmatica/peg/error/Diagnostic.java index c39543f..52e8e36 100644 --- a/peglib-core/src/main/java/org/pragmatica/peg/error/Diagnostic.java +++ b/peglib-core/src/main/java/org/pragmatica/peg/error/Diagnostic.java @@ -172,30 +172,25 @@ public String format(String source, String filename) { .append(message) .append("\n"); // Location: --> filename:line:column - var loc = span.start(); sb.append(" --> "); if (filename != null) { sb.append(filename) .append(":"); } - sb.append(loc.line()) + sb.append(span.startLine()) .append(":") - .append(loc.column()) + .append(span.startColumn()) .append("\n"); // Find all lines we need to display - int minLine = span.start() - .line(); - int maxLine = span.end() - .line(); + int minLine = span.startLine(); + int maxLine = span.endLine(); for (var label : labels) { minLine = Math.min(minLine, label.span() - .start() - .line()); + .startLine()); maxLine = Math.max(maxLine, label.span() - .end() - .line()); + .endLine()); } // Calculate gutter width int gutterWidth = String.valueOf(maxLine) @@ -238,9 +233,7 @@ public String format(String source, String filename) { private List