mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-13 03:58:33 +00:00
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:
parent
e49255b3ee
commit
b2b2c09b46
3 changed files with 205 additions and 66 deletions
96
gitnexus/test/helpers/r-argument-scaling.ts
Normal file
96
gitnexus/test/helpers/r-argument-scaling.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue