Skip to content

Commit b41ee24

Browse files
committed
parser: splice deletion-shaped surgery in place (#45)
Node surgery only spliced in place when the new kid count equalled the removed count; any edit that SHRANK the count (deleting a list element, a member, a union arm) fell to the end-allocation branch — a full row copy to the arena tail. That path is correct but relocates, growing the arena. A shrink (f < removed) FITS the original kid range: the suffix shifts LEFT, which is an overlap-safe forward copy, so target csD in place and add no rows. The per-kid transforms (prefix-rel normalize, new kids, suffix copy, end-relative boundary remap) are exactly the proven end-allocation ones — only the destination changes — so it reuses that code with ks = csD. Grows (f > removed) still relocate. exhaustive-edits asserts the in-place-shrink branch actually fires (8 splices at ≤4 chars, 60 at ≤5) and that all 3.2M edited trees stay byte-identical to fresh; __arenaStats exposes the counter.
1 parent ba295f1 commit b41ee24

2 files changed

Lines changed: 19 additions & 6 deletions

File tree

src/emit-parser.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,7 @@ let arenaLiveBaseline = 0;
15011501
let arenaCompactions = 0;
15021502
let arenaCompactFactor = 3;
15031503
let arenaCompactMin = 4096;
1504+
let arenaInPlaceShrink = 0; // surgery splices that fit a SHRUNK kid count in place (C2)
15041505
let kids = new Int32Array(16384);
15051506
// A node child's RELATIVE coordinates live in the PARENT's kids stream (parallel to
15061507
// kids), not on the child row: a memo-reused subtree can be a child of several
@@ -3259,8 +3260,15 @@ function trySurgery(dmgA, dmgB, tokD, chrD) {
32593260
}
32603261
} else {
32613262
const n2k = nD - removed + f;
3262-
if (kidN + n2k > kidCap) growKids(n2k);
3263-
const ks = kidN;
3263+
// f < removed (a SHRINK, e.g. deleting a list element) fits the OLD range in place: the
3264+
// suffix shifts LEFT, an overlap-safe forward copy, so target csD and grow the arena by
3265+
// nothing (issue #45 C2). f > removed (a GROW) cannot fit, so it relocates to the arena end
3266+
// and leaves the old range as garbage the C1 compaction later reclaims. The per-kid
3267+
// transforms — prefix normalize, new kids, suffix copy, boundary remap — are identical.
3268+
const inPlace = f < removed;
3269+
let ks;
3270+
if (inPlace) { ks = csD; arenaInPlaceShrink++; }
3271+
else { if (kidN + n2k > kidCap) growKids(n2k); ks = kidN; }
32643272
for (let k = 0; k < Da; k++) {
32653273
kids[ks + k] = kids[csD + k];
32663274
// NORMALIZE prefix rels to absolute while copying: the boundary remap below
@@ -3288,7 +3296,7 @@ function trySurgery(dmgA, dmgB, tokD, chrD) {
32883296
kidRel[ks + Da + f + (k - j)] = kidRel[csD + k];
32893297
kidTokRel[ks + Da + f + (k - j)] = kidTokRel[csD + k];
32903298
}
3291-
kidN = ks + n2k;
3299+
if (!inPlace) kidN = ks + n2k; // in-place reuses the old range; it adds no rows
32923300
rowStart[D] = ks;
32933301
rowCount[D] = n2k;
32943302
// remap the end-relative boundary into the relocated range (suffix kids kept
@@ -3999,7 +4007,7 @@ export function parseEdited(entryRule, edits) { activate(docDefault); return edi
39994007
// Arena reclamation introspection + budget override — TEST HOOKS (issue #45 C1). __arenaStats
40004008
// reports the live arena, the compacted-size baseline, and how many edits re-parsed to reclaim;
40014009
// __setArenaBudget lowers the factor/min so a gate can force compaction deterministically.
4002-
export function __arenaStats() { return { nodeN, kidN, baseline: arenaLiveBaseline, compactions: arenaCompactions }; }
4010+
export function __arenaStats() { return { nodeN, kidN, baseline: arenaLiveBaseline, compactions: arenaCompactions, inPlaceShrink: arenaInPlaceShrink }; }
40034011
export function __setArenaBudget(factor, min) { arenaCompactFactor = factor; arenaCompactMin = min; }
40044012
export function visit(entry, fns, charBase, tokBase) { activate(docDefault); return visitCore(entry, fns, charBase, tokBase); }
40054013
// ── Handle API: explicit trees over per-instance documents ──

test/exhaustive-edits.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const emPath = '/tmp/emitted-exhaustive.mjs';
3535
writeFileSync(emPath, emitParser(g));
3636
type Cst = { root: number; errors: object[] };
3737
type Parser = { parse(s: string): Cst; edit(c: Cst, e: object[]): void; visit(c: Cst, fns: object): void; tree: import('./emitted-obj.ts').TreeView };
38-
const em = (await import(emPath + '?v=' + process.pid)) as { createParser(): Parser };
38+
const em = (await import(emPath + '?v=' + process.pid)) as { createParser(): Parser; __arenaStats(): { inPlaceShrink: number } };
3939

4040
const ALPHABET = ['a', '0', '(', ')', ',', '+', ';', ' '];
4141
const MAXLEN = Number(process.env.EXH_MAXLEN ?? 4); // ~330k steps; EXH_MAXLEN=5 for the 3.2M-step deep run
@@ -69,6 +69,11 @@ for (let L = 0; L <= MAXLEN; L++) {
6969
}
7070
}
7171
}
72-
console.log(`exhaustive-edits: ${docs} documents ≤${MAXLEN} chars × every 1-char edit = ${edits} steps · ${mismatches} mismatches`);
72+
// The deletions in this list-shaped grammar shrink kid counts, so the C2 in-place-shrink
73+
// surgery branch must actually fire here — otherwise the 0-mismatch result would only prove
74+
// the path is UNREACHABLE, not correct.
75+
const inPlaceShrink = em.__arenaStats().inPlaceShrink;
76+
console.log(`exhaustive-edits: ${docs} documents ≤${MAXLEN} chars × every 1-char edit = ${edits} steps · ${mismatches} mismatches · ${inPlaceShrink} in-place shrink splices`);
7377
if (mismatches > 0) { console.error('✗ edit ≢ fresh inside the exhaustive bound'); process.exit(1); }
78+
if (inPlaceShrink === 0) { console.error('✗ the in-place shrink surgery path (C2) never fired — coverage gap'); process.exit(1); }
7479
console.log('✓ edit ≡ fresh holds COMPLETELY within the bound (tree + errors, byte-identical)');

0 commit comments

Comments
 (0)