From edbb0976166eb6e26487ca3d15ddb55f1d54c4ae Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Fri, 16 Oct 2026 14:55:06 +0100 Subject: [PATCH] perf(r): binary-search character classes in the linear regex matcher inSet() scanned a class's ranges from the front on every state visit, but the search loop charged one unit per visit regardless of class size, so a wide class multiplied real work the budget never counted. Ranges are sorted, merged and disjoint for every CharSet (normalise() for written classes, constants and complement() for escapes), so a binary search is equivalent. Charged work is unchanged. Tests pin the reads per visit, the edges of a large class and the charged work counters. --- .../ingestion/languages/r/linear-regex.ts | 21 +++-- .../r-export-pattern-shared-budget.test.ts | 84 +++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/r/linear-regex.ts b/gitnexus/src/core/ingestion/languages/r/linear-regex.ts index 934b5452f..08ae828a5 100644 --- a/gitnexus/src/core/ingestion/languages/r/linear-regex.ts +++ b/gitnexus/src/core/ingestion/languages/r/linear-regex.ts @@ -988,13 +988,24 @@ function holdsIn(assertion: number, context: number): boolean { } } +/** + * Whether `c` is in `set`. Every CharSet's ranges are sorted, merged and disjoint (a + * class goes through {@link normalise}; the escape classes are constants or their + * {@link complement}), so a binary search finds the one range that could hold `c`. A + * linear scan here would make each state visit cost a whole class, work the budget + * does not count. + */ function inSet(set: CharSet, c: number): boolean { - let hit = false; const ranges = set.ranges; - for (let i = 0; i < ranges.length; i++) { - const r = ranges[i]; - if (c < r[0]) break; // ranges are sorted - if (c <= r[1]) { + let lo = 0; + let hi = ranges.length - 1; + let hit = false; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + const r = ranges[mid]; + if (c < r[0]) hi = mid - 1; + else if (c > r[1]) lo = mid + 1; + else { hit = true; break; } diff --git a/gitnexus/test/unit/r-export-pattern-shared-budget.test.ts b/gitnexus/test/unit/r-export-pattern-shared-budget.test.ts index d4e5509db..1f0499fb7 100644 --- a/gitnexus/test/unit/r-export-pattern-shared-budget.test.ts +++ b/gitnexus/test/unit/r-export-pattern-shared-budget.test.ts @@ -606,3 +606,87 @@ describe('the start-state filter is charged to the work budget', () => { expect(budget.spent).toBeGreaterThanOrEqual(1_000_000); }); }); + +describe('a class membership test does not scan the class', () => { + /** The body of a class of `ranges` ranges, each two units wide with a gap of two between. */ + const classBody = (ranges: number): string => { + let body = ''; + for (let i = 0; i < ranges; i++) { + const lo = 0x100 + i * 4; + body += `${String.fromCharCode(lo)}-${String.fromCharCode(lo + 1)}`; + } + return body; + }; + + /** `([]?){states}Z`: `states` states each holding a `ranges`-range class. */ + const wideClassInLoop = (ranges: number, states: number): string => + `([${classBody(ranges)}]?){${states}}Z`; + + /** A name made of the last range's first unit, the worst case for a scan from the front. */ + const lastRangeName = (ranges: number, length: number): string => + String.fromCharCode(0x100 + (ranges - 1) * 4).repeat(length); + + /** + * Replace every class's ranges with a view that counts element reads. This reaches into + * the compiled matcher on purpose: it makes the cost of a membership test a count of + * reads, which no machine speed can change. + */ + const countRangeReads = (matcher: LinearRegex): { reads: number } => { + const counter = { reads: 0 }; + const sets = new Set<{ ranges: unknown }>(); + for (const entry of (matcher as unknown as { arg: unknown[] }).arg) { + if (entry !== null && typeof entry === 'object') sets.add(entry as { ranges: unknown }); + } + for (const set of sets) { + set.ranges = new Proxy(set.ranges as object, { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) counter.reads++; + return Reflect.get(target, key, receiver); + }, + }); + } + return counter; + }; + + it('reads a logarithmic number of ranges per state visit', () => { + const ranges = 1000; + const matcher = compileLinearRegex(wideClassInLoop(ranges, 150)) as LinearRegex; + expect(matcher).not.toBeNull(); + const counter = countRangeReads(matcher); + + expect(matcher.test(lastRangeName(ranges, 80))).toBe(false); + + // Every visit tests the class once, and the start-state filter adds the same again. + // A scan from the front would read some 500 ranges a visit; a binary search reads + // at most ceil(log2(1001)) = 10. + expect(matcher.work).toBeGreaterThan(10_000); + expect(counter.reads).toBeLessThanOrEqual(matcher.work * 10); + }); + + it('still decides membership exactly at the edges of a large class', () => { + const ranges = 1000; + const matcher = compileLinearRegex(`^[${classBody(ranges)}]$`) as LinearRegex; + expect(matcher).not.toBeNull(); + const at = (i: number, offset: number): string => String.fromCharCode(0x100 + i * 4 + offset); + for (const i of [0, 1, 499, 500, ranges - 2, ranges - 1]) { + expect(matcher.test(at(i, 0))).toBe(true); + expect(matcher.test(at(i, 1))).toBe(true); + expect(matcher.test(at(i, 2))).toBe(false); // the gap between two ranges + expect(matcher.test(at(i, 3))).toBe(false); + } + expect(matcher.test('ΓΏ')).toBe(false); // below every range + expect(matcher.test(at(ranges, 0))).toBe(false); // above every range + }); + + it('keeps the work counters of the class scan it charges for', () => { + // Charged work is part of the budget contract and must not follow the lookup's speed. + const wide = compileLinearRegex(wideClassInLoop(1000, 200)) as LinearRegex; + const narrow = compileLinearRegex(wideClassInLoop(1, 200)) as LinearRegex; + for (const suffix of ['0', '1', '2', '3', '4']) { + expect(wide.test(lastRangeName(1000, 100) + suffix)).toBe(false); + expect(narrow.test(lastRangeName(1, 100) + suffix)).toBe(false); + } + expect(wide.work).toBe(1_802_914); + expect(narrow.work).toBe(404_314); + }); +});