test(r): replace wall-clock query-cost limits with a shared scaling guard

The two argument-selection cost groups (r-scope-naming-argument,
r-definition-anchors) were copy-pasted with an absolute 2500 ms limit and
never asserted what the query kept, so an empty parse passed.

- Move the shapes and a size-ratio check (N vs 4N, fastest of three runs,
  floored small run) into test/helpers/r-argument-scaling.ts; linear work is
  ~4x, a quadratic query ~16x, independent of machine speed.
- Assert the exact match count and naming capture at both sizes.
- r-definition-anchors now parses through parseSourceSafe (the 100 K+ char
  inputs hit the Windows native-binding limit documented in safe-parse.ts).
- r-scope-naming-argument: classNames no longer filters on /^[A-Z]/; it selects
  by @declaration.class and gains lowercase positive and negative cases.
This commit is contained in:
Mick Cooney 2026-10-12 00:10:16 +01:00
parent e49255b3ee
commit b2b2c09b46
3 changed files with 205 additions and 66 deletions

View file

@ -0,0 +1,96 @@
/**
* Shared cost guard for the R argument-selection queries (the scope query and
* R_QUERIES). Both once had a quadratic formulation (an argument-prefix pattern
* repeated before the naming argument) that took seconds on one call with thousands
* of arguments.
*
* Absolute wall-clock limits are machine-dependent, so the guard compares the same
* work at two input sizes: linear work grows ~4x when the argument count is
* quadrupled, a quadratic query ~16x. Timings use the fastest of several runs and a
* floor on the small run, so scheduler noise on a loaded runner cannot inflate the
* ratio. Correctness of the output is asserted by the callers at both sizes, so an
* early return or an empty parse cannot pass as "fast".
*/
export const SMALL_N = 2000;
export const LARGE_N = 8000;
/** Linear is ~4; quadratic is ~16 or worse. */
export const MAX_RATIO = 10;
/** The small run is never treated as faster than this (below it, noise dominates). */
const FLOOR_MS = 15;
const REPEATS = 3;
export interface ArgumentShape {
readonly name: string;
readonly build: (n: number) => string;
}
const named = (n: number) => Array.from({ length: n }, (_, k) => `a${k} = ${k}`).join(', ');
const comments = (n: number) => Array.from({ length: n }, (_, k) => `# c${k}`).join('\n');
const unnamedStrings = (n: number) => Array.from({ length: n }, (_, k) => `"s${k}"`).join(', ');
const namedStrings = (n: number) =>
Array.from({ length: n }, (_, k) => `a${k} = "s${k}"`).join(', ');
export const SHAPES = {
listNamedArguments: {
name: 'one call with many named arguments',
build: (n) => `x <- list(${named(n)})`,
},
setClassNamedBefore: {
name: 'setClass with many named arguments before the name',
build: (n) => `setClass(${named(n)}, "A")`,
},
libraryNamedBefore: {
name: 'library with many named arguments before the name',
build: (n) => `library(${named(n)}, pkg)`,
},
setClassCommentsBefore: {
name: 'setClass with many comments before the name',
build: (n) => `setClass(\n${comments(n)}\n"A")`,
},
libraryCommentsBefore: {
name: 'library with many comments before the name',
build: (n) => `library(\n${comments(n)}\npkg)`,
},
setClassUnnamedStrings: {
name: 'setClass with many unnamed arguments',
build: (n) => `setClass(${unnamedStrings(n)})`,
},
libraryUnnamedStrings: {
name: 'library with many unnamed arguments',
build: (n) => `library(${unnamedStrings(n)})`,
},
setClassNamedStrings: {
name: 'setClass with many named string arguments',
build: (n) => `setClass("A", ${namedStrings(n)})`,
},
} as const satisfies Record<string, ArgumentShape>;
/**
* Runs the work produced by `prepare(source)` at SMALL_N and LARGE_N and returns the
* results and the time ratio. `prepare` does the untimed set-up (e.g. parsing) and
* returns the thunk that is timed.
*/
export function measureScaling<T>(
prepare: (source: string) => () => T,
build: (n: number) => string,
): { small: T; large: T; ratio: number } {
const run = (n: number) => {
const thunk = prepare(build(n));
let best = Infinity;
let result!: T;
for (let i = 0; i < REPEATS; i++) {
const start = performance.now();
result = thunk();
best = Math.min(best, performance.now() - start);
}
return { result, ms: best };
};
const small = run(SMALL_N);
const large = run(LARGE_N);
return {
small: small.result,
large: large.result,
ratio: large.ms / Math.max(small.ms, FLOOR_MS),
};
}

View file

@ -15,6 +15,8 @@ import Parser from 'tree-sitter';
import R from '@eagleoutice/tree-sitter-r';
import { R_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js';
import { isRNonNamingArgumentCapture } from '../../src/core/ingestion/languages/r/naming-argument.js';
import { parseSourceSafe } from '../../src/core/tree-sitter/safe-parse.js';
import { MAX_RATIO, SHAPES, measureScaling } from '../helpers/r-argument-scaling.js';
const language = R as Parameters<Parser['setLanguage']>[0];
const parser = new Parser();
@ -23,10 +25,16 @@ const query = new Parser.Query(language, R_QUERIES);
type Capture = { name: string; node: Parser.SyntaxNode };
/**
* Parse like production does: `parseSourceSafe` avoids the native binding's crash on
* strings above 32 767 characters on Windows (the scaling group parses 100 K+ strings).
*/
const parseR = (source: string): Parser.Tree =>
parseSourceSafe(parser, source, undefined, { bufferSize: 1 << 24 });
/** Matches the parse worker keeps: the provider's selection applied to the query's matches. */
function keptMatches(source: string | Parser.Tree): Capture[][] {
const tree =
typeof source === 'string' ? parser.parse(source, undefined, { bufferSize: 1 << 24 }) : source;
const tree = typeof source === 'string' ? parseR(source) : source;
const kept: Capture[][] = [];
for (const match of query.matches(tree.rootNode)) {
const captureMap: Record<string, Parser.SyntaxNode> = {};
@ -273,45 +281,42 @@ describe('R naming argument: a later Class = retracts an earlier unnamed argumen
/**
* Selecting the naming argument must stay linear in the argument count: an earlier
* query-level formulation (a repeated sibling group before the argument) was
* quadratic and took seconds on a single call with thousands of arguments. The
* bound is generous (a linear pass takes tens of milliseconds); the quadratic
* shapes took 4s to 18s.
* quadratic and took seconds on a single call with thousands of arguments. The same
* work is run at two sizes and the time ratio is bounded (see
* `helpers/r-argument-scaling.ts`); the kept matches are asserted at both sizes.
*/
describe('R_QUERIES: cost is linear in the argument count', () => {
const LIMIT_MS = 2500;
const N = 16000;
const named = (n: number) => Array.from({ length: n }, (_, k) => `a${k} = ${k}`).join(', ');
const timed = (source: string) => {
const tree = parser.parse(source, undefined, { bufferSize: 1 << 24 });
const start = Date.now();
const kept = keptMatches(tree).length;
return { ms: Date.now() - start, kept };
/** Parsing is set-up (untimed); the timed work is the query plus the provider's selection. */
const run = (source: string) => {
const tree = parseR(source);
return () => {
const kept = keptMatches(tree);
const defs: string[] = [];
for (const captures of kept) {
const def = captures.find((c) => c.name.startsWith('definition.'));
const name = captures.find((c) => c.name === 'name');
if (def && name) defs.push(`${def.name}:${name.node.text}`);
}
return { count: kept.length, defs };
};
};
it('one call with many named arguments', () => {
expect(timed(`x <- list(${named(N)})`).ms).toBeLessThan(LIMIT_MS);
});
const cases = [
{ shape: SHAPES.listNamedArguments, expected: { count: 1, defs: [] } },
{ shape: SHAPES.setClassNamedBefore, expected: { count: 2, defs: ['definition.class:A'] } },
{ shape: SHAPES.setClassCommentsBefore, expected: { count: 2, defs: ['definition.class:A'] } },
{ shape: SHAPES.setClassUnnamedStrings, expected: { count: 2, defs: ['definition.class:s0'] } },
{ shape: SHAPES.setClassNamedStrings, expected: { count: 2, defs: ['definition.class:A'] } },
] as const;
it('setClass with many named arguments before the name', () => {
const { ms, kept } = timed(`setClass(${named(N / 2)}, "A")`);
expect(ms).toBeLessThan(LIMIT_MS);
expect(kept).toBeGreaterThan(0);
});
it('setClass with many comments before the name', () => {
const comments = Array.from({ length: N / 2 }, (_, k) => `# c${k}`).join('\n');
const { ms, kept } = timed(`setClass(\n${comments}\n"A")`);
expect(ms).toBeLessThan(LIMIT_MS);
expect(kept).toBeGreaterThan(0);
});
it('setClass with many unnamed string arguments', () => {
const strings = Array.from({ length: N }, (_, k) => `"s${k}"`).join(', ');
expect(timed(`setClass(${strings})`).ms).toBeLessThan(LIMIT_MS);
});
it('setClass with many named string arguments', () => {
const strings = Array.from({ length: N }, (_, k) => `a${k} = "s${k}"`).join(', ');
expect(timed(`setClass("A", ${strings})`).ms).toBeLessThan(LIMIT_MS);
});
it.each(cases)(
'$shape.name',
({ shape, expected }) => {
const { small, large, ratio } = measureScaling(run, shape.build);
expect(small).toEqual(expected);
expect(large).toEqual(expected);
expect(ratio).toBeLessThan(MAX_RATIO);
},
120_000,
);
});

View file

@ -2,12 +2,13 @@
* R scope query: only the argument that names a class or an import yields
* `@declaration.name` / `@import.source`. The query captures every candidate
* argument; `emitRScopeCaptures` drops the rest. Each negative is paired with a
* positive control, and a bounded-time group guards the cost against a quadratic
* positive control, and a scaling group guards the cost against a quadratic
* regression (an argument-prefix pattern in the query was quadratic in the number
* of leading comments and named arguments).
*/
import { describe, expect, it } from 'vitest';
import { emitRScopeCaptures } from '../../src/core/ingestion/languages/r/captures.js';
import { MAX_RATIO, SHAPES, measureScaling } from '../helpers/r-argument-scaling.js';
function tagTexts(source: string, tag: string): string[] {
return emitRScopeCaptures(source, 'fixture.R')
@ -16,8 +17,16 @@ function tagTexts(source: string, tag: string): string[] {
}
const importSources = (source: string) => tagTexts(source, '@import.source');
/**
* Every class declared by the scope query. Selected by capture kind (the match carries
* `@declaration.class`), not by spelling, so a lowercase class or a wrongly captured
* lowercase string is visible.
*/
const classNames = (source: string) =>
tagTexts(source, '@declaration.name').filter((t) => /^[A-Z]/.test(t));
emitRScopeCaptures(source, 'fixture.R')
.filter((m) => m['@declaration.class'] !== undefined)
.map((m) => m['@declaration.name']?.text)
.filter((t): t is string => t !== undefined);
describe('R scope query: imports name only their first argument', () => {
it('imports the first argument (positive controls)', () => {
@ -61,6 +70,19 @@ describe('R scope query: setClass declares only its naming argument', () => {
expect(classNames('setRefClass("R", contains = "Base")')).toEqual(['R']);
});
it('declares a lowercase class name (positive controls)', () => {
expect(classNames('setClass("a", contains = "VIRTUAL")')).toEqual(['a']);
expect(classNames('setRefClass("r")')).toEqual(['r']);
expect(classNames('setClass(Class = "a")')).toEqual(['a']);
});
it('does not declare a lowercase class from a non-naming argument', () => {
expect(classNames('setClass("A", contains = "numeric")')).toEqual(['A']);
expect(classNames('setRefClass("R", contains = "base")')).toEqual(['R']);
expect(classNames('setClass("a", contains = "numeric", package = "pkg")')).toEqual(['a']);
expect(classNames('setClass("A", representation("numeric", x = "character"))')).toEqual(['A']);
});
it('declares only Class = when it follows an unnamed argument', () => {
expect(classNames('setClass(a = "Q", "B", Class = "A")')).toEqual(['A']);
expect(classNames('setRefClass("B", Class = "A")')).toEqual(['A']);
@ -79,33 +101,49 @@ describe('R scope query: setClass declares only its naming argument', () => {
});
describe('R scope query: cost is linear in the argument count', () => {
const LIMIT_MS = 2500;
const N = 16000;
const named = (n: number) => Array.from({ length: n }, (_, k) => `a${k} = ${k}`).join(', ');
const timed = (source: string) => {
const start = Date.now();
const matches = emitRScopeCaptures(source, 'fixture.R').length;
return { ms: Date.now() - start, matches };
/** What the query kept: the match count and the naming captures, in source order. */
const run = (source: string) => () => {
const matches = emitRScopeCaptures(source, 'fixture.R');
const naming: string[] = [];
for (const m of matches) {
for (const tag of ['@declaration.name', '@import.source']) {
const text = m[tag]?.text;
if (text !== undefined) naming.push(`${tag}=${text}`);
}
}
return { count: matches.length, naming };
};
it('one call with many named arguments', () => {
expect(timed(`x <- list(${named(N)})`).ms).toBeLessThan(LIMIT_MS);
});
// Every shape keeps exactly one naming capture regardless of the argument count: the
// count and the name are asserted at both sizes, so an early-return or empty parse
// cannot pass as "fast".
const cases = [
{ shape: SHAPES.listNamedArguments, expected: { count: 2, naming: [] } },
{ shape: SHAPES.setClassNamedBefore, expected: { count: 4, naming: ['@declaration.name=A'] } },
{ shape: SHAPES.libraryNamedBefore, expected: { count: 3, naming: ['@import.source=pkg'] } },
{
shape: SHAPES.setClassCommentsBefore,
expected: { count: 4, naming: ['@declaration.name=A'] },
},
{ shape: SHAPES.libraryCommentsBefore, expected: { count: 3, naming: ['@import.source=pkg'] } },
{
shape: SHAPES.setClassUnnamedStrings,
expected: { count: 4, naming: ['@declaration.name=s0'] },
},
{
shape: SHAPES.libraryUnnamedStrings,
expected: { count: 3, naming: ['@import.source="s0"'] },
},
] as const;
it('setClass and library with many named arguments before the name', () => {
expect(timed(`setClass(${named(N / 2)}, "A")`).ms).toBeLessThan(LIMIT_MS);
expect(timed(`library(${named(N / 2)}, pkg)`).ms).toBeLessThan(LIMIT_MS);
});
it('setClass and library with many comments before the name', () => {
const comments = Array.from({ length: N / 2 }, (_, k) => `# c${k}`).join('\n');
expect(timed(`setClass(\n${comments}\n"A")`).ms).toBeLessThan(LIMIT_MS);
expect(timed(`library(\n${comments}\npkg)`).ms).toBeLessThan(LIMIT_MS);
});
it('setClass and library with many unnamed arguments', () => {
const strings = Array.from({ length: N }, (_, k) => `"s${k}"`).join(', ');
expect(timed(`setClass(${strings})`).ms).toBeLessThan(LIMIT_MS);
expect(timed(`library(${strings})`).ms).toBeLessThan(LIMIT_MS);
});
it.each(cases)(
'$shape.name',
({ shape, expected }) => {
const { small, large, ratio } = measureScaling(run, shape.build);
expect(small).toEqual(expected);
expect(large).toEqual(expected);
expect(ratio).toBeLessThan(MAX_RATIO);
},
120_000,
);
});