diff --git a/gitnexus/test/helpers/r-argument-scaling.ts b/gitnexus/test/helpers/r-argument-scaling.ts new file mode 100644 index 000000000..f856dd931 --- /dev/null +++ b/gitnexus/test/helpers/r-argument-scaling.ts @@ -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; + +/** + * 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( + 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), + }; +} diff --git a/gitnexus/test/unit/r-definition-anchors.test.ts b/gitnexus/test/unit/r-definition-anchors.test.ts index 41ccdafc5..97d5b9fc3 100644 --- a/gitnexus/test/unit/r-definition-anchors.test.ts +++ b/gitnexus/test/unit/r-definition-anchors.test.ts @@ -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[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 = {}; @@ -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, + ); }); diff --git a/gitnexus/test/unit/r-scope-naming-argument.test.ts b/gitnexus/test/unit/r-scope-naming-argument.test.ts index 74228eaaf..8368bba10 100644 --- a/gitnexus/test/unit/r-scope-naming-argument.test.ts +++ b/gitnexus/test/unit/r-scope-naming-argument.test.ts @@ -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, + ); });