diff --git a/.oxlintrc.json b/.oxlintrc.json index f03a375..0ce6dba 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -11,6 +11,7 @@ "guard-for-in": "error", "import/no-duplicates": "error", "import/no-self-import": "error", + "max-lines": ["error", { "max": 400 }], "no-bitwise": "error", "no-case-declarations": "error", "no-duplicate-imports": "error", diff --git a/package.json b/package.json index c05ad31..d8cb159 100644 --- a/package.json +++ b/package.json @@ -33,13 +33,13 @@ "scripts": { "lint": "oxlint . && tsc && oxfmt --check", "fmt": "oxfmt", - "benchmark:arithmetic-chains": "node scripts/benchmark-arithmetic-chains.js", - "benchmark:nested-fallbacks": "node scripts/benchmark-nested-fallbacks.js", - "benchmark:corpus": "node scripts/benchmark.js", - "benchmark:serialization": "node scripts/benchmark-serialization.js", + "benchmark:arithmetic-chains": "node scripts/benchmark/benchmark-arithmetic-chains.js", + "benchmark:nested-fallbacks": "node scripts/benchmark/benchmark-nested-fallbacks.js", + "benchmark:corpus": "node scripts/benchmark/benchmark-corpus.js", + "benchmark:serialization": "node scripts/benchmark/benchmark-serialization.js", "test:benchmark": "node --test 'test/unit/benchmark-*.test.js' test/unit/compare-parser-benchmarks.test.js test/unit/corpus-benchmark.test.js", "test:benchmark:simulation": "node test/benchmark/statistical-simulation.js", - "benchmark:reanalyze": "node scripts/compare-parser-benchmarks.js", + "benchmark:reanalyze": "node scripts/benchmark/compare-parser-benchmarks.js", "test": "node --test --test-reporter=dot 'test/**/*.test.js' 'test/**/*.test.cjs'", "test:mutation:corpus": "node test/mutation/corpus-selection.js", "test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.js" diff --git a/scripts/README.md b/scripts/README.md index f760635..b044851 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,13 +1,16 @@ # Benchmark scripts These scripts are deliberately outside the ordinary test suite. Run them on a -controlled machine with `node scripts/.js` (or the corresponding pnpm +controlled machine with `node scripts/benchmark/.js` (or the corresponding pnpm command). Benchmark artifacts are schema-v2 JSON files and retain raw observations, configuration, provenance, and enough information for offline reanalysis. -- **`benchmark-arithmetic-chains.js`** — runs the fresh-process, paired parser - benchmark for arithmetic shapes. `benchmark-nested-fallbacks.js` does the +For a detailed explanation of the statistical methodology, experiment design, +and software architecture, see [BENCHMARKS.md](../BENCHMARKS.md). + +- **`benchmark/benchmark-arithmetic-chains.js`** — runs the fresh-process, paired parser + benchmark for arithmetic shapes. `benchmark/benchmark-nested-fallbacks.js` does the same for nested `var()` fallbacks. Both accept `--baseline`, `--blocks`, `--max-attempts`, `--seed`, and `--output`, and write schema-v2 artifacts under `reports/benchmarks/`. The default arithmetic grid uses four logarithmically @@ -24,20 +27,20 @@ reanalysis. requires every gated runtime, slope, and growth endpoint to meet its predeclared precision target; the requested block count is never increased from an observed effect during a run. -- **`compare-parser-benchmarks.js`** — reanalyzes one schema-v2 parser +- **`benchmark/compare-parser-benchmarks.js`** — reanalyzes one schema-v2 parser artifact and applies the uncertainty-aware runtime, slope, and growth gates. -- **`benchmark-serialization.js`** — measures buffered serializer scaling for wide sums/products, nested calls, and nested opaque fallbacks. +- **`benchmark/benchmark-serialization.js`** — measures buffered serializer scaling for wide sums/products, nested calls, and nested opaque fallbacks. - **`harvest-github.js`** — scrapes real-world `calc()` expressions from public GitHub into `test/corpus/github/expressions.txt`. - **`split-corpus.js`** — splits that file into `github-pure.txt` (feeds - `benchmark.js`/`show-divergences.js` below), `preprocessor.txt`, and + `benchmark/benchmark-corpus.js`/`show-divergences.js` below), `preprocessor.txt`, and `invalid.txt` (the latter two are used by real CI resilience tests). - **`lib/corpus.js`** — shared loader for `github-pure.txt`. -- **`benchmark.js`** — (`pnpm benchmark:corpus`) validates and times our +- **`benchmark/benchmark-corpus.js`** — (`pnpm benchmark:corpus`) validates and times our pipeline against `@csstools/css-calc` over the pure corpus in fresh processes. It is report-only for speed; correctness and infrastructure failures are nonzero. -- **`benchmark-plugin.js`** — measures PostCSS processing; awaiting +- **`benchmark/benchmark-plugin.js`** — measures PostCSS processing; awaiting `.process(...)` already includes result serialization, so the benchmark does not add a redundant `result.css` read. - **`show-divergences.js`** — buckets where our output disagrees with diff --git a/scripts/benchmark-arithmetic-chains.js b/scripts/benchmark/benchmark-arithmetic-chains.js similarity index 95% rename from scripts/benchmark-arithmetic-chains.js rename to scripts/benchmark/benchmark-arithmetic-chains.js index aea3950..831cac8 100644 --- a/scripts/benchmark-arithmetic-chains.js +++ b/scripts/benchmark/benchmark-arithmetic-chains.js @@ -1,4 +1,4 @@ -import { runParserBenchmark } from './lib/parser-benchmark.js'; +import { runParserBenchmark } from './parser-benchmark.js'; try { const result = await runParserBenchmark({ diff --git a/scripts/benchmark.js b/scripts/benchmark/benchmark-corpus.js similarity index 89% rename from scripts/benchmark.js rename to scripts/benchmark/benchmark-corpus.js index f1b1255..4761f9e 100644 --- a/scripts/benchmark.js +++ b/scripts/benchmark/benchmark-corpus.js @@ -1,5 +1,5 @@ // Correctness-aware fresh-process corpus benchmark against @csstools/css-calc. -import { runCorpusBenchmark, parseArgs } from './lib/corpus-benchmark.js'; +import { runCorpusBenchmark, parseArgs } from './corpus-benchmark.js'; try { const result = runCorpusBenchmark(parseArgs(process.argv.slice(2))); diff --git a/scripts/benchmark-nested-fallbacks.js b/scripts/benchmark/benchmark-nested-fallbacks.js similarity index 95% rename from scripts/benchmark-nested-fallbacks.js rename to scripts/benchmark/benchmark-nested-fallbacks.js index 8dd8d64..f9eab02 100644 --- a/scripts/benchmark-nested-fallbacks.js +++ b/scripts/benchmark/benchmark-nested-fallbacks.js @@ -1,4 +1,4 @@ -import { runParserBenchmark } from './lib/parser-benchmark.js'; +import { runParserBenchmark } from './parser-benchmark.js'; try { const result = await runParserBenchmark({ diff --git a/scripts/benchmark-plugin.js b/scripts/benchmark/benchmark-plugin.js similarity index 98% rename from scripts/benchmark-plugin.js rename to scripts/benchmark/benchmark-plugin.js index 2c12528..bdfeee4 100644 --- a/scripts/benchmark-plugin.js +++ b/scripts/benchmark/benchmark-plugin.js @@ -2,7 +2,7 @@ // adapter overhead as well as the calculation pipeline; benchmark.js keeps // the parser/expression benchmark separate. import postcss from 'postcss'; -import plugin from '../src/index.js'; +import plugin from '../../src/index.js'; const WARMUP_RUNS = 3; // Keep an even count; the common harness migration uses these as paired blocks. diff --git a/scripts/benchmark-serialization.js b/scripts/benchmark/benchmark-serialization.js similarity index 87% rename from scripts/benchmark-serialization.js rename to scripts/benchmark/benchmark-serialization.js index b2ef912..0532497 100644 --- a/scripts/benchmark-serialization.js +++ b/scripts/benchmark/benchmark-serialization.js @@ -7,7 +7,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { serialize as serializeWorktree } from '../src/lib/serialize.js'; +import { serialize as serializeWorktree } from '../../src/lib/serialize.js'; import { num, dim, @@ -16,7 +16,7 @@ import { opaqueCall, mkSum, mkProduct, -} from '../src/lib/node.js'; +} from '../../src/lib/node.js'; const WIDE_SIZES = [1_024, 16_384, 65_536]; const OTHER_SIZES = [128, 256, 512]; @@ -33,7 +33,7 @@ function median(values) { return sorted[Math.floor(sorted.length / 2)]; } -/** @param {number} size @return {import('../src/lib/node.js').Node} */ +/** @param {number} size @return {import('../../src/lib/node.js').Node} */ function wideSum(size) { return mkSum( Array.from({ length: size }, (_, index) => ({ @@ -43,7 +43,7 @@ function wideSum(size) { ); } -/** @param {number} size @return {import('../src/lib/node.js').Node} */ +/** @param {number} size @return {import('../../src/lib/node.js').Node} */ function wideProduct(size) { return mkProduct( Array.from({ length: size }, (_, index) => ({ @@ -53,7 +53,7 @@ function wideProduct(size) { ); } -/** @param {number} size @return {import('../src/lib/node.js').Node} */ +/** @param {number} size @return {import('../../src/lib/node.js').Node} */ function nestedCalls(size) { let node = ident('--x'); for (let i = 0; i < Math.max(2, Math.ceil(size / 4)); i++) { @@ -62,7 +62,7 @@ function nestedCalls(size) { return node; } -/** @param {number} size @return {import('../src/lib/node.js').Node} */ +/** @param {number} size @return {import('../../src/lib/node.js').Node} */ function nestedOpaqueFallbacks(size) { let node = opaqueCall('var', [ident('--x'), ', ', dim(1, 'px')]); const depth = Math.max(2, Math.ceil(size / 4)); @@ -73,8 +73,8 @@ function nestedOpaqueFallbacks(size) { } /** - * @param {(node: import('../src/lib/node.js').Node, opts: {precision: false}) => string} serialize - * @param {import('../src/lib/node.js').Node} node + * @param {(node: import('../../src/lib/node.js').Node, opts: {precision: false}) => string} serialize + * @param {import('../../src/lib/node.js').Node} node * @param {number} repetitions * @param {boolean} materialize * @return {number} elapsed milliseconds @@ -98,9 +98,9 @@ function clampRepetitions(repetitions) { * serializers use the same repetition count for each sample, so their times * are exposed to the same short-lived runtime effects. * - * @param {(node: import('../src/lib/node.js').Node, opts: {precision: false}) => string} worktreeSerializer - * @param {(node: import('../src/lib/node.js').Node, opts: {precision: false}) => string} headSerializer - * @param {import('../src/lib/node.js').Node} node + * @param {(node: import('../../src/lib/node.js').Node, opts: {precision: false}) => string} worktreeSerializer + * @param {(node: import('../../src/lib/node.js').Node, opts: {precision: false}) => string} headSerializer + * @param {import('../../src/lib/node.js').Node} node * @param {boolean} materialize * @return {{worktree: number, head: number}} */ @@ -141,7 +141,7 @@ function benchmarkPair(worktreeSerializer, headSerializer, node, materialize) { return { worktree: median(worktreeSamples), head: median(headSamples) }; } -/** @return {Promise} */ +/** @return {Promise} */ async function loadHeadSerializer() { const root = mkdtempSync(join(tmpdir(), 'postcss-calc-serialize-')); try { diff --git a/scripts/benchmark/benchmark.js b/scripts/benchmark/benchmark.js new file mode 100644 index 0000000..5b1bb5b --- /dev/null +++ b/scripts/benchmark/benchmark.js @@ -0,0 +1,63 @@ +export { + TARGET_BATCH_MS, + MIN_WARMUPS, + MAX_WARMUPS, + MEASURED_BATCHES, + DRIFT_THRESHOLD, + BOOTSTRAP_RESAMPLES, + NON_REGRESSION_MARGIN, + CORPUS_EQUIVALENCE_MARGIN, + GROWTH_THRESHOLD, + MIN_VALID_BLOCKS, + DECISION_CONFIG_VERSION, + PRECISION_METHOD, + DECISION_INTERVAL_METHOD, + CORPUS_INTERVAL_METHOD, + DECISION_CONFIG_KEYS, + validateDecisionConfig, + migrateLegacyDecisionConfig, + decisionConfigForArtifact, +} from './config.js'; + +export { + normalizeSeed, + seededRandom, + seededShuffle, + balancedOrder, + balancedSchedule, + bootstrapIndices, +} from './random.js'; + +export { + median, + percentile, + variationMetrics, + geometricMean, + logRatio, + ordinaryInterval, + oneSidedInterval, + pairedRatioSummary, + linearRegression, + normalQuantile, +} from './statistics.js'; + +export { + bootstrapMeanInterval, + bootstrapRatioInterval, + bootstrapPairedIntervals, + bootstrapStratifiedMaxT, + bootstrapCorpusInterval, + bootstrapPairedReplicateInterval, +} from './bootstrap.js'; + +export { + sha256File, + sourceTreeHash, + benchmarkHarnessHash, + collectBenchmarkProvenance, + collectEnvironment, + materializeBaseline, + runChild, +} from './provenance.js'; + +export { validateSchemaV2Artifact } from './validate.js'; diff --git a/scripts/benchmark/bootstrap.js b/scripts/benchmark/bootstrap.js new file mode 100644 index 0000000..5a5901a --- /dev/null +++ b/scripts/benchmark/bootstrap.js @@ -0,0 +1,383 @@ +/* oxlint-disable complexity */ +import { BOOTSTRAP_RESAMPLES, DECISION_INTERVAL_METHOD } from './config.js'; +import { seededRandom } from './random.js'; +import { finiteValues, percentile } from './statistics.js'; + +/** @param {number[]} values @param {number} seed @param {number} [resamples] */ +export function bootstrapMeanInterval( + values, + seed, + resamples = BOOTSTRAP_RESAMPLES, + confidence = 0.95 +) { + const source = finiteValues(values); + if (!Number.isInteger(resamples) || resamples <= 0) + throw new RangeError('resamples must be positive'); + if (!(confidence > 0 && confidence < 1)) + throw new RangeError('confidence must be in (0, 1)'); + const random = seededRandom(seed); + const means = Array.from({ length: resamples }); + for (let sample = 0; sample < resamples; sample++) { + let sum = 0; + for (let i = 0; i < source.length; i++) + sum += source[Math.floor(random() * source.length)]; + means[sample] = sum / source.length; + } + return { + lower: percentile(means, (1 - confidence) / 2), + upper: percentile(means, 1 - (1 - confidence) / 2), + resamples, + }; +} + +/** @param {number[]} logValues @param {number} seed @param {number} [resamples] */ +export function bootstrapRatioInterval( + logValues, + seed, + resamples = BOOTSTRAP_RESAMPLES, + confidence = 0.95 +) { + const interval = bootstrapMeanInterval( + logValues, + seed, + resamples, + confidence + ); + return { + ...interval, + lowerRatio: Math.exp(interval.lower), + upperRatio: Math.exp(interval.upper), + }; +} + +/** + * Bootstrap columns from the same row schedule. A row is one complete + * benchmark block, so all endpoints in a resample retain their correlation. + * The returned intervals are in the input (usually log-ratio) domain. + * + * @param {number[][]} rows + * @param {number} seed + * @param {number} [familyCount] + * @param {number} [resamples] + */ +export function bootstrapPairedIntervals( + rows, + seed, + familyCount = rows[0]?.length ?? 1, + resamples = BOOTSTRAP_RESAMPLES +) { + if (!Array.isArray(rows) || rows.length === 0) + throw new RangeError('cannot bootstrap an empty matrix'); + if (!Number.isInteger(resamples) || resamples <= 0) + throw new RangeError('resamples must be positive'); + const width = rows[0]?.length; + if (!Number.isInteger(width) || width <= 0) + throw new RangeError('cannot bootstrap a matrix without columns'); + if (familyCount !== width) + throw new RangeError('familyCount must equal the matrix width'); + for (const row of rows) { + if (!Array.isArray(row) || row.length !== width) { + throw new TypeError('bootstrap rows must have equal widths'); + } + finiteValues(row); + } + + const observed = Array(width).fill(0); + for (const row of rows) + for (let column = 0; column < width; column++) + observed[column] += row[column]; + for (let column = 0; column < width; column++) + observed[column] /= rows.length; + + const standardErrors = Array.from({ length: width }, (_, column) => { + const variance = + rows.reduce( + (sum, row) => sum + (row[column] - observed[column]) ** 2, + 0 + ) / Math.max(1, rows.length - 1); + return Math.sqrt(variance / rows.length); + }); + const distributions = Array.from({ length: width }, () => Array(resamples)); + const studentizedDeviations = Array(resamples); + const random = seededRandom(seed); + for (let sample = 0; sample < resamples; sample++) { + const sums = Array(width).fill(0); + for (let i = 0; i < rows.length; i++) { + const row = rows[Math.floor(random() * rows.length)]; + for (let column = 0; column < width; column++) + sums[column] += row[column]; + } + let maxDeviation = 0; + for (let column = 0; column < width; column++) { + const mean = sums[column] / rows.length; + distributions[column][sample] = mean; + const standardError = standardErrors[column]; + let deviation; + if (standardError === 0) + deviation = mean === observed[column] ? 0 : Infinity; + else deviation = Math.abs((mean - observed[column]) / standardError); + maxDeviation = Math.max(maxDeviation, deviation); + } + studentizedDeviations[sample] = maxDeviation; + } + + const familyCritical = percentile(studentizedDeviations, 0.95); + return { + familyCount, + resamples, + method: 'max-t-studentized-bootstrap', + standardErrors, + observed, + intervals: distributions.map((values, column) => ({ + lower: percentile(values, 0.025), + upper: percentile(values, 0.975), + oneSidedLower: percentile(values, 0.05), + oneSidedUpper: percentile(values, 0.95), + familyLower: observed[column] - familyCritical * standardErrors[column], + familyUpper: observed[column] + familyCritical * standardErrors[column], + familyCritical, + })), + }; +} + +/** + * Studentized max-T bootstrap for a two-stratum estimator. Each row is an + * independent experimental unit and is sampled as a complete row, which + * preserves correlation between endpoint columns. + * + * The observed estimator gives equal weight to the two strata. The bootstrap + * standard error is recomputed for every resample. If a resample has zero + * variance but its estimate differs from the observed estimate, its statistic + * uses the observed standard error for that endpoint. + * + * @param {{rows: number[][], strata: string[], seed: number, resamples?: number, confidence?: number}} options + */ +export function bootstrapStratifiedMaxT({ + rows, + strata, + seed, + resamples = BOOTSTRAP_RESAMPLES, + confidence = 0.95, +}) { + if (!Array.isArray(rows) || rows.length === 0) + throw new RangeError('cannot bootstrap an empty matrix'); + if (!Array.isArray(strata) || strata.length !== rows.length) + throw new RangeError('strata must match bootstrap rows'); + if (!Number.isInteger(resamples) || resamples <= 0) + throw new RangeError('resamples must be positive'); + if (!(confidence > 0 && confidence < 1)) + throw new RangeError('confidence must be in (0, 1)'); + const width = rows[0]?.length; + if (!Number.isInteger(width) || width <= 0) + throw new RangeError('cannot bootstrap a matrix without columns'); + for (const row of rows) { + if (!Array.isArray(row) || row.length !== width) + throw new TypeError('bootstrap rows must have equal widths'); + finiteValues(row); + } + const labels = [...new Set(strata)]; + if (labels.length !== 2) + throw new RangeError('stratified max-T requires exactly two strata'); + const ordered = labels.sort(); + const strataRows = ordered.map((label) => + rows + .map((row, index) => ({ row, label: strata[index] })) + .filter((item) => item.label === label) + .map((item) => item.row) + .sort(compareRows) + ); + if (strataRows.some((group) => group.length === 0)) + throw new RangeError('cannot resample an empty stratum'); + + const observedByStratum = strataRows.map((group) => + columnMeans(group, width) + ); + const observed = Array.from( + { length: width }, + (_, column) => + observedByStratum.reduce((sum, means) => sum + means[column], 0) / + observedByStratum.length + ); + const observedSE = standardErrorsForSample( + observedByStratum, + strataRows, + width + ); + const distributions = Array.from( + { length: width }, + () => new Float64Array(resamples) + ); + const studentizedMax = new Float64Array(resamples); + const strataCount = strataRows.length; + const sampledIndices = strataRows.map( + (group) => new Int32Array(group.length) + ); + const sampledMeans = strataRows.map(() => new Float64Array(width)); + let degenerateResamples = 0; + let degenerateFallbacks = 0; + const random = seededRandom(seed); + + for (let sample = 0; sample < resamples; sample++) { + for (let stratum = 0; stratum < strataCount; stratum++) { + const group = strataRows[stratum]; + const indices = sampledIndices[stratum]; + const means = sampledMeans[stratum]; + means.fill(0); + for (let i = 0; i < group.length; i++) { + const index = Math.floor(random() * group.length); + indices[i] = index; + const row = group[index]; + for (let column = 0; column < width; column++) + means[column] += row[column]; + } + for (let column = 0; column < width; column++) + means[column] /= group.length; + } + let maxT = 0; + let hasDegenerateEndpoint = false; + for (let column = 0; column < width; column++) { + let effect = 0; + let variance = 0; + for (let stratum = 0; stratum < strataCount; stratum++) { + const group = strataRows[stratum]; + const indices = sampledIndices[stratum]; + const mean = sampledMeans[stratum][column]; + effect += mean; + let within = 0; + for (let i = 0; i < indices.length; i++) + within += (group[indices[i]][column] - mean) ** 2; + variance += within / Math.max(1, group.length - 1) / group.length; + } + effect /= strataCount; + const sampledSE = Math.sqrt(variance / strataCount ** 2); + distributions[column][sample] = effect; + let statistic; + if (sampledSE === 0) { + hasDegenerateEndpoint = true; + const deviation = effect - observed[column]; + if (deviation === 0) statistic = 0; + else { + if (observedSE[column] === 0) + throw new RangeError( + 'nonzero bootstrap deviation has no positive standard error' + ); + statistic = deviation / observedSE[column]; + degenerateFallbacks++; + } + } else { + statistic = (effect - observed[column]) / sampledSE; + } + maxT = Math.max(maxT, Math.abs(statistic)); + } + if (hasDegenerateEndpoint) degenerateResamples++; + studentizedMax[sample] = maxT; + } + + const familyCritical = percentile(studentizedMax, confidence); + return { + method: DECISION_INTERVAL_METHOD, + strata: ordered, + familyCount: width, + resamples, + confidence, + observed, + standardErrors: observedSE, + familyCritical, + degenerateResamples, + degenerateFallbacks, + intervals: distributions.map((values, column) => { + const alpha = (1 - confidence) / 2; + const halfWidth = + observedSE[column] === 0 ? 0 : familyCritical * observedSE[column]; + return { + lower: percentile(values, alpha), + upper: percentile(values, 1 - alpha), + oneSidedLower: observed[column] - halfWidth, + oneSidedUpper: observed[column] + halfWidth, + familyLower: observed[column] - halfWidth, + familyUpper: observed[column] + halfWidth, + familyCritical, + }; + }), + }; +} + +/** + * Bootstrap the mean of paired log-ratios, resampling whole replicates. + * + * @param {number[][]} pairs [first-order, second-order] log-ratios per replicate + * @param {number} seed + * @param {number} resamples + * @param {number} confidence + */ +export function bootstrapPairedReplicateInterval( + pairs, + seed, + resamples, + confidence +) { + if (!Array.isArray(pairs) || pairs.length === 0) + throw new RangeError('cannot bootstrap an empty replicate set'); + if (pairs.some((pair) => !Array.isArray(pair) || pair.length !== 2)) + throw new TypeError( + 'each corpus replicate must contain both order results' + ); + const random = seededRandom(seed); + const means = new Float64Array(resamples); + for (let sample = 0; sample < resamples; sample++) { + for (let index = 0; index < pairs.length; index++) { + const pair = pairs[Math.floor(random() * pairs.length)]; + means[sample] += (pair[0] + pair[1]) / (2 * pairs.length); + } + } + const alpha = (1 - confidence) / 2; + return { + lower: percentile(means, alpha), + upper: percentile(means, 1 - alpha), + resamples, + }; +} + +/** + * Same as {@link bootstrapPairedReplicateInterval} for raw runtime ratios; + * the logarithms are computed once rather than once per resample. + * + * @param {number[][]} strata + */ +export function bootstrapCorpusInterval(strata, seed, resamples, confidence) { + const pairs = Array.isArray(strata) + ? strata.map((pair) => + Array.isArray(pair) && pair.length === 2 ? pair.map(Math.log) : pair + ) + : strata; + return bootstrapPairedReplicateInterval(pairs, seed, resamples, confidence); +} + +function compareRows(left, right) { + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function columnMeans(rows, width) { + const means = Array(width).fill(0); + for (const row of rows) + for (let column = 0; column < width; column++) means[column] += row[column]; + return means.map((sum) => sum / rows.length); +} + +function standardErrorsForSample(stratumMeans, sampledRows, width) { + return Array.from({ length: width }, (_, column) => { + let variance = 0; + for (let stratum = 0; stratum < sampledRows.length; stratum++) { + const rows = sampledRows[stratum]; + const mean = stratumMeans[stratum][column]; + const within = + rows.reduce((sum, row) => sum + (row[column] - mean) ** 2, 0) / + Math.max(1, rows.length - 1); + variance += within / rows.length; + } + return Math.sqrt(variance / stratumMeans.length ** 2); + }); +} diff --git a/scripts/compare-parser-benchmarks.js b/scripts/benchmark/compare-parser-benchmarks.js similarity index 89% rename from scripts/compare-parser-benchmarks.js rename to scripts/benchmark/compare-parser-benchmarks.js index b203e75..a4b758c 100644 --- a/scripts/compare-parser-benchmarks.js +++ b/scripts/benchmark/compare-parser-benchmarks.js @@ -2,12 +2,12 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; -import { analyzeParser } from './lib/parser-benchmark.js'; -import { analyzeCorpus } from './lib/corpus-benchmark.js'; -import { validateSchemaV2Artifact } from './lib/benchmark.js'; +import { analyzeParser } from './parser-benchmark.js'; +import { analyzeCorpus } from './corpus-benchmark.js'; +import { validateSchemaV2Artifact } from './benchmark.js'; const usage = - 'Usage: node scripts/compare-parser-benchmarks.js '; + 'Usage: node scripts/benchmark/compare-parser-benchmarks.js '; function readArtifact(path) { const artifact = JSON.parse(readFileSync(path, 'utf8')); diff --git a/scripts/benchmark/config.js b/scripts/benchmark/config.js new file mode 100644 index 0000000..1fb91a2 --- /dev/null +++ b/scripts/benchmark/config.js @@ -0,0 +1,174 @@ +/* oxlint-disable complexity */ +export const TARGET_BATCH_MS = 25; +export const MIN_WARMUPS = 5; +export const MAX_WARMUPS = 10; +export const MEASURED_BATCHES = 6; +export const DRIFT_THRESHOLD = 0.15; +export const BOOTSTRAP_RESAMPLES = 100_000; +export const NON_REGRESSION_MARGIN = 1.1; +export const CORPUS_EQUIVALENCE_MARGIN = 1.1; +export const GROWTH_THRESHOLD = 2.5; +export const MIN_VALID_BLOCKS = 20; +export const DECISION_CONFIG_VERSION = 3; +export const PRECISION_METHOD = 'family-adjusted-interval-width'; +export const DECISION_INTERVAL_METHOD = + 'stratified-max-t-studentized-bootstrap'; +export const CORPUS_INTERVAL_METHOD = + 'paired-replicate-order-log-ratio-bootstrap'; + +// These are the fields that determine the interpretation of a schema-v2 +// artifact. Keep this list here rather than duplicating it in the parser and +// corpus analyzers: a reanalysis must have one authoritative contract. +export const DECISION_CONFIG_KEYS = [ + 'decisionConfigVersion', + 'requestedBlocks', + 'minimumBlocks', + 'maxAttempts', + 'targetBatchMs', + 'warmupMinimum', + 'warmupMaximum', + 'measuredBatchCount', + 'driftThreshold', + 'bootstrapResamples', + 'confidence', + 'runtimeNonRegressionMargin', + 'equivalenceMargin', + 'precisionMargin', + 'growthThreshold', + 'orderInteractionThreshold', + 'precisionMethod', + 'intervalMethod', +]; + +/** @param {object} config @param {string} kind */ +export function validateDecisionConfig(config, kind = 'artifact') { + if (!config || typeof config !== 'object') + throw new TypeError(`${kind} is missing decision configuration`); + for (const key of DECISION_CONFIG_KEYS) + if (!Object.hasOwn(config, key)) + throw new TypeError(`${kind} is missing decision parameter ${key}`); + if (config.decisionConfigVersion !== DECISION_CONFIG_VERSION) + throw new TypeError( + `${kind} has an invalid decision configuration version` + ); + for (const key of [ + 'requestedBlocks', + 'minimumBlocks', + 'maxAttempts', + 'measuredBatchCount', + 'bootstrapResamples', + ]) + if (!Number.isInteger(config[key]) || config[key] <= 0) + throw new TypeError(`${kind} has invalid decision parameter ${key}`); + if (config.requestedBlocks < config.minimumBlocks) + throw new TypeError(`${kind} has an invalid requested block count`); + if (config.maxAttempts < config.requestedBlocks) + throw new TypeError(`${kind} has an invalid maxAttempts`); + for (const key of ['warmupMinimum', 'warmupMaximum']) + if (!Number.isInteger(config[key]) || config[key] < 0) + throw new TypeError(`${kind} has invalid decision parameter ${key}`); + if (config.warmupMaximum < config.warmupMinimum) + throw new TypeError(`${kind} has an invalid warm-up range`); + for (const key of [ + 'targetBatchMs', + 'driftThreshold', + 'bootstrapResamples', + 'runtimeNonRegressionMargin', + 'equivalenceMargin', + 'precisionMargin', + 'growthThreshold', + 'orderInteractionThreshold', + ]) + if ( + typeof config[key] !== 'number' || + !Number.isFinite(config[key]) || + config[key] <= 0 + ) + throw new TypeError(`${kind} has invalid decision parameter ${key}`); + if ( + typeof config.confidence !== 'number' || + !Number.isFinite(config.confidence) || + config.confidence <= 0 || + config.confidence >= 1 + ) + throw new TypeError(`${kind} has invalid decision parameter confidence`); + if (config.runtimeNonRegressionMargin < 1 || config.equivalenceMargin < 1) + throw new TypeError(`${kind} has an invalid ratio margin`); + if (config.precisionMargin < 1 || config.growthThreshold <= 1) + throw new TypeError(`${kind} has an invalid precision or growth margin`); + if ( + ![DECISION_INTERVAL_METHOD, CORPUS_INTERVAL_METHOD].includes( + config.intervalMethod + ) + ) + throw new TypeError(`${kind} has an invalid interval method`); + if (config.precisionMethod !== PRECISION_METHOD) + throw new TypeError(`${kind} has an invalid precision method`); + return config; +} + +/** + * Migrate the pre-contract artifacts that were emitted by the first schema-v2 + * implementation. This is intentionally the only place where repository + * defaults are applied. New artifacts must carry decisionConfigVersion: 3. + */ +export function migrateLegacyDecisionConfig( + artifact, + kind = artifact?.benchmark === 'corpus' ? 'corpus' : 'parser' +) { + const source = artifact?.config ?? {}; + const blocks = artifact?.blocks?.length ?? artifact?.replicates?.length ?? 0; + const requestedBlocks = + source.requestedBlocks ?? source.blocks ?? source.replicates ?? blocks; + const base = { + decisionConfigVersion: DECISION_CONFIG_VERSION, + requestedBlocks, + minimumBlocks: source.minimumBlocks ?? MIN_VALID_BLOCKS, + maxAttempts: source.maxAttempts ?? Math.max(30, requestedBlocks), + targetBatchMs: source.targetBatchMs ?? TARGET_BATCH_MS, + warmupMinimum: + source.warmupMinimum ?? (kind === 'corpus' ? 0 : MIN_WARMUPS), + warmupMaximum: + source.warmupMaximum ?? (kind === 'corpus' ? 0 : MAX_WARMUPS), + measuredBatchCount: + source.measuredBatchCount ?? source.batches ?? MEASURED_BATCHES, + driftThreshold: source.driftThreshold ?? DRIFT_THRESHOLD, + bootstrapResamples: source.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, + confidence: source.confidence ?? 0.95, + runtimeNonRegressionMargin: + source.runtimeNonRegressionMargin ?? NON_REGRESSION_MARGIN, + equivalenceMargin: + source.equivalenceMargin ?? + (kind === 'corpus' ? CORPUS_EQUIVALENCE_MARGIN : 1.1), + precisionMargin: source.precisionMargin ?? 1.1, + precisionMethod: PRECISION_METHOD, + growthThreshold: source.growthThreshold ?? GROWTH_THRESHOLD, + orderInteractionThreshold: + source.orderInteractionThreshold ?? Math.log(1.1), + intervalMethod: + source.intervalMethod ?? + (kind === 'corpus' ? CORPUS_INTERVAL_METHOD : DECISION_INTERVAL_METHOD), + }; + if (kind === 'corpus') + return { + ...base, + replicates: source.replicates ?? blocks, + batches: source.batches ?? 6, + calibrationOrderBalanced: source.calibrationOrderBalanced ?? false, + }; + return base; +} + +/** @param {object} artifact @param {string} kind @return {object} */ +export function decisionConfigForArtifact(artifact, kind) { + if (artifact?.config?.decisionConfigVersion === DECISION_CONFIG_VERSION) + return validateDecisionConfig(artifact.config, `${kind} artifact`); + if (artifact?.config?.decisionConfigVersion !== undefined) + throw new TypeError( + `${kind} artifact has an invalid decision configuration version` + ); + return validateDecisionConfig( + migrateLegacyDecisionConfig(artifact, kind), + `${kind} legacy artifact` + ); +} diff --git a/scripts/lib/corpus-benchmark.js b/scripts/benchmark/corpus-analysis.js similarity index 58% rename from scripts/lib/corpus-benchmark.js rename to scripts/benchmark/corpus-analysis.js index 55685a4..16eac30 100644 --- a/scripts/lib/corpus-benchmark.js +++ b/scripts/benchmark/corpus-analysis.js @@ -1,67 +1,17 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { loadCorpus } from './corpus.js'; -/* oxlint-disable no-bitwise */ -import { rootShape, stableHash, validateCorpus } from './corpus-policy.js'; +/* oxlint-disable no-bitwise, complexity */ +import { stableHash } from '../lib/corpus-policy.js'; import { BOOTSTRAP_RESAMPLES, - CORPUS_EQUIVALENCE_MARGIN, + bootstrapPairedReplicateInterval, + CORPUS_INTERVAL_METHOD, DECISION_CONFIG_VERSION, - DRIFT_THRESHOLD, - MEASURED_BATCHES, - MIN_VALID_BLOCKS, - PRECISION_METHOD, - TARGET_BATCH_MS, - balancedSchedule, - collectBenchmarkProvenance, decisionConfigForArtifact, - CORPUS_INTERVAL_METHOD, median, - normalizeSeed, ordinaryInterval, - percentile, - runChild, - seededRandom, - seededShuffle, - variationMetrics, validateSchemaV2Artifact, + variationMetrics, } from './benchmark.js'; -export { CORPUS_EQUIVALENCE_MARGIN } from './benchmark.js'; - -export const CORPUS_ESTIMAND = - 'Relative total runtime over the fixed set of unique harvested expressions accepted equivalently by both implementations.'; - -const WORKER = join( - dirname(dirname(fileURLToPath(import.meta.url))), - 'corpus-benchmark-worker.js' -); - -function parseArgs(args) { - const options = {}; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--seed') options.seed = Number(args[++i]); - else if (arg === '--replicates') options.replicates = Number(args[++i]); - else if (arg === '--output') options.output = args[++i]; - else throw new TypeError(`invalid option: ${arg}`); - } - if ( - options.seed !== undefined && - (!Number.isInteger(options.seed) || - options.seed < 0 || - options.seed > 0xffffffff) - ) - throw new TypeError('invalid --seed'); - if ( - options.replicates !== undefined && - (!Number.isInteger(options.replicates) || options.replicates < 20) - ) - throw new TypeError('--replicates must be at least 20'); - return options; -} - export function groupResults( replicates, seed = 0, @@ -203,224 +153,7 @@ export function groupResults( return result; } -function bootstrapPairedReplicateInterval(pairs, seed, resamples, confidence) { - if (!Array.isArray(pairs) || pairs.length === 0) - throw new RangeError('cannot bootstrap an empty replicate set'); - if (pairs.some((pair) => !Array.isArray(pair) || pair.length !== 2)) - throw new TypeError( - 'each corpus replicate must contain both order results' - ); - const random = seededRandom(seed); - const means = Array.from({ length: resamples }, () => 0); - for (let sample = 0; sample < resamples; sample++) { - for (let index = 0; index < pairs.length; index++) { - const pair = pairs[Math.floor(random() * pairs.length)]; - means[sample] += (pair[0] + pair[1]) / (2 * pairs.length); - } - } - const alpha = (1 - confidence) / 2; - const lower = percentile(means, alpha); - const upper = percentile(means, 1 - alpha); - return { lower, upper, resamples }; -} - -export function runCorpusBenchmark({ - root = process.cwd(), - seed = 0x71c0ffee, - replicates = 20, - output, -} = {}) { - const normalizedSeed = normalizeSeed(seed); - const source = loadCorpus(); - const validation = validateCorpus(source); - const corpusPath = resolve( - dirname(fileURLToPath(import.meta.url)), - '..', - '..', - 'test/corpus/github-pure.txt' - ); - const config = { - decisionConfigVersion: DECISION_CONFIG_VERSION, - requestedBlocks: replicates, - minimumBlocks: MIN_VALID_BLOCKS, - maxAttempts: replicates, - targetBatchMs: TARGET_BATCH_MS, - warmupMinimum: 0, - warmupMaximum: 0, - measuredBatchCount: MEASURED_BATCHES, - driftThreshold: DRIFT_THRESHOLD, - bootstrapResamples: BOOTSTRAP_RESAMPLES, - confidence: 0.95, - runtimeNonRegressionMargin: 1.1, - equivalenceMargin: CORPUS_EQUIVALENCE_MARGIN, - precisionMargin: CORPUS_EQUIVALENCE_MARGIN, - precisionMethod: PRECISION_METHOD, - growthThreshold: 2.5, - orderInteractionThreshold: Math.log(1.1), - intervalMethod: CORPUS_INTERVAL_METHOD, - batches: 6, - calibrationOrderBalanced: true, - corpus: 'test/corpus/github-pure.txt', - precision: 10, - }; - const environment = collectBenchmarkProvenance(root, { - benchmark: 'corpus', - corpusPath, - command: process.argv.join(' '), - }); - let entries = validation.accepted.map((record) => ({ - input: record.input, - canonical: record.canonical, - shape: rootShape(record.input), - sourceLength: record.input.length, - })); - if (!entries.length) throw new Error('corpus validation accepted no inputs'); - const lengthOrder = [...entries].sort( - (a, b) => a.sourceLength - b.sourceLength || a.input.localeCompare(b.input) - ); - const lengthStratumByInput = new Map( - lengthOrder.map((entry, index) => [ - entry.input, - `source-length-q${Math.min(4, Math.floor((index * 4) / entries.length) + 1)}`, - ]) - ); - entries = entries.map((entry) => ({ - ...entry, - lengthStratum: lengthStratumByInput.get(entry.input), - })); - const lengthQuartiles = [0.25, 0.5, 0.75].map((p) => - percentile( - entries.map((entry) => entry.sourceLength), - p - ) - ); - const temp = mkdtempSync(join(root, '.corpus-benchmark-')); - const corpusFile = join(temp, 'validated.json'); - writeFileSync(corpusFile, JSON.stringify(entries)); - try { - const childResults = []; - const calibrationSchedule = balancedSchedule( - replicates, - 'ours-first', - 'reference-first', - normalizedSeed ^ 0x243f6a88 - ); - for (let replicate = 0; replicate < replicates; replicate++) { - const permutation = seededShuffle( - entries.map((_, index) => index), - (normalizedSeed + Math.imul(replicate + 1, 0x9e3779b9)) >>> 0 - ); - const orders = seededShuffle( - [ - 'ours-first', - 'ours-first', - 'ours-first', - 'reference-first', - 'reference-first', - 'reference-first', - ], - (normalizedSeed ^ replicate) >>> 0 - ); - const calibrationOrder = calibrationSchedule[replicate]; - childResults.push( - runChild( - WORKER, - { - sourceRoot: join(root, 'src'), - corpusFile, - permutation, - orders, - calibrationOrder, - targetBatchMs: config.targetBatchMs, - replicate, - lengthQuartiles, - }, - root - ) - ); - } - const groups = groupResults( - childResults, - normalizedSeed, - config.bootstrapResamples - ); - const exact = groups.exact; - const analysis = analyzeCorpusObservations({ - groups, - replicates: childResults, - config, - seed: normalizedSeed, - }); - const artifact = { - schema: 2, - benchmark: 'corpus', - seed: normalizedSeed, - environment, - config: { ...config, replicates }, - correctness: { - counts: validation.counts, - categoryHashes: validation.categoryHashes, - accepted: entries.length, - totalSourceRecords: source.length, - uniqueRecords: new Set(source).size, - acceptedComparisonRecords: entries.length, - excludedCounts: Object.fromEntries( - Object.entries(validation.counts).filter( - ([key]) => key !== 'accepted' - ) - ), - weighting: 'unique-weighted', - estimand: CORPUS_ESTIMAND, - corpusHash: stableHash(source.join('\n')), - inputHash: stableHash( - entries - .map((entry) => entry.input) - .sort() - .join('\n') - ), - }, - corpus: { - lengthQuartiles, - lengthStrata: Object.fromEntries( - [ - 'source-length-q1', - 'source-length-q2', - 'source-length-q3', - 'source-length-q4', - ].map((group) => [ - group, - entries.filter((entry) => entry.lengthStratum === group).length, - ]) - ), - rootShapeCounts: Object.fromEntries( - [...new Set(entries.map((entry) => entry.shape))] - .sort() - .map((shape) => [ - shape, - entries.filter((entry) => entry.shape === shape).length, - ]) - ), - }, - replicates: childResults, - analysis: { ...analysis, aggregate: exact, groups }, - }; - validateSchemaV2Artifact(artifact); - const path = output - ? resolve(root, output) - : join( - root, - 'reports/benchmarks', - `corpus-${Date.now()}-${normalizedSeed}.json` - ); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`); - return { artifact, path }; - } finally { - rmSync(temp, { recursive: true, force: true }); - } -} - -function corpusGroupDecision(summary, config) { +export function corpusGroupDecision(summary, config) { const logs = summary.ratios.map(Math.log); const sd = variationMetrics(logs).sd; const intervalLower = Math.log(summary.bootstrap95.lowerRatio); @@ -462,7 +195,12 @@ function corpusGroupDecision(summary, config) { }; } -function analyzeCorpusObservations({ groups, replicates, config, seed }) { +export function analyzeCorpusObservations({ + groups, + replicates, + config, + seed, +}) { const decisions = Object.fromEntries( Object.entries(groups).map(([group, summary]) => [ group, @@ -603,8 +341,6 @@ function assertStoredValue(left, right, path) { } } -export { parseArgs }; - function withinProcessVariation(values) { const normalized = []; const byReplicate = new Map(); diff --git a/scripts/corpus-benchmark-worker.js b/scripts/benchmark/corpus-benchmark-worker.js similarity index 98% rename from scripts/corpus-benchmark-worker.js rename to scripts/benchmark/corpus-benchmark-worker.js index aadb06f..b5cc075 100644 --- a/scripts/corpus-benchmark-worker.js +++ b/scripts/benchmark/corpus-benchmark-worker.js @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { calc as referenceCalc } from '@csstools/css-calc'; -import { ourOutput as canonicalizeOutput } from './lib/corpus-policy.js'; +import { ourOutput as canonicalizeOutput } from '../lib/corpus-policy.js'; /* oxlint-disable no-bitwise */ const payload = JSON.parse( diff --git a/scripts/benchmark/corpus-benchmark.js b/scripts/benchmark/corpus-benchmark.js new file mode 100644 index 0000000..dd897f8 --- /dev/null +++ b/scripts/benchmark/corpus-benchmark.js @@ -0,0 +1,7 @@ +export { CORPUS_EQUIVALENCE_MARGIN } from './config.js'; +export { groupResults, analyzeCorpus } from './corpus-analysis.js'; +export { + CORPUS_ESTIMAND, + parseArgs, + runCorpusBenchmark, +} from './corpus-runner.js'; diff --git a/scripts/benchmark/corpus-runner.js b/scripts/benchmark/corpus-runner.js new file mode 100644 index 0000000..5a773c7 --- /dev/null +++ b/scripts/benchmark/corpus-runner.js @@ -0,0 +1,252 @@ +/* oxlint-disable no-bitwise, complexity */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadCorpus } from '../lib/corpus.js'; +import { rootShape, stableHash, validateCorpus } from '../lib/corpus-policy.js'; +import { + BOOTSTRAP_RESAMPLES, + CORPUS_EQUIVALENCE_MARGIN, + CORPUS_INTERVAL_METHOD, + DECISION_CONFIG_VERSION, + DRIFT_THRESHOLD, + MEASURED_BATCHES, + MIN_VALID_BLOCKS, + PRECISION_METHOD, + TARGET_BATCH_MS, + balancedSchedule, + collectBenchmarkProvenance, + normalizeSeed, + percentile, + runChild, + seededShuffle, + validateSchemaV2Artifact, +} from './benchmark.js'; +import { analyzeCorpusObservations, groupResults } from './corpus-analysis.js'; + +export const CORPUS_ESTIMAND = + 'Relative total runtime over the fixed set of unique harvested expressions accepted equivalently by both implementations.'; + +const WORKER = fileURLToPath( + new URL('./corpus-benchmark-worker.js', import.meta.url) +); + +export function parseArgs(args) { + const options = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--seed') options.seed = Number(args[++i]); + else if (arg === '--replicates') options.replicates = Number(args[++i]); + else if (arg === '--output') options.output = args[++i]; + else throw new TypeError(`invalid option: ${arg}`); + } + if ( + options.seed !== undefined && + (!Number.isInteger(options.seed) || + options.seed < 0 || + options.seed > 0xffffffff) + ) + throw new TypeError('invalid --seed'); + if ( + options.replicates !== undefined && + (!Number.isInteger(options.replicates) || options.replicates < 20) + ) + throw new TypeError('--replicates must be at least 20'); + return options; +} + +export function runCorpusBenchmark({ + root = process.cwd(), + seed = 0x71c0ffee, + replicates = 20, + output, +} = {}) { + const normalizedSeed = normalizeSeed(seed); + const source = loadCorpus(); + const validation = validateCorpus(source); + const corpusPath = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'test/corpus/github-pure.txt' + ); + const config = { + decisionConfigVersion: DECISION_CONFIG_VERSION, + requestedBlocks: replicates, + minimumBlocks: MIN_VALID_BLOCKS, + maxAttempts: replicates, + targetBatchMs: TARGET_BATCH_MS, + warmupMinimum: 0, + warmupMaximum: 0, + measuredBatchCount: MEASURED_BATCHES, + driftThreshold: DRIFT_THRESHOLD, + bootstrapResamples: BOOTSTRAP_RESAMPLES, + confidence: 0.95, + runtimeNonRegressionMargin: 1.1, + equivalenceMargin: CORPUS_EQUIVALENCE_MARGIN, + precisionMargin: CORPUS_EQUIVALENCE_MARGIN, + precisionMethod: PRECISION_METHOD, + growthThreshold: 2.5, + orderInteractionThreshold: Math.log(1.1), + intervalMethod: CORPUS_INTERVAL_METHOD, + batches: 6, + calibrationOrderBalanced: true, + corpus: 'test/corpus/github-pure.txt', + precision: 10, + }; + const environment = collectBenchmarkProvenance(root, { + benchmark: 'corpus', + corpusPath, + command: process.argv.join(' '), + }); + let entries = validation.accepted.map((record) => ({ + input: record.input, + canonical: record.canonical, + shape: rootShape(record.input), + sourceLength: record.input.length, + })); + if (!entries.length) throw new Error('corpus validation accepted no inputs'); + const lengthOrder = [...entries].sort( + (a, b) => a.sourceLength - b.sourceLength || a.input.localeCompare(b.input) + ); + const lengthStratumByInput = new Map( + lengthOrder.map((entry, index) => [ + entry.input, + `source-length-q${Math.min(4, Math.floor((index * 4) / entries.length) + 1)}`, + ]) + ); + entries = entries.map((entry) => ({ + ...entry, + lengthStratum: lengthStratumByInput.get(entry.input), + })); + const lengthQuartiles = [0.25, 0.5, 0.75].map((p) => + percentile( + entries.map((entry) => entry.sourceLength), + p + ) + ); + const temp = mkdtempSync(join(root, '.corpus-benchmark-')); + const corpusFile = join(temp, 'validated.json'); + writeFileSync(corpusFile, JSON.stringify(entries)); + try { + const childResults = []; + const calibrationSchedule = balancedSchedule( + replicates, + 'ours-first', + 'reference-first', + normalizedSeed ^ 0x243f6a88 + ); + for (let replicate = 0; replicate < replicates; replicate++) { + const permutation = seededShuffle( + entries.map((_, index) => index), + (normalizedSeed + Math.imul(replicate + 1, 0x9e3779b9)) >>> 0 + ); + const orders = seededShuffle( + [ + 'ours-first', + 'ours-first', + 'ours-first', + 'reference-first', + 'reference-first', + 'reference-first', + ], + (normalizedSeed ^ replicate) >>> 0 + ); + const calibrationOrder = calibrationSchedule[replicate]; + childResults.push( + runChild( + WORKER, + { + sourceRoot: join(root, 'src'), + corpusFile, + permutation, + orders, + calibrationOrder, + targetBatchMs: config.targetBatchMs, + replicate, + lengthQuartiles, + }, + root + ) + ); + } + const groups = groupResults( + childResults, + normalizedSeed, + config.bootstrapResamples + ); + const exact = groups.exact; + const analysis = analyzeCorpusObservations({ + groups, + replicates: childResults, + config, + seed: normalizedSeed, + }); + const artifact = { + schema: 2, + benchmark: 'corpus', + seed: normalizedSeed, + environment, + config: { ...config, replicates }, + correctness: { + counts: validation.counts, + categoryHashes: validation.categoryHashes, + accepted: entries.length, + totalSourceRecords: source.length, + uniqueRecords: new Set(source).size, + acceptedComparisonRecords: entries.length, + excludedCounts: Object.fromEntries( + Object.entries(validation.counts).filter( + ([key]) => key !== 'accepted' + ) + ), + weighting: 'unique-weighted', + estimand: CORPUS_ESTIMAND, + corpusHash: stableHash(source.join('\n')), + inputHash: stableHash( + entries + .map((entry) => entry.input) + .sort() + .join('\n') + ), + }, + corpus: { + lengthQuartiles, + lengthStrata: Object.fromEntries( + [ + 'source-length-q1', + 'source-length-q2', + 'source-length-q3', + 'source-length-q4', + ].map((group) => [ + group, + entries.filter((entry) => entry.lengthStratum === group).length, + ]) + ), + rootShapeCounts: Object.fromEntries( + [...new Set(entries.map((entry) => entry.shape))] + .sort() + .map((shape) => [ + shape, + entries.filter((entry) => entry.shape === shape).length, + ]) + ), + }, + replicates: childResults, + analysis: { ...analysis, aggregate: exact, groups }, + }; + validateSchemaV2Artifact(artifact); + const path = output + ? resolve(root, output) + : join( + root, + 'reports/benchmarks', + `corpus-${Date.now()}-${normalizedSeed}.json` + ); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`); + return { artifact, path }; + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} diff --git a/scripts/benchmark/parser-analysis.js b/scripts/benchmark/parser-analysis.js new file mode 100644 index 0000000..172343e --- /dev/null +++ b/scripts/benchmark/parser-analysis.js @@ -0,0 +1,387 @@ +/* oxlint-disable no-bitwise, complexity */ +import { + bootstrapStratifiedMaxT, + decisionConfigForArtifact, + DECISION_INTERVAL_METHOD, + logRatio, + median, + MIN_VALID_BLOCKS, + ordinaryInterval, + variationMetrics, + validateSchemaV2Artifact, +} from './benchmark.js'; +import { + analyzeSlopes, + addSlopeIntervals, + analyzeGrowth, + addGrowthIntervals, + largestSizeKeys, + precisionSummary, +} from './parser-scaling.js'; + +function revisionResults(blocks, revision) { + const values = new Map(); + for (const block of blocks) { + const process = block.revisions.find((item) => item.revision === revision); + for (const workload of process.workloads) { + const list = values.get(workload.key) ?? []; + list.push(median(workload.measured)); + values.set(workload.key, list); + } + } + return values; +} + +export function analyzeParser( + artifact, + { skipValidation = false, sensitivity = true } = {} +) { + if (!skipValidation) validateSchemaV2Artifact(artifact); + const blocks = artifact.blocks; + const config = decisionConfigForArtifact(artifact, 'parser'); + if (artifact.analysis?.status === 'correctness-failure') + return artifact.analysis; + if (blocks.length < MIN_VALID_BLOCKS) + return { + status: 'inconclusive', + validBlocks: blocks.length, + reason: `fewer than ${MIN_VALID_BLOCKS} valid blocks`, + }; + const baseline = revisionResults(blocks, 'baseline'); + const candidate = revisionResults(blocks, 'candidate'); + const keys = artifact.workloadKeys; + const logsByKey = keys.map((key) => { + const base = baseline.get(key); + const cand = candidate.get(key); + if ( + !base || + !cand || + base.length !== blocks.length || + cand.length !== blocks.length + ) + throw new TypeError(`missing paired observations for ${key}`); + return cand.map((value, index) => logRatio(value, base[index])); + }); + const runtimeRows = blocks.map((_, index) => + logsByKey.map((values) => values[index]) + ); + const endpoints = []; + for (const [keyIndex, key] of keys.entries()) { + const logs = logsByKey[keyIndex]; + const ordinary = ordinaryInterval(logs); + const endpoint = { + key, + geometricMeanPairedRuntimeRatio: Math.exp(ordinary.mean), + logRatio: ordinary.mean, + ordinary95: { + lowerRatio: Math.exp(ordinary.lower), + upperRatio: Math.exp(ordinary.upper), + }, + oneSided95: { + lowerRatio: null, + upperRatio: null, + }, + baselineVariation: variationFor(blocks, 'baseline', key), + candidateVariation: variationFor(blocks, 'candidate', key), + betweenProcessVariation: { + pairedRatio: variationMetrics(logs.map(Math.exp)), + }, + withinProcessBatchVariation: batchVariation(blocks, key), + byProcessOrder: processOrderSummaries(blocks, logs, key), + meaningfulImprovement: null, + ratios: logs.map(Math.exp), + }; + const sd = variationMetrics(logs).sd; + endpoint.observedLogRatioSd = sd; + endpoints.push(endpoint); + } + + const largestKeys = largestSizeKeys(artifact.workloadKeys); + const largest = endpoints.filter((endpoint) => largestKeys.has(endpoint.key)); + const slopes = analyzeSlopes(artifact, blocks, config); + const growthData = analyzeGrowth(artifact, blocks); + const familyRows = blocks.map((_, index) => [ + ...runtimeRows[index], + ...slopes.claimRows.map((values) => values[index]), + ...growthData.claimRows.map((values) => values[index]), + ]); + const familyBootstrap = bootstrapStratifiedMaxT({ + rows: familyRows, + strata: blocks.map((block) => block.processOrder), + seed: (artifact.seed ^ 0x6a09e667) >>> 0, + resamples: config.bootstrapResamples, + confidence: config.confidence, + }); + for (const [index, endpoint] of endpoints.entries()) { + endpoint.logRatio = familyBootstrap.observed[index]; + endpoint.geometricMeanPairedRuntimeRatio = Math.exp(endpoint.logRatio); + const half = 1.96 * familyBootstrap.standardErrors[index]; + endpoint.ordinary95 = { + lowerRatio: Math.exp(endpoint.logRatio - half), + upperRatio: Math.exp(endpoint.logRatio + half), + }; + addRuntimeIntervals( + endpoint, + familyBootstrap.intervals[index], + familyBootstrap, + config, + blocks.length, + index + ); + } + addSlopeIntervals(slopes, familyBootstrap, keys.length, config); + const growth = addGrowthIntervals( + growthData, + familyBootstrap, + keys.length + slopes.claimRows.length, + blocks, + config + ); + const orderEffect = processOrderEffect( + endpoints, + config.orderInteractionThreshold + ); + const runtimeStatus = applyPrecision( + verdict(largest, config.runtimeNonRegressionMargin), + largest + ); + const slopeStatus = applyPrecision( + verdict( + slopes.endpoints, + Math.log2(config.runtimeNonRegressionMargin), + true + ), + slopes.endpoints + ); + let growthStatus = 'pass'; + if (growth.some((item) => item.candidateLowerRatio > config.growthThreshold)) + growthStatus = 'regression'; + else if ( + growth.some((item) => item.candidateUpperRatio > config.growthThreshold) + ) + growthStatus = 'inconclusive'; + growthStatus = applyPrecision(growthStatus, growth); + let status; + if ( + runtimeStatus === 'regression' || + slopeStatus === 'regression' || + growthStatus === 'regression' + ) + status = 'regression'; + else if ( + runtimeStatus === 'pass' && + slopeStatus === 'pass' && + growthStatus === 'pass' + ) + status = 'pass'; + else status = 'inconclusive'; + if (orderEffect.diagnostic) status = 'inconclusive'; + const result = { + status, + intervalMethod: DECISION_INTERVAL_METHOD, + runtimeStatus, + slopeStatus, + growthStatus, + endpoints, + slopes, + growth, + orderEffect, + rejections: rejectionSummary(artifact.attempts), + rejectionCounts: rejectionSummary(artifact.attempts).byReason, + rejectionRate: rejectionSummary(artifact.attempts).rate, + observedBlocks: blocks.length, + validBlocks: blocks.length, + }; + if (sensitivity) { + const structurallyValid = Array.isArray(artifact.attempts) + ? artifact.attempts.filter( + (attempt) => attempt.structuralMismatches.length === 0 + ) + : blocks; + const orderCounts = new Set( + structurallyValid.map((attempt) => attempt.processOrder) + ); + if ( + structurallyValid.length >= MIN_VALID_BLOCKS && + orderCounts.size === 2 + ) { + result.sensitivity = analyzeParser( + { ...artifact, blocks: structurallyValid, analysis: undefined }, + { skipValidation: true, sensitivity: false } + ); + } else { + result.sensitivity = { + status: 'inconclusive', + validBlocks: structurallyValid.length, + reason: `fewer than ${MIN_VALID_BLOCKS} structurally valid blocks`, + }; + } + result.diagnostics = { + ...result.diagnostics, + rejectedAttempts: Array.isArray(artifact.attempts) + ? artifact.attempts.length - blocks.length + : 0, + structurallyValidAttempts: structurallyValid.length, + primaryAndSensitivityDisagree: + result.status !== result.sensitivity.status, + }; + if (result.diagnostics.primaryAndSensitivityDisagree) + result.status = 'inconclusive'; + } + return result; +} + +function rejectionSummary(attempts) { + if (!Array.isArray(attempts)) + return { attempts: 0, accepted: 0, rejected: 0, byReason: {}, rate: 0 }; + const byReason = {}; + for (const attempt of attempts) + for (const reason of attempt.rejectionReasons ?? []) + byReason[reason] = (byReason[reason] ?? 0) + 1; + const rejected = attempts.filter((attempt) => attempt.rejected).length; + return { + attempts: attempts.length, + accepted: attempts.length - rejected, + rejected, + byReason, + rate: rejected / attempts.length, + }; +} + +function processOrderSummaries(blocks, logs, key) { + const summaries = {}; + for (const order of ['baseline-first', 'candidate-first']) { + const values = logs.filter( + (_, index) => blocks[index].processOrder === order + ); + const interval = ordinaryInterval(values); + summaries[order] = { + replicates: values.length, + key, + geometricMeanPairedRuntimeRatio: Math.exp(interval.mean), + ordinary95: { + lowerRatio: Math.exp(interval.lower), + upperRatio: Math.exp(interval.upper), + }, + ratios: values.map(Math.exp), + }; + } + return summaries; +} + +function processOrderEffect(endpoints, threshold) { + const effects = endpoints.map((endpoint) => { + const baseline = endpoint.byProcessOrder['baseline-first'].ratios; + const candidate = endpoint.byProcessOrder['candidate-first'].ratios; + const baselineMean = ordinaryInterval(baseline.map(Math.log)); + const candidateMean = ordinaryInterval(candidate.map(Math.log)); + const orderLogRatio = candidateMean.mean - baselineMean.mean; + const standardError = Math.sqrt( + baselineMean.sd ** 2 / baseline.length + + candidateMean.sd ** 2 / candidate.length + ); + const half = 1.96 * standardError; + return { + key: endpoint.key, + logRatio: orderLogRatio, + ratio: Math.exp(orderLogRatio), + standardError, + ordinary95: { + lowerRatio: Math.exp(orderLogRatio - half), + upperRatio: Math.exp(orderLogRatio + half), + }, + }; + }); + return { + threshold, + endpoints: effects, + diagnostic: effects.some((effect) => Math.abs(effect.logRatio) > threshold), + }; +} + +function addRuntimeIntervals( + endpoint, + intervals, + bootstrap, + config, + blocks, + index +) { + endpoint.oneSided95 = { + lowerRatio: Math.exp(intervals.oneSidedLower), + upperRatio: Math.exp(intervals.oneSidedUpper), + }; + endpoint.familyAdjusted95 = { + lowerRatio: Math.exp(intervals.familyLower), + upperRatio: Math.exp(intervals.familyUpper), + }; + endpoint.meaningfulImprovement = intervals.upper <= Math.log(0.9); + endpoint.bootstrap95 = { + lowerRatio: Math.exp(intervals.lower), + upperRatio: Math.exp(intervals.upper), + resamples: bootstrap.resamples, + familyCount: bootstrap.familyCount, + degenerateResamples: bootstrap.degenerateResamples, + degenerateFallbacks: bootstrap.degenerateFallbacks, + }; + endpoint.precision = precisionSummary( + endpoint.observedLogRatioSd, + bootstrap.standardErrors[index], + config, + intervals.familyLower, + intervals.familyUpper, + endpoint.logRatio, + Math.log(config.precisionMargin), + blocks + ); +} + +function variationFor(blocks, revision, key) { + const values = blocks.map((block) => + median( + block.revisions + .find((item) => item.revision === revision) + .workloads.find((item) => item.key === key).measured + ) + ); + return { ...variationMetrics(values), observations: values }; +} + +function batchVariation(blocks, key) { + const values = []; + for (const block of blocks) + for (const revision of block.revisions) { + const workload = revision.workloads.find((item) => item.key === key); + const center = median(workload.measured); + values.push(...workload.measured.map((value) => value / center)); + } + return { ...variationMetrics(values), observations: values }; +} + +function applyPrecision(status, endpoints) { + if (endpoints.length === 0) return status; + const precise = endpoints.every((endpoint) => endpoint.precision?.targetMet); + if (precise) return status; + return 'inconclusive'; +} + +function verdict(endpoints, margin, slope = false) { + if ( + endpoints.some( + (endpoint) => + (slope + ? endpoint.familyAdjusted95.lower + : endpoint.familyAdjusted95.lowerRatio) > margin + ) + ) + return 'regression'; + if ( + endpoints.every( + (endpoint) => + (slope ? endpoint.oneSided95.upper : endpoint.oneSided95.upperRatio) <= + margin + ) + ) + return 'pass'; + return 'inconclusive'; +} diff --git a/scripts/parser-benchmark-worker.js b/scripts/benchmark/parser-benchmark-worker.js similarity index 100% rename from scripts/parser-benchmark-worker.js rename to scripts/benchmark/parser-benchmark-worker.js diff --git a/scripts/benchmark/parser-benchmark.js b/scripts/benchmark/parser-benchmark.js new file mode 100644 index 0000000..527f4a1 --- /dev/null +++ b/scripts/benchmark/parser-benchmark.js @@ -0,0 +1,3 @@ +export { parserWorkloads, SIZES, DEPTHS } from './parser-workloads.js'; +export { analyzeParser } from './parser-analysis.js'; +export { runParserBenchmark } from './parser-runner.js'; diff --git a/scripts/benchmark/parser-runner.js b/scripts/benchmark/parser-runner.js new file mode 100644 index 0000000..341d5a4 --- /dev/null +++ b/scripts/benchmark/parser-runner.js @@ -0,0 +1,233 @@ +/* oxlint-disable complexity, no-bitwise */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import { + collectBenchmarkProvenance, + DECISION_CONFIG_VERSION, + DECISION_INTERVAL_METHOD, + DRIFT_THRESHOLD, + GROWTH_THRESHOLD, + materializeBaseline, + MIN_VALID_BLOCKS, + NON_REGRESSION_MARGIN, + normalizeSeed, + PRECISION_METHOD, + runChild, + seededShuffle, + validateSchemaV2Artifact, +} from './benchmark.js'; +import { + PARSER_MEASURED_BATCHES, + PARSER_TARGET_BATCH_MS, + PARSER_WARMUP_MAXIMUM, + PARSER_WARMUP_MINIMUM, + parserWorkloads, + endpointKey, +} from './parser-workloads.js'; +import { analyzeParser } from './parser-analysis.js'; + +const WORKER = fileURLToPath( + new URL('./parser-benchmark-worker.js', import.meta.url) +); + +export function runParserBenchmark({ + root = process.cwd(), + benchmark = 'arithmetic-chains', + baseline = 'HEAD', + blocks = MIN_VALID_BLOCKS, + maxAttempts = Math.max(30, blocks), + seed = 0x51f15eed, + output, +} = {}) { + if ( + !Number.isInteger(blocks) || + blocks < MIN_VALID_BLOCKS || + blocks % 2 !== 0 + ) + throw new TypeError( + `--blocks must be an even integer of at least ${MIN_VALID_BLOCKS}` + ); + if (!Number.isInteger(maxAttempts) || maxAttempts < blocks) + throw new TypeError('--max-attempts must be an integer at least --blocks'); + const normalizedSeed = normalizeSeed(seed); + const workloads = parserWorkloads(benchmark); + const workloadKeys = workloads.map((item) => item.key); + const config = { + decisionConfigVersion: DECISION_CONFIG_VERSION, + benchmark, + baseline, + requestedBlocks: blocks, + minimumBlocks: MIN_VALID_BLOCKS, + maxAttempts, + targetBatchMs: PARSER_TARGET_BATCH_MS, + warmupMinimum: PARSER_WARMUP_MINIMUM, + warmupMaximum: PARSER_WARMUP_MAXIMUM, + measuredBatchCount: PARSER_MEASURED_BATCHES, + driftThreshold: DRIFT_THRESHOLD, + bootstrapResamples: 100_000, + confidence: 0.95, + runtimeNonRegressionMargin: NON_REGRESSION_MARGIN, + equivalenceMargin: 1.1, + precisionMargin: 1.1, + precisionMethod: PRECISION_METHOD, + growthThreshold: GROWTH_THRESHOLD, + orderInteractionThreshold: Math.log(1.1), + intervalMethod: DECISION_INTERVAL_METHOD, + }; + const environment = collectBenchmarkProvenance(root, { + baselineRef: baseline, + benchmark: `parser-${benchmark}`, + command: process.argv.join(' '), + }); + const materialized = materializeBaseline(root, baseline); + try { + const attempts = []; + const validBlocks = []; + const processSchedule = seededShuffle( + Array.from({ length: blocks }, (_, index) => + index < blocks / 2 ? 'baseline-first' : 'candidate-first' + ), + normalizedSeed + ); + let correctnessFailure = null; + let attempt = 0; + while (validBlocks.length < blocks && attempt < maxAttempts) { + const blockSeed = + (normalizedSeed + Math.imul(attempt + 1, 0x9e3779b9)) >>> 0; + const order = seededShuffle(workloads, blockSeed); + // Rejected attempts retry the same acceptance slot. This preserves the + // randomized, balanced process-order schedule among retained blocks. + const processOrder = processSchedule[validBlocks.length]; + const revisions = []; + const sources = + processOrder === 'baseline-first' + ? [ + ['baseline', materialized.sourceRoot], + ['candidate', join(root, 'src')], + ] + : [ + ['candidate', join(root, 'src')], + ['baseline', materialized.sourceRoot], + ]; + for (const [revision, sourceRoot] of sources) { + const child = runChild( + WORKER, + { + sourceRoot, + revision, + processOrder, + workloads: order, + targetBatchMs: config.targetBatchMs, + warmupMinimum: config.warmupMinimum, + warmupMaximum: config.warmupMaximum, + measuredBatchCount: config.measuredBatchCount, + }, + root + ); + revisions.push(child); + } + const drift = revisions.map((revision) => + Math.abs( + controlMedian(revision.controlAfter) / + controlMedian(revision.controlBefore) - + 1 + ) + ); + const structural = new Map( + revisions.flatMap((revision) => + revision.workloads.map((workload) => [ + `${revision.revision}:${workload.key}`, + workload.structural, + ]) + ) + ); + const mismatches = workloadKeys.filter( + (key) => + structural.get(`baseline:${key}`) !== + structural.get(`candidate:${key}`) + ); + const rejected = + drift.some((value) => value > config.driftThreshold) || + mismatches.length > 0; + const rejectionReasons = [ + ...(drift.some((value) => value > config.driftThreshold) + ? ['drift'] + : []), + ...(mismatches.length > 0 ? ['structural-mismatch'] : []), + ]; + const record = { + index: attempt, + seed: blockSeed, + processOrder, + workloadOrder: order.map(endpointKey), + rejected, + rejectionReasons, + rejectionReason: rejectionReasons.join('+') || null, + drift, + structuralMismatches: mismatches, + revisions, + }; + attempts.push(record); + if (mismatches.length > 0) { + correctnessFailure = record; + break; + } + if (!rejected) validBlocks.push(record); + attempt++; + } + const artifact = { + schema: 2, + benchmark: `parser-${benchmark}`, + seed: normalizedSeed, + config, + environment, + workloadKeys, + workloads: workloads.map((workload) => + Object.fromEntries( + Object.entries(workload).filter(([key]) => key !== 'source') + ) + ), + attempts, + blocks: validBlocks, + }; + if (correctnessFailure) { + artifact.analysis = { + status: 'correctness-failure', + validBlocks: validBlocks.length, + reason: 'baseline and candidate parser structures differ', + attempt: correctnessFailure.index, + structuralMismatches: correctnessFailure.structuralMismatches, + }; + } else if (validBlocks.length >= MIN_VALID_BLOCKS) { + artifact.analysis = analyzeParser(artifact); + } else { + artifact.analysis = { + status: 'inconclusive', + validBlocks: validBlocks.length, + reason: 'fewer than twenty valid blocks', + }; + } + validateSchemaV2Artifact(artifact); + const path = output + ? resolve(root, output) + : join( + root, + 'reports/benchmarks', + `${benchmark}-${Date.now()}-${normalizedSeed}.json` + ); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`); + if (correctnessFailure) + throw new Error( + `parser structural mismatch in attempt ${correctnessFailure.index}: ${correctnessFailure.structuralMismatches.join(', ')}` + ); + return { artifact, path }; + } finally { + materialized.cleanup(); + } +} + +function controlMedian(control) { + return typeof control === 'number' ? control : control.medianMs; +} diff --git a/scripts/benchmark/parser-scaling.js b/scripts/benchmark/parser-scaling.js new file mode 100644 index 0000000..d8204de --- /dev/null +++ b/scripts/benchmark/parser-scaling.js @@ -0,0 +1,238 @@ +import { linearRegression, median, variationMetrics } from './benchmark.js'; + +export function precisionSummary( + observedStandardDeviation, + standardError, + config, + lower, + upper, + estimate, + targetHalfWidth, + observedBlocks +) { + const intervalHalfWidth = Math.max(estimate - lower, upper - estimate); + return { + observedStandardDeviation, + standardError, + confidenceIntervalWidth: upper - lower, + intervalHalfWidth, + targetHalfWidth, + minimumBlocks: config.minimumBlocks, + requestedBlocks: config.requestedBlocks, + observedBlocks, + targetMet: + observedBlocks >= config.minimumBlocks && + intervalHalfWidth <= targetHalfWidth, + }; +} + +export function parseKey(key) { + const parts = key.split(':'); + const size = Number(parts.at(-1)); + if (parts.length === 3) return { key, shape: parts[0], mode: parts[1], size }; + return { key, shape: 'nested-fallbacks', mode: parts[0], size }; +} + +export function largestSizeKeys(workloadKeys) { + const maximum = new Map(); + for (const key of workloadKeys) { + const { shape, mode, size } = parseKey(key); + const group = `${shape}:${mode}`; + maximum.set(group, Math.max(maximum.get(group) ?? 0, size)); + } + return new Set([...maximum].map(([group, size]) => `${group}:${size}`)); +} + +export function findWorkload(block, revision, key) { + return block.revisions + .find((item) => item.revision === revision) + .workloads.find((item) => item.key === key); +} + +export function analyzeSlopes(artifact, blocks, config) { + const workloads = artifact.workloadKeys + .map((key) => parseKey(key)) + .filter((item) => item.size); + const groups = new Map(); + for (const item of workloads) { + const group = `${item.shape}:${item.mode}`; + const values = groups.get(group) ?? { + shape: item.shape, + mode: item.mode, + sizes: [], + }; + values.sizes.push(item.size); + groups.set(group, values); + } + const raw = []; + for (const group of groups.values()) { + const deltas = []; + const baseSlopes = []; + const candidateSlopes = []; + for (const block of blocks) { + const base = group.sizes.map((size) => + median( + findWorkload( + block, + 'baseline', + `${group.shape}:${group.mode}:${size}` + ).measured + ) + ); + const cand = group.sizes.map((size) => + median( + findWorkload( + block, + 'candidate', + `${group.shape}:${group.mode}:${size}` + ).measured + ) + ); + const x = group.sizes.map(Math.log); + const b = linearRegression(x, base.map(Math.log)); + const c = linearRegression(x, cand.map(Math.log)); + baseSlopes.push(b.beta); + candidateSlopes.push(c.beta); + deltas.push(c.beta - b.beta); + } + raw.push({ + key: `${group.shape}:${group.mode}`, + baselineSlope: median(baseSlopes), + candidateSlope: median(candidateSlopes), + baselineSlopes: baseSlopes, + candidateSlopes, + deltas, + }); + } + return { + permittedIncrease: Math.log2(config.runtimeNonRegressionMargin), + endpoints: raw, + claimRows: raw.map((item) => item.deltas), + }; +} + +export function addSlopeIntervals(slopes, bootstrap, offset, config) { + slopes.endpoints = slopes.endpoints.map((item, index) => { + const intervals = bootstrap.intervals[offset + index]; + const standardError = bootstrap.standardErrors[offset + index]; + return { + ...item, + deltaSlope: bootstrap.observed[offset + index], + ordinary95: { + lower: + bootstrap.observed[offset + index] - + 1.96 * bootstrap.standardErrors[offset + index], + upper: + bootstrap.observed[offset + index] + + 1.96 * bootstrap.standardErrors[offset + index], + }, + oneSided95: { + lower: intervals.oneSidedLower, + upper: intervals.oneSidedUpper, + }, + familyAdjusted95: { + lower: intervals.familyLower, + upper: intervals.familyUpper, + }, + bootstrap95: { + lower: intervals.lower, + upper: intervals.upper, + resamples: bootstrap.resamples, + familyCount: bootstrap.familyCount, + degenerateResamples: bootstrap.degenerateResamples, + degenerateFallbacks: bootstrap.degenerateFallbacks, + }, + precision: precisionSummary( + variationMetrics(item.deltas).sd, + standardError, + config, + intervals.familyLower, + intervals.familyUpper, + bootstrap.observed[offset + index], + Math.log2(config.precisionMargin), + item.deltas.length + ), + }; + }); +} + +export function analyzeGrowth(artifact, blocks) { + const results = []; + const logs = []; + const groups = new Map(); + for (const key of artifact.workloadKeys) { + const item = parseKey(key); + const group = `${item.shape}:${item.mode}`; + const list = groups.get(group) ?? []; + list.push(item); + groups.set(group, list); + } + for (const [group, items] of groups) { + items.sort((a, b) => a.size - b.size); + for (let i = 1; i < items.length; i++) { + const doublings = Math.log2(items[i].size / items[i - 1].size); + const base = []; + const cand = []; + for (const block of blocks) { + const baseRatio = + median(findWorkload(block, 'baseline', items[i].key).measured) / + median(findWorkload(block, 'baseline', items[i - 1].key).measured); + const candRatio = + median(findWorkload(block, 'candidate', items[i].key).measured) / + median(findWorkload(block, 'candidate', items[i - 1].key).measured); + base.push(doublings === 1 ? baseRatio : baseRatio ** (1 / doublings)); + cand.push(doublings === 1 ? candRatio : candRatio ** (1 / doublings)); + } + logs.push(cand.map(Math.log)); + results.push({ + group, + from: items[i - 1].size, + to: items[i].size, + baselineMedian: median(base), + candidateMedian: median(cand), + }); + } + } + return { results, claimRows: logs }; +} + +export function addGrowthIntervals( + growthData, + bootstrap, + offset, + blocks, + config +) { + return growthData.results.map((result, index) => { + const intervals = bootstrap.intervals[offset + index]; + const standardError = bootstrap.standardErrors[offset + index]; + result.candidateLowerRatio = Math.exp(intervals.familyLower); + result.candidateUpperRatio = Math.exp(intervals.familyUpper); + result.candidateMedian = Math.exp(bootstrap.observed[offset + index]); + result.familyAdjusted95 = { + lower: intervals.familyLower, + upper: intervals.familyUpper, + lowerRatio: result.candidateLowerRatio, + upperRatio: result.candidateUpperRatio, + }; + result.bootstrap95 = { + lowerRatio: Math.exp(intervals.lower), + upperRatio: Math.exp(intervals.upper), + resamples: bootstrap.resamples, + familyCount: bootstrap.familyCount, + degenerateResamples: bootstrap.degenerateResamples, + degenerateFallbacks: bootstrap.degenerateFallbacks, + }; + result.precision = precisionSummary( + variationMetrics(growthData.claimRows[index]).sd, + standardError, + config, + intervals.familyLower, + intervals.familyUpper, + bootstrap.observed[offset + index], + Math.log(config.precisionMargin), + blocks.length + ); + return result; + }); +} diff --git a/scripts/benchmark/parser-workloads.js b/scripts/benchmark/parser-workloads.js new file mode 100644 index 0000000..9a23a30 --- /dev/null +++ b/scripts/benchmark/parser-workloads.js @@ -0,0 +1,63 @@ +// Four logarithmically spaced sizes with uniform doubling steps (2x) +// keep the scaling claims (slope and doubling growth) sound and well-powered +// while holding the default 20-block run to under five minutes. +export const SIZES = [2_000, 4_000, 8_000, 16_000]; +export const DEPTHS = [16, 32, 64, 128, 256, 512]; + +// 16ms batch targets provide ample separation above the timer resolution floor +// while keeping worker durations concise. +export const PARSER_TARGET_BATCH_MS = 16; +export const PARSER_WARMUP_MINIMUM = 4; +export const PARSER_WARMUP_MAXIMUM = 8; +export const PARSER_MEASURED_BATCHES = 5; + +function arithmetic(kind, size) { + if (kind === 'additive') return Array(size).fill('1').join(' + '); + if (kind === 'multiplicative') return Array(size).fill('2').join(' * '); + const operators = [' + ', ' * ', ' - ', ' / ']; + const parts = ['1']; + for (let i = 1; i < size; i++) + parts.push(operators[(i - 1) % operators.length], String((i % 7) + 1)); + return parts.join(''); +} + +function nestedFallback(depth) { + let value = 'calc(1px + 2px)'; + for (let i = depth; i >= 1; i--) value = `calc(var(--x${i}, ${value}))`; + return value; +} + +export function parserWorkloads(benchmark) { + const result = []; + if (benchmark === 'arithmetic-chains') { + for (const shape of [ + 'additive', + 'multiplicative', + 'alternating-precedence', + ]) + for (const mode of ['cold-index', 'hot-shared-index']) + for (const size of SIZES) + result.push({ + key: `${shape}:${mode}:${size}`, + shape, + mode, + size, + source: arithmetic(shape, size), + }); + } else if (benchmark === 'nested-fallbacks') { + for (const mode of ['cold-index', 'hot-shared-index']) + for (const depth of DEPTHS) + result.push({ + key: `nested-fallbacks:${mode}:${depth}`, + shape: 'nested-fallbacks', + mode, + size: depth, + source: nestedFallback(depth), + }); + } else throw new Error(`unsupported benchmark: ${benchmark}`); + return result; +} + +export function endpointKey(workload) { + return workload.key; +} diff --git a/scripts/benchmark/provenance.js b/scripts/benchmark/provenance.js new file mode 100644 index 0000000..d97e48a --- /dev/null +++ b/scripts/benchmark/provenance.js @@ -0,0 +1,181 @@ +import { + readFileSync, + readdirSync, + statSync, + existsSync, + mkdtempSync, + mkdirSync, + rmSync, +} from 'node:fs'; +import { createHash } from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { cpus, loadavg, platform, release, arch } from 'node:os'; +import { join, relative } from 'node:path'; + +/** @param {string} file @return {string} */ +export function sha256File(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +/** @param {string} directory @return {string} */ +export function sourceTreeHash(directory) { + const files = []; + function visit(current) { + for (const name of readdirSync(current).sort()) { + const file = join(current, name); + const stat = statSync(file); + if (stat.isDirectory()) visit(file); + else files.push([relative(directory, file), readFileSync(file)]); + } + } + visit(directory); + return hashSourceFiles(files); +} + +function hashSourceFiles(files) { + const hash = createHash('sha256'); + for (const [name, data] of files) + hash.update(name).update('\0').update(data).update('\0'); + return hash.digest('hex'); +} + +export function benchmarkHarnessFiles(root) { + const files = []; + const scripts = join(root, 'scripts'); + const lib = join(scripts, 'lib'); + const benchmarkDir = join(scripts, 'benchmark'); + function addTree(directory, prefix) { + if (!existsSync(directory)) return; + for (const name of readdirSync(directory).sort()) { + const file = join(directory, name); + const stat = statSync(file); + if (stat.isDirectory()) addTree(file, `${prefix}/${name}`); + else files.push([`${prefix}/${name}`, readFileSync(file)]); + } + } + addTree(lib, 'scripts/lib'); + addTree(benchmarkDir, 'scripts/benchmark'); + return files.sort(([a], [b]) => a.localeCompare(b)); +} + +export function benchmarkHarnessHash(root) { + return hashSourceFiles(benchmarkHarnessFiles(root)); +} + +function git(root, args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +} + +function gitBuffer(root, args) { + return execFileSync('git', args, { cwd: root }); +} + +function gitSourceTreeHash(root, ref) { + const paths = git(root, ['ls-tree', '-r', '--name-only', ref, '--', 'src']) + .split('\n') + .filter(Boolean); + return hashSourceFiles( + paths.map((path) => [ + path.slice('src/'.length), + gitBuffer(root, ['show', `${ref}:${path}`]), + ]) + ); +} + +/** @param {string} root @param {string} baselineRef */ +export function collectBenchmarkProvenance( + root, + { + baselineRef = 'HEAD', + benchmark = 'unknown', + corpusPath, + command = process.argv.join(' '), + } = {} +) { + const currentCommit = git(root, ['rev-parse', 'HEAD']); + const baselineCommit = git(root, ['rev-parse', baselineRef]); + const lockfile = join(root, 'pnpm-lock.yaml'); + let governor = null; + try { + governor = readFileSync( + '/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor', + 'utf8' + ).trim(); + } catch { + // Linux CPU governor is optional on other operating systems. + } + return { + createdAt: new Date().toISOString(), + command, + benchmark, + benchmarkHarnessHash: benchmarkHarnessHash(root), + baselineCommit, + worktreeCommit: currentCommit, + baselineSourceHash: gitSourceTreeHash(root, baselineRef), + worktreeSourceHash: sourceTreeHash(join(root, 'src')), + baselineSourceTreeHash: gitSourceTreeHash(root, baselineRef), + worktreeSourceTreeHash: sourceTreeHash(join(root, 'src')), + sourceTreeHash: sourceTreeHash(join(root, 'src')), + lockfileHash: sha256File(lockfile), + corpusHash: corpusPath ? sha256File(corpusPath) : null, + dirty: git(root, ['status', '--porcelain']) !== '', + node: process.version, + v8: process.versions.v8, + cpu: cpus()[0]?.model ?? 'unknown', + cpuCount: cpus().length, + platform: `${platform()} ${release()} ${arch()}`, + os: `${platform()} ${release()} ${arch()}`, + loadAverage: loadavg(), + linuxCpuGovernor: governor, + }; +} + +export function collectEnvironment(root, baselineRef = 'HEAD') { + return collectBenchmarkProvenance(root, { baselineRef }); +} + +/** @param {string} root @param {string} ref @return {{directory: string, sourceRoot: string, commit: string, cleanup: () => void}} */ +export function materializeBaseline(root, ref) { + // Keep the temporary tree below the project so ESM's normal package + // resolution can reach the current checkout's installed dependencies. + const directory = mkdtempSync(join(root, '.postcss-calc-baseline-')); + mkdirSync(join(directory, 'src'), { recursive: true }); + try { + const archive = execFileSync('git', ['archive', ref, '--', 'src'], { + cwd: root, + }); + execFileSync('tar', ['-x', '-f', '-', '-C', directory], { input: archive }); + return { + directory, + sourceRoot: join(directory, 'src'), + commit: git(root, ['rev-parse', ref]), + cleanup: () => rmSync(directory, { recursive: true, force: true }), + }; + } catch (error) { + rmSync(directory, { recursive: true, force: true }); + throw error; + } +} + +/** @param {string} worker @param {object} payload @param {string} cwd */ +export function runChild(worker, payload, cwd) { + const result = spawnSync(process.execPath, [worker], { + cwd, + encoding: 'utf8', + input: JSON.stringify(payload), + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env }, + }); + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error( + `benchmark child failed (${result.status}): ${result.stderr || result.stdout}` + ); + try { + return JSON.parse(result.stdout); + } catch { + throw new Error( + `benchmark child returned invalid JSON: ${result.stdout.slice(0, 500)}` + ); + } +} diff --git a/scripts/benchmark/random.js b/scripts/benchmark/random.js new file mode 100644 index 0000000..41ad8e7 --- /dev/null +++ b/scripts/benchmark/random.js @@ -0,0 +1,83 @@ +/* oxlint-disable no-bitwise */ +/** @param {unknown} seed @return {number} */ +export function normalizeSeed(seed) { + if (typeof seed === 'number') { + if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) + throw new TypeError('seed must be an unsigned 32-bit integer'); + return seed >>> 0; + } + if (typeof seed === 'string' && /^\d+$/.test(seed)) { + const value = Number(seed); + if (Number.isSafeInteger(value) && value <= 0xffffffff) return value >>> 0; + } + throw new TypeError('seed must be an unsigned 32-bit integer'); +} + +/** @param {number} seed @return {() => number} */ +export function seededRandom(seed) { + let state = normalizeSeed(seed) || 0x9e3779b9; + return () => { + state = Math.imul(state ^ (state >>> 16), 0x21f0aaad); + state = Math.imul(state ^ (state >>> 15), 0x735a2d97); + state ^= state >>> 15; + return (state >>> 0) / 0x1_0000_0000; + }; +} + +/** @template T @param {readonly T[]} values @param {number} seed @return {T[]} */ +export function seededShuffle(values, seed) { + const result = [...values]; + const random = seededRandom(seed); + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +/** + * Return a shuffled, balanced process schedule. Values are deliberately + * explicit (`baseline-first`/`candidate-first`) so the artifact is auditable. + * + * @param {number} count + * @param {number} seed + */ +export function balancedOrder(count, seed) { + if (!Number.isInteger(count) || count < 2 || count % 2 !== 0) + throw new RangeError('a balanced schedule requires a positive even count'); + return seededShuffle( + Array.from({ length: count }, (_, index) => + index < count / 2 ? 'baseline-first' : 'candidate-first' + ), + seed + ); +} + +/** + * Return a deterministic, balanced schedule for the corpus calibration order. + * Odd counts differ by at most one; the first label receives the extra slot. + */ +export function balancedSchedule(count, first, second, seed) { + if (!Number.isInteger(count) || count <= 0) + throw new RangeError('schedule count must be positive'); + if ( + typeof first !== 'string' || + typeof second !== 'string' || + first === second + ) + throw new TypeError('schedule labels must be distinct strings'); + return seededShuffle( + Array.from({ length: count }, (_, index) => + index < Math.ceil(count / 2) ? first : second + ), + seed + ); +} + +/** @param {number} count @param {number} seed @return {number[]} */ +export function bootstrapIndices(count, seed) { + if (!Number.isInteger(count) || count <= 0) + throw new RangeError('cannot resample an empty collection'); + const random = seededRandom(seed); + return Array.from({ length: count }, () => Math.floor(random() * count)); +} diff --git a/scripts/benchmark/statistics.js b/scripts/benchmark/statistics.js new file mode 100644 index 0000000..55f3546 --- /dev/null +++ b/scripts/benchmark/statistics.js @@ -0,0 +1,198 @@ +/** @param {number[] | Float64Array} values @return {number[] | Float64Array} */ +export function finiteValues(values) { + if ( + (!Array.isArray(values) && !ArrayBuffer.isView(values)) || + values.length === 0 + ) + throw new RangeError('expected a non-empty numeric array'); + if ( + values.some((value) => typeof value !== 'number' || !Number.isFinite(value)) + ) + throw new TypeError('values must be finite numbers'); + return values; +} + +/** @param {number[] | Float64Array} values @return {number} */ +export function median(values) { + const finite = finiteValues(values); + const sorted = ArrayBuffer.isView(finite) + ? finite.slice().sort() + : [...finite].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2; +} + +/** @param {number[] | Float64Array} values @param {number} p @return {number} */ +export function percentile(values, p) { + const finite = finiteValues(values); + const sorted = ArrayBuffer.isView(finite) + ? finite.slice().sort() + : [...finite].sort((a, b) => a - b); + if (!Number.isFinite(p) || p < 0 || p > 1) + throw new RangeError('p must be in [0, 1]'); + const position = (sorted.length - 1) * p; + const lower = Math.floor(position); + const upper = Math.ceil(position); + return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); +} + +/** @param {number[]} values @return {{median: number, q1: number, q3: number, min: number, max: number, sd: number, cv: number, relativeSpan: number}} */ +export function variationMetrics(values) { + const finite = finiteValues(values); + const mean = finite.reduce((sum, value) => sum + value, 0) / finite.length; + const variance = + finite.reduce((sum, value) => sum + (value - mean) ** 2, 0) / + Math.max(1, finite.length - 1); + return { + median: median(finite), + q1: percentile(finite, 0.25), + q3: percentile(finite, 0.75), + min: Math.min(...finite), + max: Math.max(...finite), + sd: Math.sqrt(variance), + cv: mean === 0 ? Infinity : Math.sqrt(variance) / Math.abs(mean), + relativeSpan: + mean === 0 + ? Infinity + : (Math.max(...finite) - Math.min(...finite)) / Math.abs(mean), + }; +} + +/** @param {number[]} values @return {number} */ +export function geometricMean(values) { + const finite = finiteValues(values); + if (finite.some((value) => value <= 0)) + throw new RangeError('geometric mean requires positive values'); + return Math.exp( + finite.reduce((sum, value) => sum + Math.log(value), 0) / finite.length + ); +} + +/** @param {number} candidate @param {number} baseline @return {number} */ +export function logRatio(candidate, baseline) { + if ( + !(candidate > 0) || + !(baseline > 0) || + !Number.isFinite(candidate) || + !Number.isFinite(baseline) + ) + throw new RangeError('runtime ratios require positive finite timings'); + return Math.log(candidate / baseline); +} + +/** @param {number[]} values @return {{mean: number, sd: number, lower: number, upper: number}} */ +export function ordinaryInterval(values) { + const finite = finiteValues(values); + const mean = finite.reduce((sum, value) => sum + value, 0) / finite.length; + const sd = Math.sqrt( + finite.reduce((sum, value) => sum + (value - mean) ** 2, 0) / + Math.max(1, finite.length - 1) + ); + const half = (1.96 * sd) / Math.sqrt(finite.length); + return { mean, sd, lower: mean - half, upper: mean + half }; +} + +/** @param {number[]} values @return {{mean: number, sd: number, lower: number, upper: number}} */ +export function oneSidedInterval(values) { + const interval = ordinaryInterval(values); + const half = (1.6448536269514722 * interval.sd) / Math.sqrt(values.length); + return { + ...interval, + lower: interval.mean - half, + upper: interval.mean + half, + }; +} + +/** @param {number[]} values @return {{meanLog: number, ratio: number, ordinary: object, logValues: number[]}} */ +export function pairedRatioSummary(values) { + const logValues = finiteValues(values); + const ordinary = ordinaryInterval(logValues); + return { + meanLog: ordinary.mean, + ratio: Math.exp(ordinary.mean), + ordinary, + logValues, + }; +} + +/** @param {number[]} x @param {number[]} y @return {{alpha: number, beta: number, r2: number, n: number}} */ +export function linearRegression(x, y) { + if ( + !Array.isArray(x) || + !Array.isArray(y) || + x.length !== y.length || + x.length < 2 + ) + throw new RangeError( + 'linear regression requires paired arrays of length at least two' + ); + finiteValues(x); + finiteValues(y); + const xMean = x.reduce((sum, value) => sum + value, 0) / x.length; + const yMean = y.reduce((sum, value) => sum + value, 0) / y.length; + const ssX = x.reduce((sum, value) => sum + (value - xMean) ** 2, 0); + if (ssX === 0) + throw new RangeError('linear regression requires varying x values'); + const covariance = x.reduce( + (sum, value, index) => sum + (value - xMean) * (y[index] - yMean), + 0 + ); + const beta = covariance / ssX; + const alpha = yMean - beta * xMean; + const ssY = y.reduce((sum, value) => sum + (value - yMean) ** 2, 0); + const residual = y.reduce( + (sum, value, index) => sum + (value - (alpha + beta * x[index])) ** 2, + 0 + ); + return { alpha, beta, r2: ssY === 0 ? 1 : 1 - residual / ssY, n: x.length }; +} + +/** Acklam's inverse-normal approximation, sufficient for benchmark intervals. */ +export function normalQuantile(p) { + const a = [ + -39.6968302866538, 220.946098424521, -275.928510446969, 138.357751867269, + -30.6647980661472, 2.50662827745924, + ]; + const b = [ + -54.4760987982241, 161.585836858041, -155.698979859887, 66.8013118877197, + -13.2806815528857, + ]; + const c = [ + -0.00778489400243029, -0.322396458041136, -2.40075827716184, + -2.54973253934373, 4.37466414146497, 2.93816398269878, + ]; + const d = [ + 0.00778469570904146, 0.32246712907004, 2.445134137143, 3.75440866190742, + ]; + const plow = 0.02425; + const phigh = 1 - plow; + if (p <= 0 || p >= 1) + throw new RangeError('normal quantile requires p in (0, 1)'); + if (p < plow) { + const q = Math.sqrt(-2 * Math.log(p)); + const numerator = horner(c, q); + const denominator = horner([...d, 1], q); + return numerator / denominator; + } + if (p > phigh) { + const q = Math.sqrt(-2 * Math.log(1 - p)); + const numerator = horner(c, q); + const denominator = horner([...d, 1], q); + return -numerator / denominator; + } + const q = p - 0.5; + const r = q * q; + const numerator = horner(a, r) * q; + const denominator = horner([...b, 1], r); + return numerator / denominator; +} + +/** @param {number[]} coefficients @param {number} value */ +function horner(coefficients, value) { + return coefficients.reduce( + (result, coefficient) => result * value + coefficient, + 0 + ); +} diff --git a/scripts/benchmark/validate-corpus-summaries.js b/scripts/benchmark/validate-corpus-summaries.js new file mode 100644 index 0000000..63f18f4 --- /dev/null +++ b/scripts/benchmark/validate-corpus-summaries.js @@ -0,0 +1,149 @@ +/* oxlint-disable complexity, no-bitwise */ +import { stableHash } from '../lib/corpus-policy.js'; +import { bootstrapCorpusInterval } from './bootstrap.js'; +import { BOOTSTRAP_RESAMPLES } from './config.js'; +import { median } from './statistics.js'; +import { positiveFinite } from './validate-parser.js'; + +export function validateCorpusGroupSummaries( + artifact, + expectedGroups, + allChecksumsByGroup +) { + const groups = artifact.analysis?.groups; + if (artifact.analysis === undefined) return; + if (!groups || typeof groups !== 'object' || Array.isArray(groups)) + throw new TypeError('corpus artifact is missing group summaries'); + const names = Object.keys(groups).sort(); + if (JSON.stringify(names) !== JSON.stringify([...expectedGroups].sort())) + throw new TypeError('corpus artifact has incomplete group summaries'); + for (const group of expectedGroups) { + const summary = groups[group]; + const rawRatios = corpusRawRatios(artifact, group); + if ( + !summary || + summary.replicates !== artifact.replicates.length || + !Array.isArray(summary.ratios) || + summary.ratios.length !== artifact.replicates.length || + summary.ratios.some((ratio) => !positiveFinite(ratio)) || + !Array.isArray(summary.checksums) || + summary.checksums.length === 0 || + summary.checksums.some( + (checksum) => !Number.isInteger(checksum) || checksum < 0 + ) + ) + throw new TypeError(`corpus group ${group} has invalid summary`); + if ( + rawRatios.length !== summary.ratios.length || + rawRatios.some( + (ratio, index) => Math.abs(ratio - summary.ratios[index]) > 1e-12 + ) + ) + throw new TypeError( + `corpus group ${group} summary is not derived from raw observations` + ); + const rawLogs = rawRatios.map(Math.log); + const rawMean = + rawLogs.reduce((sum, value) => sum + value, 0) / rawLogs.length; + if ( + summary.geometricMeanPairedRuntimeRatio !== undefined && + Math.abs(summary.geometricMeanPairedRuntimeRatio - Math.exp(rawMean)) > + 1e-12 + ) + throw new TypeError( + `corpus group ${group} summary mean is not derived from raw observations` + ); + if (summary.bootstrap95) { + const digest = stableHash(group); + const groupSeed = + (artifact.seed ^ Number.parseInt(digest.slice(0, 8), 16)) >>> 0; + const expectedBootstrap = bootstrapCorpusInterval( + corpusRawReplicatePairs(artifact, group), + groupSeed, + artifact.config?.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, + 0.95 + ); + if ( + Math.abs( + summary.bootstrap95.lowerRatio - Math.exp(expectedBootstrap.lower) + ) > 1e-12 || + Math.abs( + summary.bootstrap95.upperRatio - Math.exp(expectedBootstrap.upper) + ) > 1e-12 + ) + throw new TypeError( + `corpus group ${group} interval is not derived from raw observations` + ); + if (summary.bootstrap90) { + const expectedNinety = bootstrapCorpusInterval( + corpusRawReplicatePairs(artifact, group), + (groupSeed ^ 0x9e3779b9) >>> 0, + artifact.config?.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, + 0.9 + ); + if ( + Math.abs( + summary.bootstrap90.lowerRatio - Math.exp(expectedNinety.lower) + ) > 1e-12 || + Math.abs( + summary.bootstrap90.upperRatio - Math.exp(expectedNinety.upper) + ) > 1e-12 + ) + throw new TypeError( + `corpus group ${group} practical interval is not derived from raw observations` + ); + } + } + const expectedChecksums = allChecksumsByGroup.get(group) ?? new Set(); + if ( + summary.checksums.length !== expectedChecksums.size || + summary.checksums.some((checksum) => !expectedChecksums.has(checksum)) + ) + throw new TypeError(`corpus group ${group} has inconsistent checksums`); + } + if (artifact.analysis?.status) { + if ( + !['postcss-calc faster', 'postcss-calc slower', 'inconclusive'].includes( + artifact.analysis.status + ) + ) + throw new TypeError('corpus artifact has an invalid statistical status'); + if ( + artifact.analysis.statistical?.status !== undefined && + artifact.analysis.statistical.status !== artifact.analysis.status + ) + throw new TypeError( + 'corpus artifact has inconsistent statistical status' + ); + if ( + artifact.analysis.practical?.margin !== undefined && + artifact.analysis.practical.margin !== artifact.config.equivalenceMargin + ) + throw new TypeError( + 'corpus artifact has inconsistent equivalence margin' + ); + } +} + +export function corpusRawRatios(artifact, group) { + return corpusRawReplicatePairs(artifact, group).map( + ([oursFirst, referenceFirst]) => + Math.exp((Math.log(oursFirst) + Math.log(referenceFirst)) / 2) + ); +} + +export function corpusRawReplicatePairs(artifact, group) { + return artifact.replicates.map((replicate) => + ['ours-first', 'reference-first'].map((order) => { + const values = replicate.batches + .filter((batch) => batch.order === order) + .map((batch) => + batch.measurements.find((item) => item.group === group) + ); + return ( + median(values.map((value) => value.ours.ms)) / + median(values.map((value) => value.reference.ms)) + ); + }) + ); +} diff --git a/scripts/benchmark/validate-corpus.js b/scripts/benchmark/validate-corpus.js new file mode 100644 index 0000000..1c97f7b --- /dev/null +++ b/scripts/benchmark/validate-corpus.js @@ -0,0 +1,254 @@ +/* oxlint-disable complexity */ +import { + CORPUS_CATEGORIES, + NEUTRAL_CORPUS_CATEGORIES, +} from '../lib/corpus-policy.js'; +import { + DECISION_CONFIG_VERSION, + MIN_VALID_BLOCKS, + migrateLegacyDecisionConfig, + validateDecisionConfig, +} from './config.js'; +import { positiveFinite } from './validate-parser.js'; +import { validateCorpusGroupSummaries } from './validate-corpus-summaries.js'; + +export function validateCorpusArtifact(artifact) { + if ( + !Array.isArray(artifact.replicates) || + artifact.replicates.length < MIN_VALID_BLOCKS + ) + throw new TypeError('corpus artifact has insufficient valid replicates'); + const config = + artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION + ? validateDecisionConfig(artifact.config, 'corpus artifact') + : migrateLegacyDecisionConfig(artifact, 'corpus'); + if ( + (config.replicates ?? artifact.replicates.length) !== + artifact.replicates.length || + (config.batches ?? 6) !== 6 + ) + throw new TypeError( + 'corpus artifact has inconsistent replicate configuration' + ); + if (artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION) + validateCorpusCorrectness(artifact); + else if ( + !artifact.correctness || + !Number.isInteger(artifact.correctness.accepted) || + artifact.correctness.accepted <= 0 + ) + throw new TypeError('corpus artifact has invalid correctness metadata'); + const expectedGroups = corpusGroups(artifact); + if (expectedGroups.length < 2) + throw new TypeError('corpus artifact has no complete comparison groups'); + const repetitionsByGroup = new Map(); + const checksumsByGroup = new Map(); + const allChecksumsByGroup = new Map(); + const replicateIds = new Set(); + for (const [index, replicate] of artifact.replicates.entries()) { + if ( + !Number.isInteger(replicate?.replicate) || + replicate.replicate < 0 || + replicateIds.has(replicate.replicate) + ) + throw new TypeError(`corpus replicate ${index} has an invalid id`); + replicateIds.add(replicate.replicate); + if (config.calibrationOrderBalanced === true) { + if ( + !['ours-first', 'reference-first'].includes(replicate.calibrationOrder) + ) + throw new TypeError( + `corpus replicate ${index} has an invalid calibration order` + ); + for (const expectedOrder of ['ours-first', 'reference-first']) { + const count = artifact.replicates.filter( + (item) => item.calibrationOrder === expectedOrder + ).length; + if (Math.abs(count - artifact.replicates.length / 2) > 1) + throw new TypeError('corpus calibration orders are unbalanced'); + } + } + if ( + !Array.isArray(replicate.permutation) || + !isPermutation(replicate.permutation, artifact.correctness.accepted) + ) + throw new TypeError( + `corpus replicate ${index} has an invalid permutation` + ); + if (!Array.isArray(replicate.batches) || replicate.batches.length !== 6) + throw new TypeError(`corpus replicate ${index} must contain six batches`); + const orderCounts = { 'ours-first': 0, 'reference-first': 0 }; + for (const [batchIndex, batch] of replicate.batches.entries()) { + if ( + !batch || + !['ours-first', 'reference-first'].includes(batch.order) || + !Array.isArray(batch.measurements) + ) + throw new TypeError( + `corpus replicate ${index} has invalid batch order` + ); + orderCounts[batch.order]++; + const seenGroups = new Set(); + if (batch.measurements.length !== expectedGroups.length) + throw new TypeError( + `corpus replicate ${index} batch ${batchIndex} has incomplete groups` + ); + for (const measurement of batch.measurements) { + if ( + !measurement || + typeof measurement.group !== 'string' || + !expectedGroups.includes(measurement.group) || + seenGroups.has(measurement.group) + ) + throw new TypeError( + `corpus replicate ${index} batch ${batchIndex} has invalid groups` + ); + seenGroups.add(measurement.group); + if ( + !Number.isInteger(measurement.repetitions) || + measurement.repetitions <= 0 + ) + throw new TypeError( + `corpus replicate ${index} has invalid repetitions` + ); + const replicateGroup = `${replicate.replicate}:${measurement.group}`; + const previousRepetitions = repetitionsByGroup.get(replicateGroup); + if ( + previousRepetitions !== undefined && + previousRepetitions !== measurement.repetitions + ) + throw new TypeError( + `corpus group ${measurement.group} has inconsistent repetitions` + ); + repetitionsByGroup.set(replicateGroup, measurement.repetitions); + if ( + !Array.isArray(measurement.calibrationSamplesMs) || + measurement.calibrationSamplesMs.length === 0 + ) + throw new TypeError( + `corpus replicate ${index} has invalid calibration samples` + ); + for (const sample of measurement.calibrationSamplesMs) { + if ( + !sample || + !positiveFinite(sample.oursMs) || + !positiveFinite(sample.referenceMs) + ) + throw new TypeError( + `corpus replicate ${index} has invalid calibration timings` + ); + } + if ( + config.calibrationOrderBalanced === true && + measurement.calibrationOrder !== replicate.calibrationOrder + ) + throw new TypeError( + `corpus replicate ${index} has an inconsistent calibration order` + ); + for (const implementation of ['ours', 'reference']) { + const result = measurement[implementation]; + if ( + !result || + !positiveFinite(result.ms) || + !positiveFinite(result.elapsedMs) || + !Number.isInteger(result.checksum) || + result.checksum < 0 + ) + throw new TypeError( + `corpus replicate ${index} has nonpositive timings` + ); + const checksumKey = `${replicate.replicate}:${measurement.group}:${implementation}`; + const previousChecksum = checksumsByGroup.get(checksumKey); + if ( + previousChecksum !== undefined && + previousChecksum !== result.checksum + ) + throw new TypeError( + `corpus group ${measurement.group} has inconsistent checksums` + ); + checksumsByGroup.set(checksumKey, result.checksum); + const allChecksums = + allChecksumsByGroup.get(measurement.group) ?? new Set(); + allChecksums.add(result.checksum); + allChecksumsByGroup.set(measurement.group, allChecksums); + } + } + if (seenGroups.size !== expectedGroups.length) + throw new TypeError( + `corpus replicate ${index} batch ${batchIndex} has missing groups` + ); + } + if (orderCounts['ours-first'] !== 3 || orderCounts['reference-first'] !== 3) + throw new TypeError( + `corpus replicate ${index} has unbalanced process orders` + ); + } + validateCorpusGroupSummaries(artifact, expectedGroups, allChecksumsByGroup); + return artifact; +} + +function validateCorpusCorrectness(artifact) { + const correctness = artifact.correctness; + if ( + !correctness || + !Number.isInteger(correctness.accepted) || + correctness.accepted <= 0 + ) + throw new TypeError('corpus artifact has invalid correctness metadata'); + if (!correctness.counts || !correctness.categoryHashes) + throw new TypeError('corpus artifact is missing corpus category metadata'); + for (const category of CORPUS_CATEGORIES) { + if ( + !Number.isInteger(correctness.counts[category]) || + correctness.counts[category] < 0 + ) + throw new TypeError(`corpus artifact has invalid ${category} count`); + if (typeof correctness.categoryHashes[category] !== 'string') + throw new TypeError(`corpus artifact has invalid ${category} hash`); + if ( + category !== 'accepted' && + !NEUTRAL_CORPUS_CATEGORIES.has(category) && + correctness.counts[category] !== 0 + ) + throw new TypeError( + `corpus artifact contains non-neutral ${category} inputs` + ); + } + if (correctness.counts.accepted !== correctness.accepted) + throw new TypeError('corpus artifact has inconsistent accepted counts'); + if ( + typeof correctness.inputHash !== 'string' || + correctness.inputHash.length === 0 + ) + throw new TypeError('corpus artifact has an invalid input hash'); +} + +function corpusGroups(artifact) { + const strata = artifact.corpus?.lengthStrata; + const shapes = artifact.corpus?.rootShapeCounts; + if ( + !strata || + !shapes || + typeof strata !== 'object' || + typeof shapes !== 'object' + ) + throw new TypeError('corpus artifact is missing group metadata'); + const groups = ['exact']; + for (const [group, count] of Object.entries(shapes).sort()) + if (Number.isInteger(count) && count > 0) groups.push(group); + for (const [group, count] of Object.entries(strata).sort()) + if (Number.isInteger(count) && count > 0) groups.push(group); + return groups; +} + +function isPermutation(values, length) { + return ( + Number.isInteger(length) && + Array.isArray(values) && + values.length === length && + values.every( + (value) => Number.isInteger(value) && value >= 0 && value < length + ) && + new Set(values).size === length + ); +} diff --git a/scripts/benchmark/validate-parser.js b/scripts/benchmark/validate-parser.js new file mode 100644 index 0000000..db7919c --- /dev/null +++ b/scripts/benchmark/validate-parser.js @@ -0,0 +1,193 @@ +/* oxlint-disable complexity */ +export function positiveFinite(value) { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +export function validateParserRecord( + record, + label, + expected, + isAttempt = false, + driftThreshold = 0.15 +) { + if ( + !record || + typeof record !== 'object' || + !['baseline-first', 'candidate-first'].includes(record.processOrder) + ) + throw new TypeError(`${label} has an invalid process order`); + if ( + !Number.isInteger(record.seed) || + record.seed < 0 || + record.seed > 0xffffffff + ) + throw new TypeError(`${label} has an invalid seed`); + if (isAttempt && !Number.isInteger(record.index)) + throw new TypeError(`${label} has an invalid index`); + if ( + !Array.isArray(record.workloadOrder) || + record.workloadOrder.length !== expected.size || + new Set(record.workloadOrder).size !== expected.size || + record.workloadOrder.some((key) => !expected.has(key)) + ) + throw new TypeError(`${label} has mismatched workload keys`); + if (!Array.isArray(record.revisions) || record.revisions.length !== 2) + throw new TypeError(`${label} must contain two revisions`); + const revisions = new Set( + record.revisions.map((revision) => revision?.revision) + ); + if ( + revisions.size !== 2 || + !revisions.has('baseline') || + !revisions.has('candidate') + ) + throw new TypeError(`${label} must contain baseline and candidate`); + if (isAttempt) { + if (typeof record.rejected !== 'boolean') + throw new TypeError(`${label} has an invalid rejection flag`); + if ( + !Array.isArray(record.rejectionReasons) || + record.rejectionReasons.some( + (reason) => !['drift', 'structural-mismatch'].includes(reason) + ) || + new Set(record.rejectionReasons).size !== + record.rejectionReasons.length || + record.rejected !== Boolean(record.rejectionReasons.length) || + record.rejectionReason !== (record.rejectionReasons.join('+') || null) + ) + throw new TypeError(`${label} has an invalid rejection reason`); + if ( + !Array.isArray(record.drift) || + record.drift.length !== 2 || + record.drift.some( + (value) => + typeof value !== 'number' || !Number.isFinite(value) || value < 0 + ) + ) + throw new TypeError(`${label} has invalid drift`); + if ( + !Array.isArray(record.structuralMismatches) || + record.structuralMismatches.some((key) => typeof key !== 'string') + ) + throw new TypeError(`${label} has invalid structural mismatches`); + if ( + Boolean(record.structuralMismatches.length) !== + record.rejectionReasons.includes('structural-mismatch') + ) + throw new TypeError(`${label} has inconsistent structural rejection`); + if ( + record.drift.some((value) => value > driftThreshold) !== + record.rejectionReasons.includes('drift') + ) + throw new TypeError(`${label} has inconsistent drift rejection`); + } + for (const revision of record.revisions) + validateParserRevision(revision, label, expected, record.processOrder); + if (isAttempt) { + const expectedDrift = record.revisions.map((revision) => + Math.abs( + revision.controlAfter.medianMs / revision.controlBefore.medianMs - 1 + ) + ); + if ( + record.drift.some( + (value, index) => Math.abs(value - expectedDrift[index]) > 1e-12 + ) + ) + throw new TypeError(`${label} has inconsistent drift`); + const structural = new Map( + record.revisions.flatMap((revision) => + revision.workloads.map((workload) => [ + `${revision.revision}:${workload.key}`, + workload.structural, + ]) + ) + ); + const mismatches = [...expected].filter( + (key) => + structural.get(`baseline:${key}`) !== structural.get(`candidate:${key}`) + ); + if ( + JSON.stringify(mismatches) !== JSON.stringify(record.structuralMismatches) + ) + throw new TypeError(`${label} has inconsistent structural mismatches`); + } +} + +function validateParserRevision(revision, label, expected, processOrder) { + if (!revision || !['baseline', 'candidate'].includes(revision.revision)) + throw new TypeError(`${label} has invalid revisions`); + if ( + !['baseline-first', 'candidate-first'].includes(revision.processOrder) || + revision.processOrder !== processOrder + ) + throw new TypeError(`${label} has invalid revision process order`); + validateControl(revision.controlBefore, `${label} controlBefore`); + validateControl(revision.controlAfter, `${label} controlAfter`); + if (!Array.isArray(revision.workloads)) + throw new TypeError(`${label} has invalid workloads`); + const seen = new Set(); + for (const workload of revision.workloads) { + if ( + !workload || + typeof workload.key !== 'string' || + !expected.has(workload.key) || + seen.has(workload.key) + ) + throw new TypeError(`${label} has mismatched workload keys`); + seen.add(workload.key); + validateParserWorkload(workload, `${label} ${workload.key}`); + } + if (seen.size !== expected.size) + throw new TypeError(`${label} has missing workload keys`); +} + +function validateControl(control, label) { + if ( + !control || + typeof control !== 'object' || + !positiveFinite(control.medianMs) || + !Array.isArray(control.samplesMs) || + control.samplesMs.length === 0 || + control.samplesMs.some((value) => !positiveFinite(value)) + ) + throw new TypeError(`${label} has invalid timings`); +} + +function validateParserWorkload(workload, label) { + if (!Number.isInteger(workload.repetitions) || workload.repetitions <= 0) + throw new TypeError(`${label} has invalid repetitions`); + for (const [name, values] of [ + ['calibrationSamplesMs', workload.calibrationSamplesMs], + ['warmups', workload.warmups], + ['warmupElapsedMs', workload.warmupElapsedMs], + ['measured', workload.measured], + ['measuredElapsedMs', workload.measuredElapsedMs], + ]) { + if ( + !Array.isArray(values) || + values.length === 0 || + values.some((value) => !positiveFinite(value)) + ) + throw new TypeError( + `${label} has ${ + name === 'measured' || name === 'warmups' + ? 'nonpositive timings' + : `invalid ${name}` + }` + ); + } + if ( + workload.warmups.length !== workload.warmupElapsedMs.length || + workload.measured.length !== workload.measuredElapsedMs.length + ) + throw new TypeError(`${label} has inconsistent elapsed timings`); + if ( + typeof workload.structural !== 'string' || + !workload.structural || + workload.checksum !== workload.structural || + !Number.isInteger(workload.consumed) || + workload.consumed < 0 + ) + throw new TypeError(`${label} has invalid structural digest or checksum`); +} diff --git a/scripts/benchmark/validate.js b/scripts/benchmark/validate.js new file mode 100644 index 0000000..bd99f35 --- /dev/null +++ b/scripts/benchmark/validate.js @@ -0,0 +1,109 @@ +/* oxlint-disable complexity */ +import { + DECISION_CONFIG_VERSION, + MIN_VALID_BLOCKS, + migrateLegacyDecisionConfig, + validateDecisionConfig, +} from './config.js'; +import { validateCorpusArtifact } from './validate-corpus.js'; +import { validateParserRecord } from './validate-parser.js'; + +/** @param {unknown} artifact @return {object} */ +export function validateSchemaV2Artifact(artifact) { + if (!artifact || typeof artifact !== 'object' || artifact.schema !== 2) + throw new TypeError('artifact must use schema 2'); + if ( + typeof artifact.seed !== 'number' || + !Number.isInteger(artifact.seed) || + artifact.seed < 0 || + artifact.seed > 0xffffffff + ) + throw new TypeError('artifact has an invalid seed'); + if (artifact.benchmark === 'corpus') return validateCorpusArtifact(artifact); + if (!Array.isArray(artifact.blocks)) + throw new TypeError('artifact must contain blocks'); + const config = + artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION + ? validateDecisionConfig(artifact.config, 'parser artifact') + : migrateLegacyDecisionConfig(artifact, 'parser'); + const requestedBlocks = config.requestedBlocks; + const maxAttempts = config.maxAttempts; + if ( + !Number.isInteger(requestedBlocks) || + requestedBlocks < MIN_VALID_BLOCKS || + requestedBlocks % 2 !== 0 + ) + throw new TypeError('artifact has an invalid requested block count'); + if (!Number.isInteger(maxAttempts) || maxAttempts < requestedBlocks) + throw new TypeError('artifact has an invalid maxAttempts'); + const minimumBlocks = config.minimumBlocks; + const underFloor = artifact.blocks.length < minimumBlocks; + const isInconclusiveUnderfloorArtifact = + underFloor && + artifact.analysis?.status === 'inconclusive' && + Array.isArray(artifact.attempts) && + artifact.attempts.length > 0; + const isCorrectnessFailure = + artifact.analysis?.status === 'correctness-failure'; + if (underFloor && !isInconclusiveUnderfloorArtifact && !isCorrectnessFailure) + throw new TypeError( + `artifact has fewer than ${minimumBlocks} valid blocks` + ); + const expected = new Set(artifact.workloadKeys ?? []); + if (expected.size === 0) + throw new TypeError('artifact must contain workload keys'); + for (const [blockIndex, block] of artifact.blocks.entries()) + validateParserRecord(block, `block ${blockIndex}`, expected); + if (artifact.attempts !== undefined) { + if (!Array.isArray(artifact.attempts) || artifact.attempts.length === 0) + throw new TypeError('artifact attempts must be a non-empty array'); + if (artifact.attempts.length > maxAttempts) + throw new TypeError('artifact contains more attempts than maxAttempts'); + for (const [attemptIndex, attempt] of artifact.attempts.entries()) { + validateParserRecord( + attempt, + `attempt ${attemptIndex}`, + expected, + true, + config.driftThreshold + ); + if (attempt.index !== attemptIndex) + throw new TypeError( + `attempt ${attemptIndex} has an inconsistent index` + ); + } + const accepted = artifact.attempts.filter((attempt) => !attempt.rejected); + if (accepted.length !== artifact.blocks.length) + throw new TypeError('artifact attempts and blocks are inconsistent'); + for (const [index, block] of artifact.blocks.entries()) { + const attempt = accepted[index]; + if (JSON.stringify(attempt) !== JSON.stringify(block)) + throw new TypeError('artifact attempts and blocks are inconsistent'); + } + } else if (underFloor) { + throw new TypeError('under-floor artifact must retain attempts'); + } + if (artifact.blocks.length > requestedBlocks) + throw new TypeError('artifact contains more blocks than requested'); + if (artifact.blocks.length === requestedBlocks) { + const orders = artifact.blocks.map((block) => block.processOrder); + if ( + orders.filter((order) => order === 'baseline-first').length !== + requestedBlocks / 2 + ) + throw new TypeError('accepted blocks have an unbalanced process order'); + } + if (artifact.blocks.length >= minimumBlocks) { + const orderCounts = { + 'baseline-first': artifact.blocks.filter( + (block) => block.processOrder === 'baseline-first' + ).length, + 'candidate-first': artifact.blocks.filter( + (block) => block.processOrder === 'candidate-first' + ).length, + }; + if (orderCounts['baseline-first'] !== orderCounts['candidate-first']) + throw new TypeError('accepted blocks have an unbalanced process order'); + } + return artifact; +} diff --git a/scripts/lib/benchmark.js b/scripts/lib/benchmark.js deleted file mode 100644 index ad4e2c8..0000000 --- a/scripts/lib/benchmark.js +++ /dev/null @@ -1,1669 +0,0 @@ -/* oxlint-disable no-bitwise, complexity */ -// Small, deterministic benchmark primitives. This module intentionally has -// no third-party dependencies: benchmark results should be reproducible with -// the package's normal development installation. -import { - readFileSync, - readdirSync, - statSync, - mkdtempSync, - mkdirSync, - rmSync, -} from 'node:fs'; -import { createHash } from 'node:crypto'; -import { execFileSync, spawnSync } from 'node:child_process'; -import { cpus, loadavg, platform, release, arch } from 'node:os'; -import { join, relative } from 'node:path'; -import { - CORPUS_CATEGORIES, - NEUTRAL_CORPUS_CATEGORIES, -} from './corpus-policy.js'; - -export const TARGET_BATCH_MS = 25; -export const MIN_WARMUPS = 5; -export const MAX_WARMUPS = 10; -export const MEASURED_BATCHES = 6; -export const DRIFT_THRESHOLD = 0.15; -export const BOOTSTRAP_RESAMPLES = 100_000; -export const NON_REGRESSION_MARGIN = 1.1; -export const CORPUS_EQUIVALENCE_MARGIN = 1.1; -export const GROWTH_THRESHOLD = 2.5; -export const MIN_VALID_BLOCKS = 20; -export const DECISION_CONFIG_VERSION = 3; -export const PRECISION_METHOD = 'family-adjusted-interval-width'; -export const DECISION_INTERVAL_METHOD = - 'stratified-max-t-studentized-bootstrap'; -export const CORPUS_INTERVAL_METHOD = - 'paired-replicate-order-log-ratio-bootstrap'; - -// These are the fields that determine the interpretation of a schema-v2 -// artifact. Keep this list here rather than duplicating it in the parser and -// corpus analyzers: a reanalysis must have one authoritative contract. -export const DECISION_CONFIG_KEYS = [ - 'decisionConfigVersion', - 'requestedBlocks', - 'minimumBlocks', - 'maxAttempts', - 'targetBatchMs', - 'warmupMinimum', - 'warmupMaximum', - 'measuredBatchCount', - 'driftThreshold', - 'bootstrapResamples', - 'confidence', - 'runtimeNonRegressionMargin', - 'equivalenceMargin', - 'precisionMargin', - 'growthThreshold', - 'orderInteractionThreshold', - 'precisionMethod', - 'intervalMethod', -]; - -/** @param {object} config @param {string} kind */ -export function validateDecisionConfig(config, kind = 'artifact') { - if (!config || typeof config !== 'object') - throw new TypeError(`${kind} is missing decision configuration`); - for (const key of DECISION_CONFIG_KEYS) - if (!Object.hasOwn(config, key)) - throw new TypeError(`${kind} is missing decision parameter ${key}`); - if (config.decisionConfigVersion !== DECISION_CONFIG_VERSION) - throw new TypeError( - `${kind} has an invalid decision configuration version` - ); - for (const key of [ - 'requestedBlocks', - 'minimumBlocks', - 'maxAttempts', - 'measuredBatchCount', - 'bootstrapResamples', - ]) - if (!Number.isInteger(config[key]) || config[key] <= 0) - throw new TypeError(`${kind} has invalid decision parameter ${key}`); - if (config.requestedBlocks < config.minimumBlocks) - throw new TypeError(`${kind} has an invalid requested block count`); - if (config.maxAttempts < config.requestedBlocks) - throw new TypeError(`${kind} has an invalid maxAttempts`); - for (const key of ['warmupMinimum', 'warmupMaximum']) - if (!Number.isInteger(config[key]) || config[key] < 0) - throw new TypeError(`${kind} has invalid decision parameter ${key}`); - if (config.warmupMaximum < config.warmupMinimum) - throw new TypeError(`${kind} has an invalid warm-up range`); - for (const key of [ - 'targetBatchMs', - 'driftThreshold', - 'bootstrapResamples', - 'runtimeNonRegressionMargin', - 'equivalenceMargin', - 'precisionMargin', - 'growthThreshold', - 'orderInteractionThreshold', - ]) - if ( - typeof config[key] !== 'number' || - !Number.isFinite(config[key]) || - config[key] <= 0 - ) - throw new TypeError(`${kind} has invalid decision parameter ${key}`); - if ( - typeof config.confidence !== 'number' || - !Number.isFinite(config.confidence) || - config.confidence <= 0 || - config.confidence >= 1 - ) - throw new TypeError(`${kind} has invalid decision parameter confidence`); - if (config.runtimeNonRegressionMargin < 1 || config.equivalenceMargin < 1) - throw new TypeError(`${kind} has an invalid ratio margin`); - if (config.precisionMargin < 1 || config.growthThreshold <= 1) - throw new TypeError(`${kind} has an invalid precision or growth margin`); - if ( - ![DECISION_INTERVAL_METHOD, CORPUS_INTERVAL_METHOD].includes( - config.intervalMethod - ) - ) - throw new TypeError(`${kind} has an invalid interval method`); - if (config.precisionMethod !== PRECISION_METHOD) - throw new TypeError(`${kind} has an invalid precision method`); - return config; -} - -/** - * Migrate the pre-contract artifacts that were emitted by the first schema-v2 - * implementation. This is intentionally the only place where repository - * defaults are applied. New artifacts must carry decisionConfigVersion: 3. - */ -export function migrateLegacyDecisionConfig( - artifact, - kind = artifact?.benchmark === 'corpus' ? 'corpus' : 'parser' -) { - const source = artifact?.config ?? {}; - const blocks = artifact?.blocks?.length ?? artifact?.replicates?.length ?? 0; - const requestedBlocks = - source.requestedBlocks ?? source.blocks ?? source.replicates ?? blocks; - const base = { - decisionConfigVersion: DECISION_CONFIG_VERSION, - requestedBlocks, - minimumBlocks: source.minimumBlocks ?? MIN_VALID_BLOCKS, - maxAttempts: source.maxAttempts ?? Math.max(30, requestedBlocks), - targetBatchMs: source.targetBatchMs ?? TARGET_BATCH_MS, - warmupMinimum: - source.warmupMinimum ?? (kind === 'corpus' ? 0 : MIN_WARMUPS), - warmupMaximum: - source.warmupMaximum ?? (kind === 'corpus' ? 0 : MAX_WARMUPS), - measuredBatchCount: - source.measuredBatchCount ?? source.batches ?? MEASURED_BATCHES, - driftThreshold: source.driftThreshold ?? DRIFT_THRESHOLD, - bootstrapResamples: source.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, - confidence: source.confidence ?? 0.95, - runtimeNonRegressionMargin: - source.runtimeNonRegressionMargin ?? NON_REGRESSION_MARGIN, - equivalenceMargin: - source.equivalenceMargin ?? - (kind === 'corpus' ? CORPUS_EQUIVALENCE_MARGIN : 1.1), - precisionMargin: source.precisionMargin ?? 1.1, - precisionMethod: PRECISION_METHOD, - growthThreshold: source.growthThreshold ?? GROWTH_THRESHOLD, - orderInteractionThreshold: - source.orderInteractionThreshold ?? Math.log(1.1), - intervalMethod: - source.intervalMethod ?? - (kind === 'corpus' ? CORPUS_INTERVAL_METHOD : DECISION_INTERVAL_METHOD), - }; - if (kind === 'corpus') - return { - ...base, - replicates: source.replicates ?? blocks, - batches: source.batches ?? 6, - calibrationOrderBalanced: source.calibrationOrderBalanced ?? false, - }; - return base; -} - -/** @param {object} artifact @param {string} kind @return {object} */ -export function decisionConfigForArtifact(artifact, kind) { - if (artifact?.config?.decisionConfigVersion === DECISION_CONFIG_VERSION) - return validateDecisionConfig(artifact.config, `${kind} artifact`); - if (artifact?.config?.decisionConfigVersion !== undefined) - throw new TypeError( - `${kind} artifact has an invalid decision configuration version` - ); - return validateDecisionConfig( - migrateLegacyDecisionConfig(artifact, kind), - `${kind} legacy artifact` - ); -} - -/** @param {unknown} seed @return {number} */ -export function normalizeSeed(seed) { - if (typeof seed === 'number') { - if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) - throw new TypeError('seed must be an unsigned 32-bit integer'); - return seed >>> 0; - } - if (typeof seed === 'string' && /^\d+$/.test(seed)) { - const value = Number(seed); - if (Number.isSafeInteger(value) && value <= 0xffffffff) return value >>> 0; - } - throw new TypeError('seed must be an unsigned 32-bit integer'); -} - -/** @param {number} seed @return {() => number} */ -export function seededRandom(seed) { - let state = normalizeSeed(seed) || 0x9e3779b9; - return () => { - state = Math.imul(state ^ (state >>> 16), 0x21f0aaad); - state = Math.imul(state ^ (state >>> 15), 0x735a2d97); - state ^= state >>> 15; - return (state >>> 0) / 0x1_0000_0000; - }; -} - -/** @template T @param {readonly T[]} values @param {number} seed @return {T[]} */ -export function seededShuffle(values, seed) { - const result = [...values]; - const random = seededRandom(seed); - for (let i = result.length - 1; i > 0; i--) { - const j = Math.floor(random() * (i + 1)); - [result[i], result[j]] = [result[j], result[i]]; - } - return result; -} - -/** - * Return a shuffled, balanced process schedule. Values are deliberately - * explicit (`baseline-first`/`candidate-first`) so the artifact is auditable. - * - * @param {number} count - * @param {number} seed - */ -export function balancedOrder(count, seed) { - if (!Number.isInteger(count) || count < 2 || count % 2 !== 0) - throw new RangeError('a balanced schedule requires a positive even count'); - return seededShuffle( - Array.from({ length: count }, (_, index) => - index < count / 2 ? 'baseline-first' : 'candidate-first' - ), - seed - ); -} - -/** - * Return a deterministic, balanced schedule for the corpus calibration order. - * Odd counts differ by at most one; the first label receives the extra slot. - */ -export function balancedSchedule(count, first, second, seed) { - if (!Number.isInteger(count) || count <= 0) - throw new RangeError('schedule count must be positive'); - if ( - typeof first !== 'string' || - typeof second !== 'string' || - first === second - ) - throw new TypeError('schedule labels must be distinct strings'); - return seededShuffle( - Array.from({ length: count }, (_, index) => - index < Math.ceil(count / 2) ? first : second - ), - seed - ); -} - -/** @param {number} count @param {number} seed @return {number[]} */ -export function bootstrapIndices(count, seed) { - if (!Number.isInteger(count) || count <= 0) - throw new RangeError('cannot resample an empty collection'); - const random = seededRandom(seed); - return Array.from({ length: count }, () => Math.floor(random() * count)); -} - -/** @param {number[]} values @return {number} */ -function finiteValues(values) { - if (!Array.isArray(values) || values.length === 0) - throw new RangeError('expected a non-empty numeric array'); - if ( - values.some((value) => typeof value !== 'number' || !Number.isFinite(value)) - ) - throw new TypeError('values must be finite numbers'); - return values; -} - -/** @param {number[]} values @return {number} */ -export function median(values) { - const sorted = [...finiteValues(values)].sort((a, b) => a - b); - const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 - ? sorted[middle] - : (sorted[middle - 1] + sorted[middle]) / 2; -} - -/** @param {number[]} values @param {number} p @return {number} */ -export function percentile(values, p) { - const sorted = [...finiteValues(values)].sort((a, b) => a - b); - if (!Number.isFinite(p) || p < 0 || p > 1) - throw new RangeError('p must be in [0, 1]'); - const position = (sorted.length - 1) * p; - const lower = Math.floor(position); - const upper = Math.ceil(position); - return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); -} - -/** @param {number[]} values @return {{median: number, q1: number, q3: number, min: number, max: number, sd: number, cv: number, relativeSpan: number}} */ -export function variationMetrics(values) { - const finite = finiteValues(values); - const mean = finite.reduce((sum, value) => sum + value, 0) / finite.length; - const variance = - finite.reduce((sum, value) => sum + (value - mean) ** 2, 0) / - Math.max(1, finite.length - 1); - return { - median: median(finite), - q1: percentile(finite, 0.25), - q3: percentile(finite, 0.75), - min: Math.min(...finite), - max: Math.max(...finite), - sd: Math.sqrt(variance), - cv: mean === 0 ? Infinity : Math.sqrt(variance) / Math.abs(mean), - relativeSpan: - mean === 0 - ? Infinity - : (Math.max(...finite) - Math.min(...finite)) / Math.abs(mean), - }; -} - -export function variation(values) { - return variationMetrics(values); -} - -/** @param {number[]} values @return {number} */ -export function geometricMean(values) { - const finite = finiteValues(values); - if (finite.some((value) => value <= 0)) - throw new RangeError('geometric mean requires positive values'); - return Math.exp( - finite.reduce((sum, value) => sum + Math.log(value), 0) / finite.length - ); -} - -/** @param {number} candidate @param {number} baseline @return {number} */ -export function logRatio(candidate, baseline) { - if ( - !(candidate > 0) || - !(baseline > 0) || - !Number.isFinite(candidate) || - !Number.isFinite(baseline) - ) - throw new RangeError('runtime ratios require positive finite timings'); - return Math.log(candidate / baseline); -} - -/** @param {number[]} values @return {{mean: number, sd: number, lower: number, upper: number}} */ -export function ordinaryInterval(values) { - const finite = finiteValues(values); - const mean = finite.reduce((sum, value) => sum + value, 0) / finite.length; - const sd = Math.sqrt( - finite.reduce((sum, value) => sum + (value - mean) ** 2, 0) / - Math.max(1, finite.length - 1) - ); - const half = (1.96 * sd) / Math.sqrt(finite.length); - return { mean, sd, lower: mean - half, upper: mean + half }; -} - -/** @param {number[]} values @return {{mean: number, sd: number, lower: number, upper: number}} */ -export function oneSidedInterval(values) { - const interval = ordinaryInterval(values); - const half = (1.6448536269514722 * interval.sd) / Math.sqrt(values.length); - return { - ...interval, - lower: interval.mean - half, - upper: interval.mean + half, - }; -} - -/** @param {number[]} values @return {{meanLog: number, ratio: number, ordinary: object, logValues: number[]}} */ -export function pairedRatioSummary(values) { - const logValues = finiteValues(values); - const ordinary = ordinaryInterval(logValues); - return { - meanLog: ordinary.mean, - ratio: Math.exp(ordinary.mean), - ordinary, - logValues, - }; -} - -/** @param {number[]} values @param {number} seed @param {number} [resamples] */ -export function bootstrapMeanInterval( - values, - seed, - resamples = BOOTSTRAP_RESAMPLES, - confidence = 0.95 -) { - const source = finiteValues(values); - if (!Number.isInteger(resamples) || resamples <= 0) - throw new RangeError('resamples must be positive'); - if (!(confidence > 0 && confidence < 1)) - throw new RangeError('confidence must be in (0, 1)'); - const random = seededRandom(seed); - const means = Array.from({ length: resamples }); - for (let sample = 0; sample < resamples; sample++) { - let sum = 0; - for (let i = 0; i < source.length; i++) - sum += source[Math.floor(random() * source.length)]; - means[sample] = sum / source.length; - } - return { - lower: percentile(means, (1 - confidence) / 2), - upper: percentile(means, 1 - (1 - confidence) / 2), - resamples, - }; -} - -/** @param {number[]} logValues @param {number} seed @param {number} [resamples] */ -export function bootstrapRatioInterval( - logValues, - seed, - resamples = BOOTSTRAP_RESAMPLES, - confidence = 0.95 -) { - const interval = bootstrapMeanInterval( - logValues, - seed, - resamples, - confidence - ); - return { - ...interval, - lowerRatio: Math.exp(interval.lower), - upperRatio: Math.exp(interval.upper), - }; -} - -/** - * Bootstrap columns from the same row schedule. A row is one complete - * benchmark block, so all endpoints in a resample retain their correlation. - * The returned intervals are in the input (usually log-ratio) domain. - * - * @param {number[][]} rows - * @param {number} seed - * @param {number} [familyCount] - * @param {number} [resamples] - */ -export function bootstrapPairedIntervals( - rows, - seed, - familyCount = rows[0]?.length ?? 1, - resamples = BOOTSTRAP_RESAMPLES -) { - if (!Array.isArray(rows) || rows.length === 0) - throw new RangeError('cannot bootstrap an empty matrix'); - if (!Number.isInteger(resamples) || resamples <= 0) - throw new RangeError('resamples must be positive'); - const width = rows[0]?.length; - if (!Number.isInteger(width) || width <= 0) - throw new RangeError('cannot bootstrap a matrix without columns'); - if (familyCount !== width) - throw new RangeError('familyCount must equal the matrix width'); - for (const row of rows) { - if (!Array.isArray(row) || row.length !== width) { - throw new TypeError('bootstrap rows must have equal widths'); - } - finiteValues(row); - } - - const observed = Array(width).fill(0); - for (const row of rows) - for (let column = 0; column < width; column++) - observed[column] += row[column]; - for (let column = 0; column < width; column++) - observed[column] /= rows.length; - - const standardErrors = Array.from({ length: width }, (_, column) => { - const variance = - rows.reduce( - (sum, row) => sum + (row[column] - observed[column]) ** 2, - 0 - ) / Math.max(1, rows.length - 1); - return Math.sqrt(variance / rows.length); - }); - const distributions = Array.from({ length: width }, () => Array(resamples)); - const studentizedDeviations = Array(resamples); - const random = seededRandom(seed); - for (let sample = 0; sample < resamples; sample++) { - const sums = Array(width).fill(0); - for (let i = 0; i < rows.length; i++) { - const row = rows[Math.floor(random() * rows.length)]; - for (let column = 0; column < width; column++) - sums[column] += row[column]; - } - let maxDeviation = 0; - for (let column = 0; column < width; column++) { - const mean = sums[column] / rows.length; - distributions[column][sample] = mean; - const standardError = standardErrors[column]; - let deviation; - if (standardError === 0) - deviation = mean === observed[column] ? 0 : Infinity; - else deviation = Math.abs((mean - observed[column]) / standardError); - maxDeviation = Math.max(maxDeviation, deviation); - } - studentizedDeviations[sample] = maxDeviation; - } - - const familyCritical = percentile(studentizedDeviations, 0.95); - return { - familyCount, - resamples, - method: 'max-t-studentized-bootstrap', - standardErrors, - observed, - intervals: distributions.map((values, column) => ({ - lower: percentile(values, 0.025), - upper: percentile(values, 0.975), - oneSidedLower: percentile(values, 0.05), - oneSidedUpper: percentile(values, 0.95), - familyLower: observed[column] - familyCritical * standardErrors[column], - familyUpper: observed[column] + familyCritical * standardErrors[column], - familyCritical, - })), - }; -} - -/** - * Studentized max-T bootstrap for a two-stratum estimator. Each row is an - * independent experimental unit and is sampled as a complete row, which - * preserves correlation between endpoint columns. - * - * The observed estimator gives equal weight to the two strata. The bootstrap - * standard error is recomputed for every resample. If a resample has zero - * variance but its estimate differs from the observed estimate, its statistic - * uses the observed standard error for that endpoint. - * - * @param {{rows: number[][], strata: string[], seed: number, resamples?: number, confidence?: number}} options - */ -export function bootstrapStratifiedMaxT({ - rows, - strata, - seed, - resamples = BOOTSTRAP_RESAMPLES, - confidence = 0.95, -}) { - if (!Array.isArray(rows) || rows.length === 0) - throw new RangeError('cannot bootstrap an empty matrix'); - if (!Array.isArray(strata) || strata.length !== rows.length) - throw new RangeError('strata must match bootstrap rows'); - if (!Number.isInteger(resamples) || resamples <= 0) - throw new RangeError('resamples must be positive'); - if (!(confidence > 0 && confidence < 1)) - throw new RangeError('confidence must be in (0, 1)'); - const width = rows[0]?.length; - if (!Number.isInteger(width) || width <= 0) - throw new RangeError('cannot bootstrap a matrix without columns'); - for (const row of rows) { - if (!Array.isArray(row) || row.length !== width) - throw new TypeError('bootstrap rows must have equal widths'); - finiteValues(row); - } - const labels = [...new Set(strata)]; - if (labels.length !== 2) - throw new RangeError('stratified max-T requires exactly two strata'); - const ordered = labels.sort(); - const strataRows = ordered.map((label) => - rows - .map((row, index) => ({ row, label: strata[index] })) - .filter((item) => item.label === label) - .map((item) => item.row) - .sort(compareRows) - ); - if (strataRows.some((group) => group.length === 0)) - throw new RangeError('cannot resample an empty stratum'); - - const observedByStratum = strataRows.map((group) => - columnMeans(group, width) - ); - const observed = Array.from( - { length: width }, - (_, column) => - observedByStratum.reduce((sum, means) => sum + means[column], 0) / - observedByStratum.length - ); - const observedSE = standardErrorsForSample( - observedByStratum, - strataRows, - width - ); - const distributions = Array.from({ length: width }, () => []); - const studentizedMax = []; - let degenerateResamples = 0; - let degenerateFallbacks = 0; - const random = seededRandom(seed); - - for (let sample = 0; sample < resamples; sample++) { - const sampled = strataRows.map((group) => - Array.from( - { length: group.length }, - () => group[Math.floor(random() * group.length)] - ) - ); - const sampledMeans = sampled.map((group) => columnMeans(group, width)); - const effects = Array.from( - { length: width }, - (_, column) => - sampledMeans.reduce((sum, means) => sum + means[column], 0) / - sampledMeans.length - ); - const sampledSE = standardErrorsForSample(sampledMeans, sampled, width); - let maxT = 0; - let hasDegenerateEndpoint = false; - for (let column = 0; column < width; column++) { - distributions[column].push(effects[column]); - let statistic; - if (sampledSE[column] === 0) { - hasDegenerateEndpoint = true; - const deviation = effects[column] - observed[column]; - if (deviation === 0) statistic = 0; - else { - if (observedSE[column] === 0) - throw new RangeError( - 'nonzero bootstrap deviation has no positive standard error' - ); - statistic = deviation / observedSE[column]; - degenerateFallbacks++; - } - } else { - statistic = (effects[column] - observed[column]) / sampledSE[column]; - } - maxT = Math.max(maxT, Math.abs(statistic)); - } - if (hasDegenerateEndpoint) degenerateResamples++; - studentizedMax.push(maxT); - } - - const familyCritical = percentile(studentizedMax, confidence); - return { - method: DECISION_INTERVAL_METHOD, - strata: ordered, - familyCount: width, - resamples, - confidence, - observed, - standardErrors: observedSE, - familyCritical, - degenerateResamples, - degenerateFallbacks, - intervals: distributions.map((values, column) => { - const alpha = (1 - confidence) / 2; - const halfWidth = - observedSE[column] === 0 ? 0 : familyCritical * observedSE[column]; - return { - lower: percentile(values, alpha), - upper: percentile(values, 1 - alpha), - oneSidedLower: observed[column] - halfWidth, - oneSidedUpper: observed[column] + halfWidth, - familyLower: observed[column] - halfWidth, - familyUpper: observed[column] + halfWidth, - familyCritical, - }; - }), - }; -} - -function compareRows(left, right) { - for (let index = 0; index < left.length; index++) { - if (left[index] !== right[index]) return left[index] - right[index]; - } - return 0; -} - -function columnMeans(rows, width) { - const means = Array(width).fill(0); - for (const row of rows) - for (let column = 0; column < width; column++) means[column] += row[column]; - return means.map((sum) => sum / rows.length); -} - -function standardErrorsForSample(stratumMeans, sampledRows, width) { - return Array.from({ length: width }, (_, column) => { - let variance = 0; - for (let stratum = 0; stratum < sampledRows.length; stratum++) { - const rows = sampledRows[stratum]; - const mean = stratumMeans[stratum][column]; - const within = - rows.reduce((sum, row) => sum + (row[column] - mean) ** 2, 0) / - Math.max(1, rows.length - 1); - variance += within / rows.length; - } - return Math.sqrt(variance / stratumMeans.length ** 2); - }); -} - -/** @param {number[]} x @param {number[]} y @return {{alpha: number, beta: number, r2: number, n: number}} */ -export function linearRegression(x, y) { - if ( - !Array.isArray(x) || - !Array.isArray(y) || - x.length !== y.length || - x.length < 2 - ) - throw new RangeError( - 'linear regression requires paired arrays of length at least two' - ); - finiteValues(x); - finiteValues(y); - const xMean = x.reduce((sum, value) => sum + value, 0) / x.length; - const yMean = y.reduce((sum, value) => sum + value, 0) / y.length; - const ssX = x.reduce((sum, value) => sum + (value - xMean) ** 2, 0); - if (ssX === 0) - throw new RangeError('linear regression requires varying x values'); - const covariance = x.reduce( - (sum, value, index) => sum + (value - xMean) * (y[index] - yMean), - 0 - ); - const beta = covariance / ssX; - const alpha = yMean - beta * xMean; - const ssY = y.reduce((sum, value) => sum + (value - yMean) ** 2, 0); - const residual = y.reduce( - (sum, value, index) => sum + (value - (alpha + beta * x[index])) ** 2, - 0 - ); - return { alpha, beta, r2: ssY === 0 ? 1 : 1 - residual / ssY, n: x.length }; -} - -export function regression(x, y) { - return linearRegression(x, y); -} - -/** @param {number[]} values @param {number} familyCount */ -export function familyAdjustedBounds(values, familyCount = 1) { - const interval = ordinaryInterval(values); - const z = normalQuantile(1 - 0.05 / Math.max(1, familyCount)); - const half = (z * interval.sd) / Math.sqrt(values.length); - return { - lower: interval.mean - half, - upper: interval.mean + half, - z, - lowerRatio: Math.exp(interval.mean - half), - upperRatio: Math.exp(interval.mean + half), - }; -} - -/** Acklam's inverse-normal approximation, sufficient for benchmark intervals. */ -function normalQuantile(p) { - const a = [ - -39.6968302866538, 220.946098424521, -275.928510446969, 138.357751867269, - -30.6647980661472, 2.50662827745924, - ]; - const b = [ - -54.4760987982241, 161.585836858041, -155.698979859887, 66.8013118877197, - -13.2806815528857, - ]; - const c = [ - -0.00778489400243029, -0.322396458041136, -2.40075827716184, - -2.54973253934373, 4.37466414146497, 2.93816398269878, - ]; - const d = [ - 0.00778469570904146, 0.32246712907004, 2.445134137143, 3.75440866190742, - ]; - const plow = 0.02425; - const phigh = 1 - plow; - if (p <= 0 || p >= 1) - throw new RangeError('normal quantile requires p in (0, 1)'); - if (p < plow) { - const q = Math.sqrt(-2 * Math.log(p)); - const numerator = horner(c, q); - const denominator = horner([...d, 1], q); - return numerator / denominator; - } - if (p > phigh) { - const q = Math.sqrt(-2 * Math.log(1 - p)); - const numerator = horner(c, q); - const denominator = horner([...d, 1], q); - return -numerator / denominator; - } - const q = p - 0.5; - const r = q * q; - const numerator = horner(a, r) * q; - const denominator = horner([...b, 1], r); - return numerator / denominator; -} - -/** @param {number[]} coefficients @param {number} value */ -function horner(coefficients, value) { - return coefficients.reduce( - (result, coefficient) => result * value + coefficient, - 0 - ); -} - -/** @param {string} file @return {string} */ -export function sha256File(file) { - return createHash('sha256').update(readFileSync(file)).digest('hex'); -} - -/** @param {string} directory @return {string} */ -export function sourceTreeHash(directory) { - const files = []; - function visit(current) { - for (const name of readdirSync(current).sort()) { - const file = join(current, name); - const stat = statSync(file); - if (stat.isDirectory()) visit(file); - else files.push([relative(directory, file), readFileSync(file)]); - } - } - visit(directory); - return hashSourceFiles(files); -} - -function hashSourceFiles(files) { - const hash = createHash('sha256'); - for (const [name, data] of files) - hash.update(name).update('\0').update(data).update('\0'); - return hash.digest('hex'); -} - -function benchmarkHarnessFiles(root) { - const files = []; - const scripts = join(root, 'scripts'); - const lib = join(scripts, 'lib'); - function addTree(directory, prefix) { - for (const name of readdirSync(directory).sort()) { - const file = join(directory, name); - const stat = statSync(file); - if (stat.isDirectory()) addTree(file, `${prefix}/${name}`); - else files.push([`${prefix}/${name}`, readFileSync(file)]); - } - } - addTree(lib, 'scripts/lib'); - for (const name of readdirSync(scripts).sort()) { - if (!/^benchmark(?:-.+)?\.js$/.test(name) && !name.endsWith('worker.js')) - continue; - const file = join(scripts, name); - if (statSync(file).isFile()) - files.push([`scripts/${name}`, readFileSync(file)]); - } - return files.sort(([a], [b]) => a.localeCompare(b)); -} - -export function benchmarkHarnessHash(root) { - return hashSourceFiles(benchmarkHarnessFiles(root)); -} - -function git(root, args) { - return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); -} - -function gitBuffer(root, args) { - return execFileSync('git', args, { cwd: root }); -} - -function gitSourceTreeHash(root, ref) { - const paths = git(root, ['ls-tree', '-r', '--name-only', ref, '--', 'src']) - .split('\n') - .filter(Boolean); - return hashSourceFiles( - paths.map((path) => [ - path.slice('src/'.length), - gitBuffer(root, ['show', `${ref}:${path}`]), - ]) - ); -} - -/** @param {string} root @param {string} baselineRef */ -export function collectBenchmarkProvenance( - root, - { - baselineRef = 'HEAD', - benchmark = 'unknown', - corpusPath, - command = process.argv.join(' '), - } = {} -) { - const currentCommit = git(root, ['rev-parse', 'HEAD']); - const baselineCommit = git(root, ['rev-parse', baselineRef]); - const lockfile = join(root, 'pnpm-lock.yaml'); - let governor = null; - try { - governor = readFileSync( - '/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor', - 'utf8' - ).trim(); - } catch { - // Linux CPU governor is optional on other operating systems. - } - return { - createdAt: new Date().toISOString(), - command, - benchmark, - benchmarkHarnessHash: benchmarkHarnessHash(root), - baselineCommit, - worktreeCommit: currentCommit, - baselineSourceHash: gitSourceTreeHash(root, baselineRef), - worktreeSourceHash: sourceTreeHash(join(root, 'src')), - baselineSourceTreeHash: gitSourceTreeHash(root, baselineRef), - worktreeSourceTreeHash: sourceTreeHash(join(root, 'src')), - sourceTreeHash: sourceTreeHash(join(root, 'src')), - lockfileHash: sha256File(lockfile), - corpusHash: corpusPath ? sha256File(corpusPath) : null, - dirty: git(root, ['status', '--porcelain']) !== '', - node: process.version, - v8: process.versions.v8, - cpu: cpus()[0]?.model ?? 'unknown', - cpuCount: cpus().length, - platform: `${platform()} ${release()} ${arch()}`, - os: `${platform()} ${release()} ${arch()}`, - loadAverage: loadavg(), - linuxCpuGovernor: governor, - }; -} - -export function collectEnvironment(root, baselineRef = 'HEAD') { - return collectBenchmarkProvenance(root, { baselineRef }); -} - -/** @param {string} root @param {string} ref @return {{directory: string, sourceRoot: string, commit: string, cleanup: () => void}} */ -export function materializeBaseline(root, ref) { - // Keep the temporary tree below the project so ESM's normal package - // resolution can reach the current checkout's installed dependencies. - const directory = mkdtempSync(join(root, '.postcss-calc-baseline-')); - mkdirSync(join(directory, 'src'), { recursive: true }); - try { - const archive = execFileSync('git', ['archive', ref, '--', 'src'], { - cwd: root, - }); - execFileSync('tar', ['-x', '-f', '-', '-C', directory], { input: archive }); - return { - directory, - sourceRoot: join(directory, 'src'), - commit: git(root, ['rev-parse', ref]), - cleanup: () => rmSync(directory, { recursive: true, force: true }), - }; - } catch (error) { - rmSync(directory, { recursive: true, force: true }); - throw error; - } -} - -/** @param {string} worker @param {object} payload @param {string} cwd */ -export function runChild(worker, payload, cwd) { - const result = spawnSync(process.execPath, [worker], { - cwd, - encoding: 'utf8', - input: JSON.stringify(payload), - maxBuffer: 64 * 1024 * 1024, - env: { ...process.env }, - }); - if (result.error) throw result.error; - if (result.status !== 0) - throw new Error( - `benchmark child failed (${result.status}): ${result.stderr || result.stdout}` - ); - try { - return JSON.parse(result.stdout); - } catch { - throw new Error( - `benchmark child returned invalid JSON: ${result.stdout.slice(0, 500)}` - ); - } -} - -/** @param {unknown} artifact @return {object} */ -export function validateSchemaV2Artifact(artifact) { - if (!artifact || typeof artifact !== 'object' || artifact.schema !== 2) - throw new TypeError('artifact must use schema 2'); - if ( - typeof artifact.seed !== 'number' || - !Number.isInteger(artifact.seed) || - artifact.seed < 0 || - artifact.seed > 0xffffffff - ) - throw new TypeError('artifact has an invalid seed'); - if (artifact.benchmark === 'corpus') return validateCorpusArtifact(artifact); - if (!Array.isArray(artifact.blocks)) - throw new TypeError('artifact must contain blocks'); - const config = - artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION - ? validateDecisionConfig(artifact.config, 'parser artifact') - : migrateLegacyDecisionConfig(artifact, 'parser'); - const requestedBlocks = config.requestedBlocks; - const maxAttempts = config.maxAttempts; - if ( - !Number.isInteger(requestedBlocks) || - requestedBlocks < MIN_VALID_BLOCKS || - requestedBlocks % 2 !== 0 - ) - throw new TypeError('artifact has an invalid requested block count'); - if (!Number.isInteger(maxAttempts) || maxAttempts < requestedBlocks) - throw new TypeError('artifact has an invalid maxAttempts'); - const minimumBlocks = config.minimumBlocks; - const underFloor = artifact.blocks.length < minimumBlocks; - const isInconclusiveUnderfloorArtifact = - underFloor && - artifact.analysis?.status === 'inconclusive' && - Array.isArray(artifact.attempts) && - artifact.attempts.length > 0; - const isCorrectnessFailure = - artifact.analysis?.status === 'correctness-failure'; - if (underFloor && !isInconclusiveUnderfloorArtifact && !isCorrectnessFailure) - throw new TypeError( - `artifact has fewer than ${minimumBlocks} valid blocks` - ); - const expected = new Set(artifact.workloadKeys ?? []); - if (expected.size === 0) - throw new TypeError('artifact must contain workload keys'); - for (const [blockIndex, block] of artifact.blocks.entries()) - validateParserRecord(block, `block ${blockIndex}`, expected); - if (artifact.attempts !== undefined) { - if (!Array.isArray(artifact.attempts) || artifact.attempts.length === 0) - throw new TypeError('artifact attempts must be a non-empty array'); - if (artifact.attempts.length > maxAttempts) - throw new TypeError('artifact contains more attempts than maxAttempts'); - for (const [attemptIndex, attempt] of artifact.attempts.entries()) { - validateParserRecord( - attempt, - `attempt ${attemptIndex}`, - expected, - true, - config.driftThreshold - ); - if (attempt.index !== attemptIndex) - throw new TypeError( - `attempt ${attemptIndex} has an inconsistent index` - ); - } - const accepted = artifact.attempts.filter((attempt) => !attempt.rejected); - if (accepted.length !== artifact.blocks.length) - throw new TypeError('artifact attempts and blocks are inconsistent'); - for (const [index, block] of artifact.blocks.entries()) { - const attempt = accepted[index]; - if (JSON.stringify(attempt) !== JSON.stringify(block)) - throw new TypeError('artifact attempts and blocks are inconsistent'); - } - } else if (underFloor) { - throw new TypeError('under-floor artifact must retain attempts'); - } - if (artifact.blocks.length > requestedBlocks) - throw new TypeError('artifact contains more blocks than requested'); - if (artifact.blocks.length === requestedBlocks) { - const orders = artifact.blocks.map((block) => block.processOrder); - if ( - orders.filter((order) => order === 'baseline-first').length !== - requestedBlocks / 2 - ) - throw new TypeError('accepted blocks have an unbalanced process order'); - } - if (artifact.blocks.length >= minimumBlocks) { - const orderCounts = { - 'baseline-first': artifact.blocks.filter( - (block) => block.processOrder === 'baseline-first' - ).length, - 'candidate-first': artifact.blocks.filter( - (block) => block.processOrder === 'candidate-first' - ).length, - }; - if (orderCounts['baseline-first'] !== orderCounts['candidate-first']) - throw new TypeError('accepted blocks have an unbalanced process order'); - } - return artifact; -} - -function validateParserRecord( - record, - label, - expected, - isAttempt = false, - driftThreshold = 0.15 -) { - if ( - !record || - typeof record !== 'object' || - !['baseline-first', 'candidate-first'].includes(record.processOrder) - ) - throw new TypeError(`${label} has an invalid process order`); - if ( - !Number.isInteger(record.seed) || - record.seed < 0 || - record.seed > 0xffffffff - ) - throw new TypeError(`${label} has an invalid seed`); - if (isAttempt && !Number.isInteger(record.index)) - throw new TypeError(`${label} has an invalid index`); - if ( - !Array.isArray(record.workloadOrder) || - record.workloadOrder.length !== expected.size || - new Set(record.workloadOrder).size !== expected.size || - record.workloadOrder.some((key) => !expected.has(key)) - ) - throw new TypeError(`${label} has mismatched workload keys`); - if (!Array.isArray(record.revisions) || record.revisions.length !== 2) - throw new TypeError(`${label} must contain two revisions`); - const revisions = new Set( - record.revisions.map((revision) => revision?.revision) - ); - if ( - revisions.size !== 2 || - !revisions.has('baseline') || - !revisions.has('candidate') - ) - throw new TypeError(`${label} must contain baseline and candidate`); - if (isAttempt) { - if (typeof record.rejected !== 'boolean') - throw new TypeError(`${label} has an invalid rejection flag`); - if ( - !Array.isArray(record.rejectionReasons) || - record.rejectionReasons.some( - (reason) => !['drift', 'structural-mismatch'].includes(reason) - ) || - new Set(record.rejectionReasons).size !== - record.rejectionReasons.length || - record.rejected !== Boolean(record.rejectionReasons.length) || - record.rejectionReason !== (record.rejectionReasons.join('+') || null) - ) - throw new TypeError(`${label} has an invalid rejection reason`); - if ( - !Array.isArray(record.drift) || - record.drift.length !== 2 || - record.drift.some( - (value) => - typeof value !== 'number' || !Number.isFinite(value) || value < 0 - ) - ) - throw new TypeError(`${label} has invalid drift`); - if ( - !Array.isArray(record.structuralMismatches) || - record.structuralMismatches.some((key) => typeof key !== 'string') - ) - throw new TypeError(`${label} has invalid structural mismatches`); - if ( - Boolean(record.structuralMismatches.length) !== - record.rejectionReasons.includes('structural-mismatch') - ) - throw new TypeError(`${label} has inconsistent structural rejection`); - if ( - record.drift.some((value) => value > driftThreshold) !== - record.rejectionReasons.includes('drift') - ) - throw new TypeError(`${label} has inconsistent drift rejection`); - } - for (const revision of record.revisions) - validateParserRevision(revision, label, expected, record.processOrder); - if (isAttempt) { - const expectedDrift = record.revisions.map((revision) => - Math.abs( - revision.controlAfter.medianMs / revision.controlBefore.medianMs - 1 - ) - ); - if ( - record.drift.some( - (value, index) => Math.abs(value - expectedDrift[index]) > 1e-12 - ) - ) - throw new TypeError(`${label} has inconsistent drift`); - const structural = new Map( - record.revisions.flatMap((revision) => - revision.workloads.map((workload) => [ - `${revision.revision}:${workload.key}`, - workload.structural, - ]) - ) - ); - const mismatches = [...expected].filter( - (key) => - structural.get(`baseline:${key}`) !== structural.get(`candidate:${key}`) - ); - if ( - JSON.stringify(mismatches) !== JSON.stringify(record.structuralMismatches) - ) - throw new TypeError(`${label} has inconsistent structural mismatches`); - } -} - -function validateParserRevision(revision, label, expected, processOrder) { - if (!revision || !['baseline', 'candidate'].includes(revision.revision)) - throw new TypeError(`${label} has invalid revisions`); - if ( - !['baseline-first', 'candidate-first'].includes(revision.processOrder) || - revision.processOrder !== processOrder - ) - throw new TypeError(`${label} has invalid revision process order`); - validateControl(revision.controlBefore, `${label} controlBefore`); - validateControl(revision.controlAfter, `${label} controlAfter`); - if (!Array.isArray(revision.workloads)) - throw new TypeError(`${label} has invalid workloads`); - const seen = new Set(); - for (const workload of revision.workloads) { - if ( - !workload || - typeof workload.key !== 'string' || - !expected.has(workload.key) || - seen.has(workload.key) - ) - throw new TypeError(`${label} has mismatched workload keys`); - seen.add(workload.key); - validateParserWorkload(workload, `${label} ${workload.key}`); - } - if (seen.size !== expected.size) - throw new TypeError(`${label} has missing workload keys`); -} - -function validateControl(control, label) { - if ( - !control || - typeof control !== 'object' || - !positiveFinite(control.medianMs) || - !Array.isArray(control.samplesMs) || - control.samplesMs.length === 0 || - control.samplesMs.some((value) => !positiveFinite(value)) - ) - throw new TypeError(`${label} has invalid timings`); -} - -function validateParserWorkload(workload, label) { - if (!Number.isInteger(workload.repetitions) || workload.repetitions <= 0) - throw new TypeError(`${label} has invalid repetitions`); - for (const [name, values] of [ - ['calibrationSamplesMs', workload.calibrationSamplesMs], - ['warmups', workload.warmups], - ['warmupElapsedMs', workload.warmupElapsedMs], - ['measured', workload.measured], - ['measuredElapsedMs', workload.measuredElapsedMs], - ]) { - if ( - !Array.isArray(values) || - values.length === 0 || - values.some((value) => !positiveFinite(value)) - ) - throw new TypeError( - `${label} has ${ - name === 'measured' || name === 'warmups' - ? 'nonpositive timings' - : `invalid ${name}` - }` - ); - } - if ( - workload.warmups.length !== workload.warmupElapsedMs.length || - workload.measured.length !== workload.measuredElapsedMs.length - ) - throw new TypeError(`${label} has inconsistent elapsed timings`); - if ( - typeof workload.structural !== 'string' || - !workload.structural || - workload.checksum !== workload.structural || - !Number.isInteger(workload.consumed) || - workload.consumed < 0 - ) - throw new TypeError(`${label} has invalid structural digest or checksum`); -} - -function positiveFinite(value) { - return typeof value === 'number' && Number.isFinite(value) && value > 0; -} - -function validateCorpusArtifact(artifact) { - if ( - !Array.isArray(artifact.replicates) || - artifact.replicates.length < MIN_VALID_BLOCKS - ) - throw new TypeError('corpus artifact has insufficient valid replicates'); - const config = - artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION - ? validateDecisionConfig(artifact.config, 'corpus artifact') - : migrateLegacyDecisionConfig(artifact, 'corpus'); - if ( - (config.replicates ?? artifact.replicates.length) !== - artifact.replicates.length || - (config.batches ?? 6) !== 6 - ) - throw new TypeError( - 'corpus artifact has inconsistent replicate configuration' - ); - if (artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION) - validateCorpusCorrectness(artifact); - else if ( - !artifact.correctness || - !Number.isInteger(artifact.correctness.accepted) || - artifact.correctness.accepted <= 0 - ) - throw new TypeError('corpus artifact has invalid correctness metadata'); - const expectedGroups = corpusGroups(artifact); - if (expectedGroups.length < 2) - throw new TypeError('corpus artifact has no complete comparison groups'); - const repetitionsByGroup = new Map(); - const checksumsByGroup = new Map(); - const allChecksumsByGroup = new Map(); - const replicateIds = new Set(); - for (const [index, replicate] of artifact.replicates.entries()) { - if ( - !Number.isInteger(replicate?.replicate) || - replicate.replicate < 0 || - replicateIds.has(replicate.replicate) - ) - throw new TypeError(`corpus replicate ${index} has an invalid id`); - replicateIds.add(replicate.replicate); - if (config.calibrationOrderBalanced === true) { - if ( - !['ours-first', 'reference-first'].includes(replicate.calibrationOrder) - ) - throw new TypeError( - `corpus replicate ${index} has an invalid calibration order` - ); - for (const expectedOrder of ['ours-first', 'reference-first']) { - const count = artifact.replicates.filter( - (item) => item.calibrationOrder === expectedOrder - ).length; - if (Math.abs(count - artifact.replicates.length / 2) > 1) - throw new TypeError('corpus calibration orders are unbalanced'); - } - } - if ( - !Array.isArray(replicate.permutation) || - !isPermutation(replicate.permutation, artifact.correctness.accepted) - ) - throw new TypeError( - `corpus replicate ${index} has an invalid permutation` - ); - if (!Array.isArray(replicate.batches) || replicate.batches.length !== 6) - throw new TypeError(`corpus replicate ${index} must contain six batches`); - const orderCounts = { 'ours-first': 0, 'reference-first': 0 }; - for (const [batchIndex, batch] of replicate.batches.entries()) { - if ( - !batch || - !['ours-first', 'reference-first'].includes(batch.order) || - !Array.isArray(batch.measurements) - ) - throw new TypeError( - `corpus replicate ${index} has invalid batch order` - ); - orderCounts[batch.order]++; - const seenGroups = new Set(); - if (batch.measurements.length !== expectedGroups.length) - throw new TypeError( - `corpus replicate ${index} batch ${batchIndex} has incomplete groups` - ); - for (const measurement of batch.measurements) { - if ( - !measurement || - typeof measurement.group !== 'string' || - !expectedGroups.includes(measurement.group) || - seenGroups.has(measurement.group) - ) - throw new TypeError( - `corpus replicate ${index} batch ${batchIndex} has invalid groups` - ); - seenGroups.add(measurement.group); - if ( - !Number.isInteger(measurement.repetitions) || - measurement.repetitions <= 0 - ) - throw new TypeError( - `corpus replicate ${index} has invalid repetitions` - ); - const replicateGroup = `${replicate.replicate}:${measurement.group}`; - const previousRepetitions = repetitionsByGroup.get(replicateGroup); - if ( - previousRepetitions !== undefined && - previousRepetitions !== measurement.repetitions - ) - throw new TypeError( - `corpus group ${measurement.group} has inconsistent repetitions` - ); - repetitionsByGroup.set(replicateGroup, measurement.repetitions); - if ( - !Array.isArray(measurement.calibrationSamplesMs) || - measurement.calibrationSamplesMs.length === 0 - ) - throw new TypeError( - `corpus replicate ${index} has invalid calibration samples` - ); - for (const sample of measurement.calibrationSamplesMs) { - if ( - !sample || - !positiveFinite(sample.oursMs) || - !positiveFinite(sample.referenceMs) - ) - throw new TypeError( - `corpus replicate ${index} has invalid calibration timings` - ); - } - if ( - config.calibrationOrderBalanced === true && - measurement.calibrationOrder !== replicate.calibrationOrder - ) - throw new TypeError( - `corpus replicate ${index} has an inconsistent calibration order` - ); - for (const implementation of ['ours', 'reference']) { - const result = measurement[implementation]; - if ( - !result || - !positiveFinite(result.ms) || - !positiveFinite(result.elapsedMs) || - !Number.isInteger(result.checksum) || - result.checksum < 0 - ) - throw new TypeError( - `corpus replicate ${index} has nonpositive timings` - ); - const checksumKey = `${replicate.replicate}:${measurement.group}:${implementation}`; - const previousChecksum = checksumsByGroup.get(checksumKey); - if ( - previousChecksum !== undefined && - previousChecksum !== result.checksum - ) - throw new TypeError( - `corpus group ${measurement.group} has inconsistent checksums` - ); - checksumsByGroup.set(checksumKey, result.checksum); - const allChecksums = - allChecksumsByGroup.get(measurement.group) ?? new Set(); - allChecksums.add(result.checksum); - allChecksumsByGroup.set(measurement.group, allChecksums); - } - } - if (seenGroups.size !== expectedGroups.length) - throw new TypeError( - `corpus replicate ${index} batch ${batchIndex} has missing groups` - ); - } - if (orderCounts['ours-first'] !== 3 || orderCounts['reference-first'] !== 3) - throw new TypeError( - `corpus replicate ${index} has unbalanced process orders` - ); - } - validateCorpusGroupSummaries(artifact, expectedGroups, allChecksumsByGroup); - return artifact; -} - -function validateCorpusCorrectness(artifact) { - const correctness = artifact.correctness; - if ( - !correctness || - !Number.isInteger(correctness.accepted) || - correctness.accepted <= 0 - ) - throw new TypeError('corpus artifact has invalid correctness metadata'); - if (!correctness.counts || !correctness.categoryHashes) - throw new TypeError('corpus artifact is missing corpus category metadata'); - for (const category of CORPUS_CATEGORIES) { - if ( - !Number.isInteger(correctness.counts[category]) || - correctness.counts[category] < 0 - ) - throw new TypeError(`corpus artifact has invalid ${category} count`); - if (typeof correctness.categoryHashes[category] !== 'string') - throw new TypeError(`corpus artifact has invalid ${category} hash`); - if ( - category !== 'accepted' && - !NEUTRAL_CORPUS_CATEGORIES.has(category) && - correctness.counts[category] !== 0 - ) - throw new TypeError( - `corpus artifact contains non-neutral ${category} inputs` - ); - } - if (correctness.counts.accepted !== correctness.accepted) - throw new TypeError('corpus artifact has inconsistent accepted counts'); - if ( - typeof correctness.inputHash !== 'string' || - correctness.inputHash.length === 0 - ) - throw new TypeError('corpus artifact has an invalid input hash'); -} - -function corpusGroups(artifact) { - const strata = artifact.corpus?.lengthStrata; - const shapes = artifact.corpus?.rootShapeCounts; - if ( - !strata || - !shapes || - typeof strata !== 'object' || - typeof shapes !== 'object' - ) - throw new TypeError('corpus artifact is missing group metadata'); - const groups = ['exact']; - for (const [group, count] of Object.entries(shapes).sort()) - if (Number.isInteger(count) && count > 0) groups.push(group); - for (const [group, count] of Object.entries(strata).sort()) - if (Number.isInteger(count) && count > 0) groups.push(group); - return groups; -} - -function isPermutation(values, length) { - return ( - Number.isInteger(length) && - Array.isArray(values) && - values.length === length && - values.every( - (value) => Number.isInteger(value) && value >= 0 && value < length - ) && - new Set(values).size === length - ); -} - -function validateCorpusGroupSummaries( - artifact, - expectedGroups, - allChecksumsByGroup -) { - const groups = artifact.analysis?.groups; - if (artifact.analysis === undefined) return; - if (!groups || typeof groups !== 'object' || Array.isArray(groups)) - throw new TypeError('corpus artifact is missing group summaries'); - const names = Object.keys(groups).sort(); - if (JSON.stringify(names) !== JSON.stringify([...expectedGroups].sort())) - throw new TypeError('corpus artifact has incomplete group summaries'); - for (const group of expectedGroups) { - const summary = groups[group]; - const rawRatios = corpusRawRatios(artifact, group); - if ( - !summary || - summary.replicates !== artifact.replicates.length || - !Array.isArray(summary.ratios) || - summary.ratios.length !== artifact.replicates.length || - summary.ratios.some((ratio) => !positiveFinite(ratio)) || - !Array.isArray(summary.checksums) || - summary.checksums.length === 0 || - summary.checksums.some( - (checksum) => !Number.isInteger(checksum) || checksum < 0 - ) - ) - throw new TypeError(`corpus group ${group} has invalid summary`); - if ( - rawRatios.length !== summary.ratios.length || - rawRatios.some( - (ratio, index) => Math.abs(ratio - summary.ratios[index]) > 1e-12 - ) - ) - throw new TypeError( - `corpus group ${group} summary is not derived from raw observations` - ); - const rawLogs = rawRatios.map(Math.log); - const rawMean = - rawLogs.reduce((sum, value) => sum + value, 0) / rawLogs.length; - if ( - summary.geometricMeanPairedRuntimeRatio !== undefined && - Math.abs(summary.geometricMeanPairedRuntimeRatio - Math.exp(rawMean)) > - 1e-12 - ) - throw new TypeError( - `corpus group ${group} summary mean is not derived from raw observations` - ); - if (summary.bootstrap95) { - const digest = createHash('sha256').update(group).digest('hex'); - const groupSeed = - (artifact.seed ^ Number.parseInt(digest.slice(0, 8), 16)) >>> 0; - const expectedBootstrap = bootstrapCorpusInterval( - corpusRawReplicatePairs(artifact, group), - groupSeed, - artifact.config?.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, - 0.95 - ); - if ( - Math.abs( - summary.bootstrap95.lowerRatio - Math.exp(expectedBootstrap.lower) - ) > 1e-12 || - Math.abs( - summary.bootstrap95.upperRatio - Math.exp(expectedBootstrap.upper) - ) > 1e-12 - ) - throw new TypeError( - `corpus group ${group} interval is not derived from raw observations` - ); - if (summary.bootstrap90) { - const expectedNinety = bootstrapCorpusInterval( - corpusRawReplicatePairs(artifact, group), - (groupSeed ^ 0x9e3779b9) >>> 0, - artifact.config?.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, - 0.9 - ); - if ( - Math.abs( - summary.bootstrap90.lowerRatio - Math.exp(expectedNinety.lower) - ) > 1e-12 || - Math.abs( - summary.bootstrap90.upperRatio - Math.exp(expectedNinety.upper) - ) > 1e-12 - ) - throw new TypeError( - `corpus group ${group} practical interval is not derived from raw observations` - ); - } - } - const expectedChecksums = allChecksumsByGroup.get(group) ?? new Set(); - if ( - summary.checksums.length !== expectedChecksums.size || - summary.checksums.some((checksum) => !expectedChecksums.has(checksum)) - ) - throw new TypeError(`corpus group ${group} has inconsistent checksums`); - } - if (artifact.analysis?.status) { - if ( - !['postcss-calc faster', 'postcss-calc slower', 'inconclusive'].includes( - artifact.analysis.status - ) - ) - throw new TypeError('corpus artifact has an invalid statistical status'); - if ( - artifact.analysis.statistical?.status !== undefined && - artifact.analysis.statistical.status !== artifact.analysis.status - ) - throw new TypeError( - 'corpus artifact has inconsistent statistical status' - ); - if ( - artifact.analysis.practical?.margin !== undefined && - artifact.analysis.practical.margin !== artifact.config.equivalenceMargin - ) - throw new TypeError( - 'corpus artifact has inconsistent equivalence margin' - ); - } -} - -function corpusRawRatios(artifact, group) { - return corpusRawReplicatePairs(artifact, group).map( - ([oursFirst, referenceFirst]) => - Math.exp((Math.log(oursFirst) + Math.log(referenceFirst)) / 2) - ); -} - -function corpusRawReplicatePairs(artifact, group) { - return artifact.replicates.map((replicate) => - ['ours-first', 'reference-first'].map((order) => { - const values = replicate.batches - .filter((batch) => batch.order === order) - .map((batch) => - batch.measurements.find((item) => item.group === group) - ); - return ( - median(values.map((value) => value.ours.ms)) / - median(values.map((value) => value.reference.ms)) - ); - }) - ); -} - -function bootstrapCorpusInterval(strata, seed, resamples, confidence) { - if (!Array.isArray(strata) || strata.length === 0) - throw new RangeError('cannot bootstrap an empty replicate set'); - if (strata.some((pair) => !Array.isArray(pair) || pair.length !== 2)) - throw new TypeError( - 'each corpus replicate must contain both order results' - ); - const random = seededRandom(seed); - const means = Array.from({ length: resamples }, () => 0); - for (let sample = 0; sample < resamples; sample++) { - for (let index = 0; index < strata.length; index++) { - const [oursFirst, referenceFirst] = - strata[Math.floor(random() * strata.length)]; - means[sample] += - (Math.log(oursFirst) + Math.log(referenceFirst)) / (2 * strata.length); - } - } - const alpha = (1 - confidence) / 2; - return { - lower: percentile(means, alpha), - upper: percentile(means, 1 - alpha), - }; -} - -export { normalQuantile }; diff --git a/scripts/lib/parser-benchmark.js b/scripts/lib/parser-benchmark.js deleted file mode 100644 index aa54aab..0000000 --- a/scripts/lib/parser-benchmark.js +++ /dev/null @@ -1,891 +0,0 @@ -/* oxlint-disable no-bitwise, complexity */ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join, resolve } from 'node:path'; -import { - bootstrapStratifiedMaxT, - collectBenchmarkProvenance, - decisionConfigForArtifact, - DECISION_CONFIG_VERSION, - DECISION_INTERVAL_METHOD, - DRIFT_THRESHOLD, - GROWTH_THRESHOLD, - linearRegression, - logRatio, - materializeBaseline, - median, - MIN_VALID_BLOCKS, - NON_REGRESSION_MARGIN, - PRECISION_METHOD, - normalizeSeed, - ordinaryInterval, - runChild, - seededShuffle, - variationMetrics, - validateSchemaV2Artifact, -} from './benchmark.js'; - -// Four logarithmically spaced sizes with uniform doubling steps (2x) -// keep the scaling claims (slope and doubling growth) sound and well-powered -// while holding the default 20-block run to under five minutes. -const SIZES = [2_000, 4_000, 8_000, 16_000]; -const DEPTHS = [16, 32, 64, 128, 256, 512]; -// 16ms batch targets provide ample separation above the timer resolution floor -// while keeping worker durations concise. -const PARSER_TARGET_BATCH_MS = 16; -const PARSER_WARMUP_MINIMUM = 4; -const PARSER_WARMUP_MAXIMUM = 8; -const PARSER_MEASURED_BATCHES = 5; -const SCRIPT_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const WORKER = join(SCRIPT_ROOT, 'parser-benchmark-worker.js'); - -function arithmetic(kind, size) { - if (kind === 'additive') return Array(size).fill('1').join(' + '); - if (kind === 'multiplicative') return Array(size).fill('2').join(' * '); - const operators = [' + ', ' * ', ' - ', ' / ']; - const parts = ['1']; - for (let i = 1; i < size; i++) - parts.push(operators[(i - 1) % operators.length], String((i % 7) + 1)); - return parts.join(''); -} - -function nestedFallback(depth) { - let value = 'calc(1px + 2px)'; - for (let i = depth; i >= 1; i--) value = `calc(var(--x${i}, ${value}))`; - return value; -} - -export function parserWorkloads(benchmark) { - const result = []; - if (benchmark === 'arithmetic-chains') { - for (const shape of [ - 'additive', - 'multiplicative', - 'alternating-precedence', - ]) - for (const mode of ['cold-index', 'hot-shared-index']) - for (const size of SIZES) - result.push({ - key: `${shape}:${mode}:${size}`, - shape, - mode, - size, - source: arithmetic(shape, size), - }); - } else if (benchmark === 'nested-fallbacks') { - for (const mode of ['cold-index', 'hot-shared-index']) - for (const depth of DEPTHS) - result.push({ - key: `nested-fallbacks:${mode}:${depth}`, - shape: 'nested-fallbacks', - mode, - size: depth, - source: nestedFallback(depth), - }); - } else throw new Error(`unsupported benchmark: ${benchmark}`); - return result; -} - -function endpointKey(workload) { - return workload.key; -} - -function revisionResults(blocks, revision) { - const values = new Map(); - for (const block of blocks) { - const process = block.revisions.find((item) => item.revision === revision); - for (const workload of process.workloads) { - const list = values.get(workload.key) ?? []; - list.push(median(workload.measured)); - values.set(workload.key, list); - } - } - return values; -} - -function analyzeParser( - artifact, - { skipValidation = false, sensitivity = true } = {} -) { - if (!skipValidation) validateSchemaV2Artifact(artifact); - const blocks = artifact.blocks; - const config = decisionConfigForArtifact(artifact, 'parser'); - if (artifact.analysis?.status === 'correctness-failure') - return artifact.analysis; - if (blocks.length < MIN_VALID_BLOCKS) - return { - status: 'inconclusive', - validBlocks: blocks.length, - reason: `fewer than ${MIN_VALID_BLOCKS} valid blocks`, - }; - const baseline = revisionResults(blocks, 'baseline'); - const candidate = revisionResults(blocks, 'candidate'); - const keys = artifact.workloadKeys; - const logsByKey = keys.map((key) => { - const base = baseline.get(key); - const cand = candidate.get(key); - if ( - !base || - !cand || - base.length !== blocks.length || - cand.length !== blocks.length - ) - throw new TypeError(`missing paired observations for ${key}`); - return cand.map((value, index) => logRatio(value, base[index])); - }); - const runtimeRows = blocks.map((_, index) => - logsByKey.map((values) => values[index]) - ); - const endpoints = []; - for (const [keyIndex, key] of keys.entries()) { - const logs = logsByKey[keyIndex]; - const ordinary = ordinaryInterval(logs); - const endpoint = { - key, - geometricMeanPairedRuntimeRatio: Math.exp(ordinary.mean), - logRatio: ordinary.mean, - ordinary95: { - lowerRatio: Math.exp(ordinary.lower), - upperRatio: Math.exp(ordinary.upper), - }, - oneSided95: { - lowerRatio: null, - upperRatio: null, - }, - baselineVariation: variationFor(blocks, 'baseline', key), - candidateVariation: variationFor(blocks, 'candidate', key), - betweenProcessVariation: { - pairedRatio: variationMetrics(logs.map(Math.exp)), - }, - withinProcessBatchVariation: batchVariation(blocks, key), - byProcessOrder: processOrderSummaries(blocks, logs, key), - meaningfulImprovement: null, - ratios: logs.map(Math.exp), - }; - const sd = variationMetrics(logs).sd; - endpoint.observedLogRatioSd = sd; - endpoints.push(endpoint); - } - - const largestKeys = largestSizeKeys(artifact.workloadKeys); - const largest = endpoints.filter((endpoint) => largestKeys.has(endpoint.key)); - const slopes = analyzeSlopes(artifact, blocks, config); - const growthData = analyzeGrowth(artifact, blocks); - const familyRows = blocks.map((_, index) => [ - ...runtimeRows[index], - ...slopes.claimRows.map((values) => values[index]), - ...growthData.claimRows.map((values) => values[index]), - ]); - const familyBootstrap = bootstrapStratifiedMaxT({ - rows: familyRows, - strata: blocks.map((block) => block.processOrder), - seed: (artifact.seed ^ 0x6a09e667) >>> 0, - resamples: config.bootstrapResamples, - confidence: config.confidence, - }); - for (const [index, endpoint] of endpoints.entries()) { - endpoint.logRatio = familyBootstrap.observed[index]; - endpoint.geometricMeanPairedRuntimeRatio = Math.exp(endpoint.logRatio); - const half = 1.96 * familyBootstrap.standardErrors[index]; - endpoint.ordinary95 = { - lowerRatio: Math.exp(endpoint.logRatio - half), - upperRatio: Math.exp(endpoint.logRatio + half), - }; - addRuntimeIntervals( - endpoint, - familyBootstrap.intervals[index], - familyBootstrap, - config, - blocks.length, - index - ); - } - addSlopeIntervals(slopes, familyBootstrap, keys.length, config); - const growth = addGrowthIntervals( - growthData, - familyBootstrap, - keys.length + slopes.claimRows.length, - blocks, - config - ); - const orderEffect = processOrderEffect( - endpoints, - config.orderInteractionThreshold - ); - const runtimeStatus = applyPrecision( - verdict(largest, config.runtimeNonRegressionMargin), - largest - ); - const slopeStatus = applyPrecision( - verdict( - slopes.endpoints, - Math.log2(config.runtimeNonRegressionMargin), - true - ), - slopes.endpoints - ); - let growthStatus = 'pass'; - if (growth.some((item) => item.candidateLowerRatio > config.growthThreshold)) - growthStatus = 'regression'; - else if ( - growth.some((item) => item.candidateUpperRatio > config.growthThreshold) - ) - growthStatus = 'inconclusive'; - growthStatus = applyPrecision(growthStatus, growth); - let status; - if ( - runtimeStatus === 'regression' || - slopeStatus === 'regression' || - growthStatus === 'regression' - ) - status = 'regression'; - else if ( - runtimeStatus === 'pass' && - slopeStatus === 'pass' && - growthStatus === 'pass' - ) - status = 'pass'; - else status = 'inconclusive'; - if (orderEffect.diagnostic) status = 'inconclusive'; - const result = { - status, - intervalMethod: DECISION_INTERVAL_METHOD, - runtimeStatus, - slopeStatus, - growthStatus, - endpoints, - slopes, - growth, - orderEffect, - rejections: rejectionSummary(artifact.attempts), - rejectionCounts: rejectionSummary(artifact.attempts).byReason, - rejectionRate: rejectionSummary(artifact.attempts).rate, - observedBlocks: blocks.length, - validBlocks: blocks.length, - }; - if (sensitivity) { - const structurallyValid = Array.isArray(artifact.attempts) - ? artifact.attempts.filter( - (attempt) => attempt.structuralMismatches.length === 0 - ) - : blocks; - const orderCounts = new Set( - structurallyValid.map((attempt) => attempt.processOrder) - ); - if ( - structurallyValid.length >= MIN_VALID_BLOCKS && - orderCounts.size === 2 - ) { - result.sensitivity = analyzeParser( - { ...artifact, blocks: structurallyValid, analysis: undefined }, - { skipValidation: true, sensitivity: false } - ); - } else { - result.sensitivity = { - status: 'inconclusive', - validBlocks: structurallyValid.length, - reason: `fewer than ${MIN_VALID_BLOCKS} structurally valid blocks`, - }; - } - result.diagnostics = { - ...result.diagnostics, - rejectedAttempts: Array.isArray(artifact.attempts) - ? artifact.attempts.length - blocks.length - : 0, - structurallyValidAttempts: structurallyValid.length, - primaryAndSensitivityDisagree: - result.status !== result.sensitivity.status, - }; - if (result.diagnostics.primaryAndSensitivityDisagree) - result.status = 'inconclusive'; - } - return result; -} - -function rejectionSummary(attempts) { - if (!Array.isArray(attempts)) - return { attempts: 0, accepted: 0, rejected: 0, byReason: {}, rate: 0 }; - const byReason = {}; - for (const attempt of attempts) - for (const reason of attempt.rejectionReasons ?? []) - byReason[reason] = (byReason[reason] ?? 0) + 1; - const rejected = attempts.filter((attempt) => attempt.rejected).length; - return { - attempts: attempts.length, - accepted: attempts.length - rejected, - rejected, - byReason, - rate: rejected / attempts.length, - }; -} - -function processOrderSummaries(blocks, logs, key) { - const summaries = {}; - for (const order of ['baseline-first', 'candidate-first']) { - const values = logs.filter( - (_, index) => blocks[index].processOrder === order - ); - const interval = ordinaryInterval(values); - summaries[order] = { - replicates: values.length, - key, - geometricMeanPairedRuntimeRatio: Math.exp(interval.mean), - ordinary95: { - lowerRatio: Math.exp(interval.lower), - upperRatio: Math.exp(interval.upper), - }, - ratios: values.map(Math.exp), - }; - } - return summaries; -} - -function processOrderEffect(endpoints, threshold) { - const effects = endpoints.map((endpoint) => { - const baseline = endpoint.byProcessOrder['baseline-first'].ratios; - const candidate = endpoint.byProcessOrder['candidate-first'].ratios; - const baselineMean = ordinaryInterval(baseline.map(Math.log)); - const candidateMean = ordinaryInterval(candidate.map(Math.log)); - const orderLogRatio = candidateMean.mean - baselineMean.mean; - const standardError = Math.sqrt( - baselineMean.sd ** 2 / baseline.length + - candidateMean.sd ** 2 / candidate.length - ); - const half = 1.96 * standardError; - return { - key: endpoint.key, - logRatio: orderLogRatio, - ratio: Math.exp(orderLogRatio), - standardError, - ordinary95: { - lowerRatio: Math.exp(orderLogRatio - half), - upperRatio: Math.exp(orderLogRatio + half), - }, - }; - }); - return { - threshold, - endpoints: effects, - diagnostic: effects.some((effect) => Math.abs(effect.logRatio) > threshold), - }; -} - -function addRuntimeIntervals( - endpoint, - intervals, - bootstrap, - config, - blocks, - index -) { - endpoint.oneSided95 = { - lowerRatio: Math.exp(intervals.oneSidedLower), - upperRatio: Math.exp(intervals.oneSidedUpper), - }; - endpoint.familyAdjusted95 = { - lowerRatio: Math.exp(intervals.familyLower), - upperRatio: Math.exp(intervals.familyUpper), - }; - endpoint.meaningfulImprovement = intervals.upper <= Math.log(0.9); - endpoint.bootstrap95 = { - lowerRatio: Math.exp(intervals.lower), - upperRatio: Math.exp(intervals.upper), - resamples: bootstrap.resamples, - familyCount: bootstrap.familyCount, - degenerateResamples: bootstrap.degenerateResamples, - degenerateFallbacks: bootstrap.degenerateFallbacks, - }; - endpoint.precision = precisionSummary( - endpoint.observedLogRatioSd, - bootstrap.standardErrors[index], - config, - intervals.familyLower, - intervals.familyUpper, - endpoint.logRatio, - Math.log(config.precisionMargin), - blocks - ); -} - -function variationFor(blocks, revision, key) { - const values = blocks.map((block) => - median( - block.revisions - .find((item) => item.revision === revision) - .workloads.find((item) => item.key === key).measured - ) - ); - return { ...variationMetrics(values), observations: values }; -} - -function batchVariation(blocks, key) { - const values = []; - for (const block of blocks) - for (const revision of block.revisions) { - const workload = revision.workloads.find((item) => item.key === key); - const center = median(workload.measured); - values.push(...workload.measured.map((value) => value / center)); - } - return { ...variationMetrics(values), observations: values }; -} - -function precisionSummary( - observedStandardDeviation, - standardError, - config, - lower, - upper, - estimate, - targetHalfWidth, - observedBlocks -) { - const intervalHalfWidth = Math.max(estimate - lower, upper - estimate); - return { - observedStandardDeviation, - standardError, - confidenceIntervalWidth: upper - lower, - intervalHalfWidth, - targetHalfWidth, - minimumBlocks: config.minimumBlocks, - requestedBlocks: config.requestedBlocks, - observedBlocks, - targetMet: - observedBlocks >= config.minimumBlocks && - intervalHalfWidth <= targetHalfWidth, - }; -} - -function applyPrecision(status, endpoints) { - if (endpoints.length === 0) return status; - const precise = endpoints.every((endpoint) => endpoint.precision?.targetMet); - if (precise) return status; - return 'inconclusive'; -} - -function verdict(endpoints, margin, slope = false) { - if ( - endpoints.some( - (endpoint) => - (slope - ? endpoint.familyAdjusted95.lower - : endpoint.familyAdjusted95.lowerRatio) > margin - ) - ) - return 'regression'; - if ( - endpoints.every( - (endpoint) => - (slope ? endpoint.oneSided95.upper : endpoint.oneSided95.upperRatio) <= - margin - ) - ) - return 'pass'; - return 'inconclusive'; -} - -function analyzeSlopes(artifact, blocks, config) { - const workloads = artifact.workloadKeys - .map((key) => parseKey(key)) - .filter((item) => item.size); - const groups = new Map(); - for (const item of workloads) { - const group = `${item.shape}:${item.mode}`; - const values = groups.get(group) ?? { - shape: item.shape, - mode: item.mode, - sizes: [], - }; - values.sizes.push(item.size); - groups.set(group, values); - } - const raw = []; - for (const group of groups.values()) { - const deltas = []; - const baseSlopes = []; - const candidateSlopes = []; - for (const block of blocks) { - const base = group.sizes.map((size) => - median( - findWorkload( - block, - 'baseline', - `${group.shape}:${group.mode}:${size}` - ).measured - ) - ); - const cand = group.sizes.map((size) => - median( - findWorkload( - block, - 'candidate', - `${group.shape}:${group.mode}:${size}` - ).measured - ) - ); - const x = group.sizes.map(Math.log); - const b = linearRegression(x, base.map(Math.log)); - const c = linearRegression(x, cand.map(Math.log)); - baseSlopes.push(b.beta); - candidateSlopes.push(c.beta); - deltas.push(c.beta - b.beta); - } - raw.push({ - key: `${group.shape}:${group.mode}`, - baselineSlope: median(baseSlopes), - candidateSlope: median(candidateSlopes), - baselineSlopes: baseSlopes, - candidateSlopes, - deltas, - }); - } - return { - permittedIncrease: Math.log2(config.runtimeNonRegressionMargin), - endpoints: raw, - claimRows: raw.map((item) => item.deltas), - }; -} - -function addSlopeIntervals(slopes, bootstrap, offset, config) { - slopes.endpoints = slopes.endpoints.map((item, index) => { - const intervals = bootstrap.intervals[offset + index]; - const standardError = bootstrap.standardErrors[offset + index]; - return { - ...item, - deltaSlope: bootstrap.observed[offset + index], - ordinary95: { - lower: - bootstrap.observed[offset + index] - - 1.96 * bootstrap.standardErrors[offset + index], - upper: - bootstrap.observed[offset + index] + - 1.96 * bootstrap.standardErrors[offset + index], - }, - oneSided95: { - lower: intervals.oneSidedLower, - upper: intervals.oneSidedUpper, - }, - familyAdjusted95: { - lower: intervals.familyLower, - upper: intervals.familyUpper, - }, - bootstrap95: { - lower: intervals.lower, - upper: intervals.upper, - resamples: bootstrap.resamples, - familyCount: bootstrap.familyCount, - degenerateResamples: bootstrap.degenerateResamples, - degenerateFallbacks: bootstrap.degenerateFallbacks, - }, - precision: precisionSummary( - variationMetrics(item.deltas).sd, - standardError, - config, - intervals.familyLower, - intervals.familyUpper, - bootstrap.observed[offset + index], - Math.log2(config.precisionMargin), - item.deltas.length - ), - }; - }); -} - -function analyzeGrowth(artifact, blocks) { - const results = []; - const logs = []; - const groups = new Map(); - for (const key of artifact.workloadKeys) { - const item = parseKey(key); - const group = `${item.shape}:${item.mode}`; - const list = groups.get(group) ?? []; - list.push(item); - groups.set(group, list); - } - for (const [group, items] of groups) { - items.sort((a, b) => a.size - b.size); - for (let i = 1; i < items.length; i++) { - const doublings = Math.log2(items[i].size / items[i - 1].size); - const base = []; - const cand = []; - for (const block of blocks) { - const baseRatio = - median(findWorkload(block, 'baseline', items[i].key).measured) / - median(findWorkload(block, 'baseline', items[i - 1].key).measured); - const candRatio = - median(findWorkload(block, 'candidate', items[i].key).measured) / - median(findWorkload(block, 'candidate', items[i - 1].key).measured); - base.push(doublings === 1 ? baseRatio : baseRatio ** (1 / doublings)); - cand.push(doublings === 1 ? candRatio : candRatio ** (1 / doublings)); - } - logs.push(cand.map(Math.log)); - results.push({ - group, - from: items[i - 1].size, - to: items[i].size, - baselineMedian: median(base), - candidateMedian: median(cand), - }); - } - } - return { results, claimRows: logs }; -} - -function addGrowthIntervals(growthData, bootstrap, offset, blocks, config) { - return growthData.results.map((result, index) => { - const intervals = bootstrap.intervals[offset + index]; - const standardError = bootstrap.standardErrors[offset + index]; - result.candidateLowerRatio = Math.exp(intervals.familyLower); - result.candidateUpperRatio = Math.exp(intervals.familyUpper); - result.candidateMedian = Math.exp(bootstrap.observed[offset + index]); - result.familyAdjusted95 = { - lower: intervals.familyLower, - upper: intervals.familyUpper, - lowerRatio: result.candidateLowerRatio, - upperRatio: result.candidateUpperRatio, - }; - result.bootstrap95 = { - lowerRatio: Math.exp(intervals.lower), - upperRatio: Math.exp(intervals.upper), - resamples: bootstrap.resamples, - familyCount: bootstrap.familyCount, - degenerateResamples: bootstrap.degenerateResamples, - degenerateFallbacks: bootstrap.degenerateFallbacks, - }; - result.precision = precisionSummary( - variationMetrics(growthData.claimRows[index]).sd, - standardError, - config, - intervals.familyLower, - intervals.familyUpper, - bootstrap.observed[offset + index], - Math.log(config.precisionMargin), - blocks.length - ); - return result; - }); -} - -function parseKey(key) { - const parts = key.split(':'); - const size = Number(parts.at(-1)); - if (parts.length === 3) return { key, shape: parts[0], mode: parts[1], size }; - return { key, shape: 'nested-fallbacks', mode: parts[0], size }; -} -function largestSizeKeys(workloadKeys) { - const maximum = new Map(); - for (const key of workloadKeys) { - const { shape, mode, size } = parseKey(key); - const group = `${shape}:${mode}`; - maximum.set(group, Math.max(maximum.get(group) ?? 0, size)); - } - return new Set([...maximum].map(([group, size]) => `${group}:${size}`)); -} - -function findWorkload(block, revision, key) { - return block.revisions - .find((item) => item.revision === revision) - .workloads.find((item) => item.key === key); -} - -export function runParserBenchmark({ - root = process.cwd(), - benchmark = 'arithmetic-chains', - baseline = 'HEAD', - blocks = MIN_VALID_BLOCKS, - maxAttempts = Math.max(30, blocks), - seed = 0x51f15eed, - output, -} = {}) { - if ( - !Number.isInteger(blocks) || - blocks < MIN_VALID_BLOCKS || - blocks % 2 !== 0 - ) - throw new TypeError( - `--blocks must be an even integer of at least ${MIN_VALID_BLOCKS}` - ); - if (!Number.isInteger(maxAttempts) || maxAttempts < blocks) - throw new TypeError('--max-attempts must be an integer at least --blocks'); - const normalizedSeed = normalizeSeed(seed); - const workloads = parserWorkloads(benchmark); - const workloadKeys = workloads.map((item) => item.key); - const config = { - decisionConfigVersion: DECISION_CONFIG_VERSION, - benchmark, - baseline, - requestedBlocks: blocks, - minimumBlocks: MIN_VALID_BLOCKS, - maxAttempts, - targetBatchMs: PARSER_TARGET_BATCH_MS, - warmupMinimum: PARSER_WARMUP_MINIMUM, - warmupMaximum: PARSER_WARMUP_MAXIMUM, - measuredBatchCount: PARSER_MEASURED_BATCHES, - driftThreshold: DRIFT_THRESHOLD, - bootstrapResamples: 100_000, - confidence: 0.95, - runtimeNonRegressionMargin: NON_REGRESSION_MARGIN, - equivalenceMargin: 1.1, - precisionMargin: 1.1, - precisionMethod: PRECISION_METHOD, - growthThreshold: GROWTH_THRESHOLD, - orderInteractionThreshold: Math.log(1.1), - intervalMethod: DECISION_INTERVAL_METHOD, - }; - const environment = collectBenchmarkProvenance(root, { - baselineRef: baseline, - benchmark: `parser-${benchmark}`, - command: process.argv.join(' '), - }); - const materialized = materializeBaseline(root, baseline); - try { - const attempts = []; - const validBlocks = []; - const processSchedule = seededShuffle( - Array.from({ length: blocks }, (_, index) => - index < blocks / 2 ? 'baseline-first' : 'candidate-first' - ), - normalizedSeed - ); - let correctnessFailure = null; - let attempt = 0; - while (validBlocks.length < blocks && attempt < maxAttempts) { - const blockSeed = - (normalizedSeed + Math.imul(attempt + 1, 0x9e3779b9)) >>> 0; - const order = seededShuffle(workloads, blockSeed); - // Rejected attempts retry the same acceptance slot. This preserves the - // randomized, balanced process-order schedule among retained blocks. - const processOrder = processSchedule[validBlocks.length]; - const revisions = []; - const sources = - processOrder === 'baseline-first' - ? [ - ['baseline', materialized.sourceRoot], - ['candidate', join(root, 'src')], - ] - : [ - ['candidate', join(root, 'src')], - ['baseline', materialized.sourceRoot], - ]; - for (const [revision, sourceRoot] of sources) { - const child = runChild( - WORKER, - { - sourceRoot, - revision, - processOrder, - workloads: order, - targetBatchMs: config.targetBatchMs, - warmupMinimum: config.warmupMinimum, - warmupMaximum: config.warmupMaximum, - measuredBatchCount: config.measuredBatchCount, - }, - root - ); - revisions.push(child); - } - const drift = revisions.map((revision) => - Math.abs( - controlMedian(revision.controlAfter) / - controlMedian(revision.controlBefore) - - 1 - ) - ); - const structural = new Map( - revisions.flatMap((revision) => - revision.workloads.map((workload) => [ - `${revision.revision}:${workload.key}`, - workload.structural, - ]) - ) - ); - const mismatches = workloadKeys.filter( - (key) => - structural.get(`baseline:${key}`) !== - structural.get(`candidate:${key}`) - ); - const rejected = - drift.some((value) => value > config.driftThreshold) || - mismatches.length > 0; - const rejectionReasons = [ - ...(drift.some((value) => value > config.driftThreshold) - ? ['drift'] - : []), - ...(mismatches.length > 0 ? ['structural-mismatch'] : []), - ]; - const record = { - index: attempt, - seed: blockSeed, - processOrder, - workloadOrder: order.map(endpointKey), - rejected, - rejectionReasons, - rejectionReason: rejectionReasons.join('+') || null, - drift, - structuralMismatches: mismatches, - revisions, - }; - attempts.push(record); - if (mismatches.length > 0) { - correctnessFailure = record; - break; - } - if (!rejected) validBlocks.push(record); - attempt++; - } - const artifact = { - schema: 2, - benchmark: `parser-${benchmark}`, - seed: normalizedSeed, - config, - environment, - workloadKeys, - workloads: workloads.map((workload) => - Object.fromEntries( - Object.entries(workload).filter(([key]) => key !== 'source') - ) - ), - attempts, - blocks: validBlocks, - }; - if (correctnessFailure) { - artifact.analysis = { - status: 'correctness-failure', - validBlocks: validBlocks.length, - reason: 'baseline and candidate parser structures differ', - attempt: correctnessFailure.index, - structuralMismatches: correctnessFailure.structuralMismatches, - }; - } else if (validBlocks.length >= MIN_VALID_BLOCKS) { - artifact.analysis = analyzeParser(artifact); - } else { - artifact.analysis = { - status: 'inconclusive', - validBlocks: validBlocks.length, - reason: 'fewer than twenty valid blocks', - }; - } - validateSchemaV2Artifact(artifact); - const path = output - ? resolve(root, output) - : join( - root, - 'reports/benchmarks', - `${benchmark}-${Date.now()}-${normalizedSeed}.json` - ); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`); - if (correctnessFailure) - throw new Error( - `parser structural mismatch in attempt ${correctnessFailure.index}: ${correctnessFailure.structuralMismatches.join(', ')}` - ); - return { artifact, path }; - } finally { - materialized.cleanup(); - } -} - -function controlMedian(control) { - return typeof control === 'number' ? control : control.medianMs; -} - -export { analyzeParser, SIZES, DEPTHS }; diff --git a/src/lib/parser.js b/src/lib/parser.js index 80e3514..0ef9ddc 100644 --- a/src/lib/parser.js +++ b/src/lib/parser.js @@ -1,33 +1,25 @@ // Pratt parser over native @csstools/css-tokenizer tokens. -import { TokenType as CssType } from '@csstools/css-tokenizer'; import { baseOf } from './convertUnits.js'; -import { call, dim, ident, mkProduct, mkSum, num, opaqueCall } from './node.js'; +import { call, dim, ident, mkProduct, mkSum, num } from './node.js'; import { isCalculationFunction, isSupportedMathFunction } from './functions.js'; import { assertDepth } from './limits.js'; -import { CSS_NUMBER_PREFIX } from './regex.js'; +import { parseOpaqueCall, parseVar } from './parser/opaque.js'; +import { + Cursor, + expectPunct, + isPunct, + matchPunct, + peekToken, + sourceSpelling, + takeToken, +} from './parser/tokens.js'; /** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ /** @typedef {import('./node.js').Node} Node */ -/** @typedef {import('./node.js').OpaqueComponent} OpaqueComponent */ -/** @typedef {ReturnType} BlockIndex */ -/** @typedef {{raw: string, pos: number, ws: boolean, index: number}} TokenBase */ -/** @typedef {TokenBase & {type: 'number', value: number, signCharacter?: '+' | '-'}} NumberToken */ -/** @typedef {TokenBase & {type: 'dimension', value: number, unit: string, rawUnit: string, signCharacter?: '+' | '-'}} DimensionToken */ -/** @typedef {TokenBase & {type: 'ident', value: string}} IdentToken */ -/** @typedef {TokenBase & {type: 'function', value: string}} FunctionToken */ -/** @typedef {'(' | ')' | ',' | '+' | '-' | '*' | '/'} Punctuator */ -/** @typedef {TokenBase & {type: 'punct', value: Punctuator}} PunctToken */ -/** @typedef {TokenBase & {type: 'eof', value: '', raw: ''}} EofToken */ -/** @typedef {NumberToken | DimensionToken | IdentToken | FunctionToken | PunctToken | EofToken} Token */ -/** - * Immutable bounds and shared block index for one parse range. - * @typedef {Readonly<{tokens: CSSToken[], end: number, index: BlockIndex}>} ParseInput - */ - -/** @param {string} value @return {value is '+' | '-' | '*' | '/'} */ -function isOperator(value) { - return value === '+' || value === '-' || value === '*' || value === '/'; -} +/** @typedef {import('./parser/tokens.js').BlockIndex} BlockIndex */ +/** @typedef {import('./parser/tokens.js').ParseInput} ParseInput */ +/** @typedef {import('./parser/tokens.js').Token} Token */ +/** @typedef {import('./parser/tokens.js').FunctionToken} FunctionToken */ /** * CSS numeric tokens do not retain a signed-zero distinction. Keep that @@ -41,220 +33,6 @@ function normalizeSourceZero(value) { return value === 0 ? 0 : value; } -/** @param {string} raw @param {string} decoded */ -function sourceSpelling(raw, decoded) { - return raw === decoded ? undefined : raw; -} - -/** - * Mutable navigation state only. `index` is always the next native token - * position; trivia is intentionally left visible to `scanToken`. - */ -class Cursor { - /** @param {number} start */ - constructor(start) { - /** @type {number} */ - this.index = start; - /** @type {boolean} */ - this.firstToken = true; - /** @type {Token | null} */ - this.lookahead = null; - /** @type {number} */ - this.lookaheadNextIndex = start; - } - - /** @param {number} index @return {void} */ - skipTo(index) { - this.index = index; - this.lookaheadNextIndex = index; - this.firstToken = false; - this.lookahead = null; - } -} - -/** @param {OpaqueComponent[]} target @param {OpaqueComponent} part */ -function pushComponent(target, part) { - if (typeof part === 'string' && typeof target.at(-1) === 'string') - target[target.length - 1] += part; - else target.push(part); -} - -/** - * Scan one token without consuming it. `firstToken` supplies the virtual - * leading trivia at a bounded parse boundary; all later whitespace state is - * derived from the native tokens encountered in this scan. - * - * @param {ParseInput} input - * @param {Cursor} cursor - * @return {Token} - */ -function scanToken(input, cursor) { - let i = cursor.index; - let ws = cursor.firstToken; - while (i < input.end) { - const native = input.tokens[i]; - if (native[0] === CssType.Whitespace || native[0] === CssType.Comment) { - ws = true; - i++; - continue; - } - if (native[0] === CssType.EOF) break; - const token = normalizeToken(native, i, ws); - cursor.lookahead = token; - cursor.lookaheadNextIndex = i + 1; - return token; - } - /** @type {EofToken} */ - const token = { - type: 'eof', - value: '', - raw: '', - pos: eofPositionAt(input, i), - ws, - index: i, - }; - cursor.lookahead = token; - // Native EOF is a real token and is consumed past its array index. When - // the bounded range ends before native EOF, this is a virtual EOF and must - // remain at the range boundary. - cursor.lookaheadNextIndex = - i < input.end && input.tokens[i][0] === CssType.EOF ? i + 1 : input.end; - return token; -} - -/** @param {CSSToken} t @param {number} index @param {boolean} ws @return {Token} */ -function normalizeToken(t, index, ws) { - const [type, raw, pos, , detail] = t; - switch (type) { - case CssType.Number: - return { - type: 'number', - value: detail.value, - raw, - pos, - ws, - index, - signCharacter: detail.signCharacter, - }; - case CssType.Dimension: { - const match = CSS_NUMBER_PREFIX.exec(raw); - return { - type: 'dimension', - value: detail.value, - raw, - pos, - ws, - index, - unit: detail.unit, - rawUnit: match ? raw.slice(match[0].length) : detail.unit, - signCharacter: detail.signCharacter, - }; - } - case CssType.Percentage: - return { - type: 'dimension', - value: detail.value, - raw, - pos, - ws, - index, - unit: '%', - rawUnit: '%', - signCharacter: detail.signCharacter, - }; - case CssType.Ident: - case CssType.Function: - return { - type: type === CssType.Ident ? 'ident' : 'function', - value: detail.value, - raw, - pos, - ws, - index, - }; - case CssType.OpenParen: - return { type: 'punct', value: '(', raw, pos, ws, index }; - case CssType.CloseParen: - return { type: 'punct', value: ')', raw, pos, ws, index }; - case CssType.Comma: - return { type: 'punct', value: ',', raw, pos, ws, index }; - case CssType.Delim: - if (isOperator(detail.value)) - return { - type: 'punct', - value: detail.value, - raw, - pos, - ws, - index, - }; - } - throw new Error(`Unexpected character "${raw[0] ?? ''}" at position ${pos}`); -} - -/** @param {ParseInput} input @param {number} index @return {number} */ -function eofPositionAt(input, index) { - if (index < input.end && input.tokens[index][0] !== CssType.EOF) { - return input.tokens[index][2]; - } - if ( - input.end < input.tokens.length && - input.tokens[input.end][0] !== CssType.EOF - ) { - return input.tokens[input.end][2]; - } - for (let i = Math.min(input.end, input.tokens.length) - 1; i >= 0; i--) { - const token = input.tokens[i]; - if (token[0] !== CssType.EOF) return token[3] + 1; - } - return 0; -} - -/** @param {ParseInput} input @param {Cursor} cursor @return {Token} */ -function peekToken(input, cursor) { - return cursor.lookahead ?? scanToken(input, cursor); -} - -/** - * Consume the cached token and advance to its native next index. This is one - * of the only two operations allowed to advance `cursor.index`. - * @param {ParseInput} input - * @param {Cursor} cursor - * @return {Token} - */ -function takeToken(input, cursor) { - const token = peekToken(input, cursor); - cursor.index = cursor.lookaheadNextIndex; - cursor.firstToken = false; - cursor.lookahead = null; - return token; -} - -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @param {Punctuator} [value2] @return {boolean} */ -function isPunct(input, cursor, value, value2) { - const t = peekToken(input, cursor); - return ( - t.type === 'punct' && - (t.value === value || (value2 !== undefined && t.value === value2)) - ); -} - -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {boolean} */ -function matchPunct(input, cursor, value) { - if (!isPunct(input, cursor, value)) return false; - takeToken(input, cursor); - return true; -} - -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {PunctToken} */ -function expectPunct(input, cursor, value) { - const t = takeToken(input, cursor); - if (t.type !== 'punct' || t.value !== value) { - throw new Error(`Expected ${value} at position ${t.pos}, got "${t.value}"`); - } - return t; -} - /** @param {ParseInput} input @param {Cursor} cursor @param {Token} token @param {number} depth @return {Node} */ function parsePrefix(input, cursor, token, depth) { switch (token.type) { @@ -308,10 +86,10 @@ function parseExpr(input, cursor, minBp = 0, depth = 0) { `"${nxt.signCharacter}" must be surrounded by whitespace at position ${nxt.pos}` ); } - const infixKey = nxt.type === 'punct' ? String(nxt.value) : nxt.type; - const rule = INFIX[infixKey]; - if (!rule || rule.lbp < minBp) break; - if (infixKey === '+' || infixKey === '-') { + if (nxt.type !== 'punct') break; + const op = nxt.value; + if (op === '+' || op === '-') { + if (ADD_BP < minBp) break; /** @type {import('./node.js').SumTerm[]} */ const terms = [{ sign: /** @type {1} */ (1), node: left }]; do { @@ -325,7 +103,8 @@ function parseExpr(input, cursor, minBp = 0, depth = 0) { left = mkSum(terms); continue; } - if (infixKey === '*' || infixKey === '/') { + if (op === '*' || op === '/') { + if (MUL_BP < minBp) break; /** @type {import('./node.js').ProductFactor[]} */ const factors = [{ exponent: /** @type {1} */ (1), node: left }]; do { @@ -361,22 +140,6 @@ function foldCalcKeyword(name) { const ADD_BP = 1; const MUL_BP = 3; -/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @return {Node} */ -function parseOpaqueCall(input, cursor, token, name, rawName) { - const start = token.index + 1; - const close = input.index.closeOf(token.index, input.end); - if (close === -1) - throw new Error( - `Unclosed ${name}( at position ${eofPositionAt(input, cursor.index)}` - ); - cursor.skipTo(close + 1); - return opaqueCall( - name, - componentTree(input, start, close), - sourceSpelling(rawName, name) - ); -} - /** @param {ParseInput} input @param {Cursor} cursor @param {Token} token */ function requireSurroundingWs(input, cursor, token) { if (!token.ws || !peekToken(input, cursor).ws) @@ -390,9 +153,9 @@ function parseCall(input, cursor, t, depth) { const name = t.value; const rawName = t.raw.slice(0, -1); if (name.toLowerCase() === 'var') - return parseVar(input, cursor, t, name, rawName); + return parseVar(input, cursor, t, name, rawName, parseRange); if (!isCalculationFunction(name) && !isSupportedMathFunction(name)) - return parseOpaqueCall(input, cursor, t, name, rawName); + return parseOpaqueCall(input, cursor, t, name, rawName, parseRange); /** @type {Node[]} */ const args = []; if (!isPunct(input, cursor, ')')) { args.push(parseExpr(input, cursor, 0, depth + 1)); @@ -403,120 +166,6 @@ function parseCall(input, cursor, t, depth) { return call(name, args, sourceSpelling(rawName, name)); } -/** @param {CSSToken[]} tokens @param {number} start @param {number} end */ -function rawTokens(tokens, start, end) { - let raw = ''; - for (let i = start; i < end; i++) raw += tokens[i][1]; - return raw; -} -/** @param {CSSToken[]} tokens @param {number} start @param {number} end */ -function customProperty(tokens, start, end) { - /** @type {CSSToken | null} */ - let found = null; - let foundIndex = -1; - for (let i = start; i < end; i++) { - const type = tokens[i][0]; - if (type === CssType.Whitespace || type === CssType.Comment) continue; - if (found !== null) return null; - found = tokens[i]; - foundIndex = i; - } - if (!found || found[0] !== CssType.Ident) return null; - const decoded = found[4].value; - return decoded.startsWith('--') && decoded !== '--' - ? { decoded, raw: found[1], index: foundIndex } - : null; -} -/** @param {ParseInput} input @param {number} start @param {number} end @param {number} [depth] @return {OpaqueComponent[]} */ -function componentTree(input, start, end, depth = 0) { - assertDepth(depth); - /** @type {OpaqueComponent[]} */ const root = []; - /** @type {OpaqueComponent[]} */ let tree = root; - /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}[]} */ - const frames = []; - const { tokens } = input; - let i = start; - while (true) { - if (i >= end) { - if (frames.length === 0) break; - const frame = - /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}} */ ( - frames.pop() - ); - tree = frame.parent; - pushComponent(tree, frame.tree); - pushComponent(tree, tokens[frame.close][1]); - end = frame.end; - i = frame.close + 1; - continue; - } - - const token = tokens[i]; - const close = input.index.closeOf(i, end); - if (close === -1) { - pushComponent(tree, token[1]); - i++; - continue; - } - - const isMathFunction = - token[0] === CssType.Function && - (isCalculationFunction(token[4].value) || - isSupportedMathFunction(token[4].value)); - if (isMathFunction) { - try { - pushComponent(tree, parseRange(input, i, close + 1)); - } catch { - pushComponent(tree, rawTokens(tokens, i, close + 1)); - } - i = close + 1; - continue; - } - - pushComponent(tree, token[1]); - assertDepth(depth + frames.length + 1); - /** @type {OpaqueComponent[]} */ - const child = []; - frames.push({ parent: tree, tree: child, close, end }); - tree = child; - end = close; - i++; - } - return root; -} -/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @return {Node} */ -function parseVar(input, cursor, token, name, rawName) { - const { tokens } = input; - const start = token.index + 1; - const close = input.index.closeOf(token.index, input.end); - if (close === -1) - throw new Error( - `Unclosed ${name}( at position ${eofPositionAt(input, cursor.index)}` - ); - const comma = input.index.firstTopLevelComma(start, close); - const property = customProperty(tokens, start, comma === -1 ? close : comma); - if (!property) - throw new Error( - `Invalid custom property in ${name}() at position ${tokens[start]?.[2] ?? eofPositionAt(input, cursor.index)}` - ); - cursor.skipTo(close + 1); - /** @type {OpaqueComponent[]} */ - const components = [ - ident(property.decoded, sourceSpelling(property.raw, property.decoded)), - ]; - if (comma !== -1) - components.push(...componentTree(input, property.index + 1, close)); - return opaqueCall(name, components, sourceSpelling(rawName, name)); -} - -/** @type {Record} */ -const INFIX = { - '+': { lbp: ADD_BP }, - '-': { lbp: ADD_BP }, - '*': { lbp: MUL_BP }, - '/': { lbp: MUL_BP }, -}; - /** * @param {ParseInput} input * @param {number} start diff --git a/src/lib/parser/opaque.js b/src/lib/parser/opaque.js new file mode 100644 index 0000000..1d53222 --- /dev/null +++ b/src/lib/parser/opaque.js @@ -0,0 +1,151 @@ +// Opaque function parsing: `var()`, unknown functions and their component trees. +import { TokenType as CssType } from '@csstools/css-tokenizer'; +import { ident, opaqueCall } from '../node.js'; +import { + isCalculationFunction, + isSupportedMathFunction, +} from '../functions.js'; +import { assertDepth } from '../limits.js'; +import { eofPositionAt, sourceSpelling } from './tokens.js'; + +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ +/** @typedef {import('../node.js').Node} Node */ +/** @typedef {import('../node.js').OpaqueComponent} OpaqueComponent */ +/** @typedef {import('./tokens.js').ParseInput} ParseInput */ +/** @typedef {import('./tokens.js').Cursor} Cursor */ +/** @typedef {import('./tokens.js').FunctionToken} FunctionToken */ +/** @typedef {(input: ParseInput, start: number, end: number) => Node} ParseRange */ + +/** @param {OpaqueComponent[]} target @param {OpaqueComponent} part */ +function pushComponent(target, part) { + if (typeof part === 'string' && typeof target.at(-1) === 'string') + target[target.length - 1] += part; + else target.push(part); +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @return {number} */ +function closeOfCall(input, cursor, token, name) { + const close = input.index.closeOf(token.index, input.end); + if (close === -1) + throw new Error( + `Unclosed ${name}( at position ${eofPositionAt(input, cursor.index)}` + ); + return close; +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */ +function parseOpaqueCall(input, cursor, token, name, rawName, parseRange) { + const close = closeOfCall(input, cursor, token, name); + cursor.skipTo(close + 1); + return opaqueCall( + name, + componentTree(input, token.index + 1, close, parseRange, []), + sourceSpelling(rawName, name) + ); +} + +/** @param {CSSToken[]} tokens @param {number} start @param {number} end */ +function rawTokens(tokens, start, end) { + let raw = ''; + for (let i = start; i < end; i++) raw += tokens[i][1]; + return raw; +} +/** @param {CSSToken[]} tokens @param {number} start @param {number} end */ +function customProperty(tokens, start, end) { + /** @type {CSSToken | null} */ + let found = null; + let foundIndex = -1; + for (let i = start; i < end; i++) { + const type = tokens[i][0]; + if (type === CssType.Whitespace || type === CssType.Comment) continue; + if (found !== null) return null; + found = tokens[i]; + foundIndex = i; + } + if (!found || found[0] !== CssType.Ident) return null; + const decoded = found[4].value; + return decoded.startsWith('--') && decoded !== '--' + ? { decoded, raw: found[1], index: foundIndex } + : null; +} +/** + * Append the component tree of `[start, end)` to `root` and return it. + * Callers seed `root` so leading components need no copy or spread. + * @param {ParseInput} input @param {number} start @param {number} end @param {ParseRange} parseRange @param {OpaqueComponent[]} root @return {OpaqueComponent[]} + */ +function componentTree(input, start, end, parseRange, root) { + /** @type {OpaqueComponent[]} */ let tree = root; + /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}[]} */ + const frames = []; + const { tokens } = input; + let i = start; + while (true) { + if (i >= end) { + if (frames.length === 0) break; + const frame = + /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}} */ ( + frames.pop() + ); + tree = frame.parent; + pushComponent(tree, frame.tree); + pushComponent(tree, tokens[frame.close][1]); + end = frame.end; + i = frame.close + 1; + continue; + } + + const token = tokens[i]; + const close = input.index.closeOf(i, end); + if (close === -1) { + pushComponent(tree, token[1]); + i++; + continue; + } + + const isMathFunction = + token[0] === CssType.Function && + (isCalculationFunction(token[4].value) || + isSupportedMathFunction(token[4].value)); + if (isMathFunction) { + try { + pushComponent(tree, parseRange(input, i, close + 1)); + } catch { + pushComponent(tree, rawTokens(tokens, i, close + 1)); + } + i = close + 1; + continue; + } + + pushComponent(tree, token[1]); + assertDepth(frames.length + 1); + /** @type {OpaqueComponent[]} */ + const child = []; + frames.push({ parent: tree, tree: child, close, end }); + tree = child; + end = close; + i++; + } + return root; +} +/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */ +function parseVar(input, cursor, token, name, rawName, parseRange) { + const { tokens } = input; + const start = token.index + 1; + const close = closeOfCall(input, cursor, token, name); + const comma = input.index.firstTopLevelComma(start, close); + const property = customProperty(tokens, start, comma === -1 ? close : comma); + if (!property) + throw new Error( + `Invalid custom property in ${name}() at position ${tokens[start]?.[2] ?? eofPositionAt(input, cursor.index)}` + ); + cursor.skipTo(close + 1); + /** @type {OpaqueComponent[]} */ + const components = [ + ident(property.decoded, sourceSpelling(property.raw, property.decoded)), + ]; + if (comma !== -1) + componentTree(input, property.index + 1, close, parseRange, components); + return opaqueCall(name, components, sourceSpelling(rawName, name)); +} + +export { parseOpaqueCall, parseVar }; diff --git a/src/lib/parser/tokens.js b/src/lib/parser/tokens.js new file mode 100644 index 0000000..2abe3f1 --- /dev/null +++ b/src/lib/parser/tokens.js @@ -0,0 +1,242 @@ +// Token scanning and cursor navigation for the Pratt parser. +import { TokenType as CssType } from '@csstools/css-tokenizer'; +import { CSS_NUMBER_PREFIX } from '../regex.js'; + +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ +/** @typedef {ReturnType} BlockIndex */ +/** @typedef {{raw: string, pos: number, ws: boolean, index: number}} TokenBase */ +/** @typedef {TokenBase & {type: 'number', value: number, signCharacter?: '+' | '-'}} NumberToken */ +/** @typedef {TokenBase & {type: 'dimension', value: number, unit: string, rawUnit: string, signCharacter?: '+' | '-'}} DimensionToken */ +/** @typedef {TokenBase & {type: 'ident', value: string}} IdentToken */ +/** @typedef {TokenBase & {type: 'function', value: string}} FunctionToken */ +/** @typedef {'(' | ')' | ',' | '+' | '-' | '*' | '/'} Punctuator */ +/** @typedef {TokenBase & {type: 'punct', value: Punctuator}} PunctToken */ +/** @typedef {TokenBase & {type: 'eof', value: '', raw: ''}} EofToken */ +/** @typedef {NumberToken | DimensionToken | IdentToken | FunctionToken | PunctToken | EofToken} Token */ +/** + * Immutable bounds and shared block index for one parse range. + * @typedef {Readonly<{tokens: CSSToken[], end: number, index: BlockIndex}>} ParseInput + */ + +/** @param {string} value @return {value is '+' | '-' | '*' | '/'} */ +function isOperator(value) { + return value === '+' || value === '-' || value === '*' || value === '/'; +} + +/** @param {string} raw @param {string} decoded */ +function sourceSpelling(raw, decoded) { + return raw === decoded ? undefined : raw; +} + +/** + * Mutable navigation state only. `index` is always the next native token + * position; trivia is intentionally left visible to `scanToken`. + */ +class Cursor { + /** @param {number} start */ + constructor(start) { + /** @type {number} */ + this.index = start; + /** @type {boolean} */ + this.firstToken = true; + /** @type {Token | null} */ + this.lookahead = null; + /** @type {number} */ + this.lookaheadNextIndex = start; + } + + /** @param {number} index @return {void} */ + skipTo(index) { + this.index = index; + this.lookaheadNextIndex = index; + this.firstToken = false; + this.lookahead = null; + } +} + +/** + * Scan one token without consuming it. `firstToken` supplies the virtual + * leading trivia at a bounded parse boundary; all later whitespace state is + * derived from the native tokens encountered in this scan. + * + * @param {ParseInput} input + * @param {Cursor} cursor + * @return {Token} + */ +function scanToken(input, cursor) { + let i = cursor.index; + let ws = cursor.firstToken; + while (i < input.end) { + const native = input.tokens[i]; + if (native[0] === CssType.Whitespace || native[0] === CssType.Comment) { + ws = true; + i++; + continue; + } + if (native[0] === CssType.EOF) break; + const token = normalizeToken(native, i, ws); + cursor.lookahead = token; + cursor.lookaheadNextIndex = i + 1; + return token; + } + /** @type {EofToken} */ + const token = { + type: 'eof', + value: '', + raw: '', + pos: eofPositionAt(input, i), + ws, + index: i, + }; + cursor.lookahead = token; + // Native EOF is a real token and is consumed past its array index. When + // the bounded range ends before native EOF, this is a virtual EOF and must + // remain at the range boundary. + cursor.lookaheadNextIndex = + i < input.end && input.tokens[i][0] === CssType.EOF ? i + 1 : input.end; + return token; +} + +/** @param {CSSToken} t @param {number} index @param {boolean} ws @return {Token} */ +function normalizeToken(t, index, ws) { + const [type, raw, pos, , detail] = t; + switch (type) { + case CssType.Number: + return { + type: 'number', + value: detail.value, + raw, + pos, + ws, + index, + signCharacter: detail.signCharacter, + }; + case CssType.Dimension: { + const match = CSS_NUMBER_PREFIX.exec(raw); + return { + type: 'dimension', + value: detail.value, + raw, + pos, + ws, + index, + unit: detail.unit, + rawUnit: match ? raw.slice(match[0].length) : detail.unit, + signCharacter: detail.signCharacter, + }; + } + case CssType.Percentage: + return { + type: 'dimension', + value: detail.value, + raw, + pos, + ws, + index, + unit: '%', + rawUnit: '%', + signCharacter: detail.signCharacter, + }; + case CssType.Ident: + case CssType.Function: + return { + type: type === CssType.Ident ? 'ident' : 'function', + value: detail.value, + raw, + pos, + ws, + index, + }; + case CssType.OpenParen: + return { type: 'punct', value: '(', raw, pos, ws, index }; + case CssType.CloseParen: + return { type: 'punct', value: ')', raw, pos, ws, index }; + case CssType.Comma: + return { type: 'punct', value: ',', raw, pos, ws, index }; + case CssType.Delim: + if (isOperator(detail.value)) + return { + type: 'punct', + value: detail.value, + raw, + pos, + ws, + index, + }; + } + throw new Error(`Unexpected character "${raw[0] ?? ''}" at position ${pos}`); +} + +/** @param {ParseInput} input @param {number} index @return {number} */ +function eofPositionAt(input, index) { + if (index < input.end && input.tokens[index][0] !== CssType.EOF) { + return input.tokens[index][2]; + } + if ( + input.end < input.tokens.length && + input.tokens[input.end][0] !== CssType.EOF + ) { + return input.tokens[input.end][2]; + } + for (let i = Math.min(input.end, input.tokens.length) - 1; i >= 0; i--) { + const token = input.tokens[i]; + if (token[0] !== CssType.EOF) return token[3] + 1; + } + return 0; +} + +/** @param {ParseInput} input @param {Cursor} cursor @return {Token} */ +function peekToken(input, cursor) { + return cursor.lookahead ?? scanToken(input, cursor); +} + +/** + * Consume the cached token and advance to its native next index. This is one + * of the only two operations allowed to advance `cursor.index`. + * @param {ParseInput} input + * @param {Cursor} cursor + * @return {Token} + */ +function takeToken(input, cursor) { + const token = peekToken(input, cursor); + cursor.index = cursor.lookaheadNextIndex; + cursor.firstToken = false; + cursor.lookahead = null; + return token; +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @param {Punctuator} [value2] @return {boolean} */ +function isPunct(input, cursor, value, value2) { + const t = peekToken(input, cursor); + return ( + t.type === 'punct' && + (t.value === value || (value2 !== undefined && t.value === value2)) + ); +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {boolean} */ +function matchPunct(input, cursor, value) { + if (!isPunct(input, cursor, value)) return false; + takeToken(input, cursor); + return true; +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {PunctToken} */ +function expectPunct(input, cursor, value) { + const t = takeToken(input, cursor); + if (t.type !== 'punct' || t.value !== value) { + throw new Error(`Expected ${value} at position ${t.pos}, got "${t.value}"`); + } + return t; +} + +export { + Cursor, + eofPositionAt, + expectPunct, + isPunct, + matchPunct, + peekToken, + sourceSpelling, + takeToken, +}; diff --git a/src/lib/serialize.js b/src/lib/serialize.js index ee9e3c8..31c4136 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -2,10 +2,24 @@ // Outer calc() is added when the top-level result contains an arithmetic // operator, or when a finite scalar needs context-sensitive CSS semantics. -import { serializeComponents } from './opaque.js'; import { checkCalculationDepth } from './limits.js'; import { isCalculationFunction } from './functions.js'; -import { num } from './node.js'; +import { + isScalar, + serializeNumber, + roundedScalarValue, + isDegenerate, + degenerateKeyword, +} from './serialize/precision.js'; +import { + emitCall, + emitOpaqueCall, + emitMathResult, + emitNode, + emitLeadingNeg, + emitSumTerms, + termSign, +} from './serialize/expression.js'; /** * @typedef {import('./node.js').Node} Node @@ -19,127 +33,6 @@ import { num } from './node.js'; * @property {boolean} [unwrapSingleValue] Serialize fully resolved finite scalar results without calculation syntax. */ -// The AST is canonical: sums and products are flat, so these precedence -// levels cover every binary expression -const SUM_PRECEDENCE = 1; -const PRODUCT_PRECEDENCE = 2; -const ATOMIC_PRECEDENCE = 3; -// Negation (-1 * ...) binds more tightly than a sum but has the same atomic boundary -// for deciding whether the operand needs parentheses. -const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE; -const NOISE_FLOOR = 1e-12; - -/** - * Divide a decimal digit string by 10^k, rounding half away from zero, and - * return the resulting integer digit string. `digits` has no leading zeros. - * @param {string} digits - * @param {number} k - * @return {string} - */ -function divideByPowerOfTen(digits, k) { - // 0x30/0x35/0x39 are the char codes of '0'/'5'/'9'. - if (digits.length <= k) { - return digits.length === k && digits.charCodeAt(0) >= 0x35 ? '1' : '0'; - } - const cut = digits.length - k; - if (digits.charCodeAt(cut) < 0x35) return digits.slice(0, cut); - // Round up and propagate the carry through trailing nines. - let index = cut - 1; - while (index >= 0 && digits.charCodeAt(index) === 0x39) index--; - if (index < 0) return `1${'0'.repeat(cut)}`; - return `${digits.slice(0, index)}${String.fromCharCode( - digits.charCodeAt(index) + 1 - )}${'0'.repeat(cut - index - 1)}`; -} - -/** - * Round the shortest decimal representation of a non-negative double to `p` - * fractional digits, half away from zero. - * - * `Number(text + 'e' + p)` reads the exact intended decimal (so `1.005` at - * precision 2 becomes `1.01`), but it is only exact while the shifted value - * fits in `Number.MAX_SAFE_INTEGER`; beyond that the intermediate double - * rounds and can move the rounding boundary (e.g. `312834450754803.44` at - * precision 1 or 6 drifted to `312834450754803.5`). Round the decimal digits - * directly instead. - * - * @param {number} abs - * @param {number} p - * @return {number} - */ -function roundDecimal(abs, p) { - const text = String(abs); - const eIdx = text.indexOf('e'); - const mantissa = eIdx === -1 ? text : text.slice(0, eIdx); - let exponent = eIdx === -1 ? 0 : Number(text.slice(eIdx + 1)); - const dot = mantissa.indexOf('.'); - let digits = mantissa; - if (dot !== -1) { - digits = mantissa.slice(0, dot) + mantissa.slice(dot + 1); - exponent -= mantissa.length - dot - 1; - } - - // value = digits * 10^exponent, so the shortest decimal has -exponent - // fractional digits when it is smaller than 1. - if (exponent >= -p) return abs; - - let start = 0; - while (start < digits.length - 1 && digits.charCodeAt(start) === 0x30) - start++; - const rounded = divideByPowerOfTen(digits.slice(start), -(exponent + p)); - return Number(`${rounded}e-${p}`); -} - -/** - * @param {number} v - * @param {number | false} prec - * @return {number} - */ -function round(v, prec) { - if (prec === false || !Number.isFinite(v)) return v; - if (Object.is(v, -0) || v === 0) return v; - const abs = Math.abs(v); - // Numbers >= MAX_SAFE_INTEGER (2^53 - 1) cannot represent fractional values, and - // integers already have 0 fractional places. Bypassing them avoids float drift. - if (abs >= Number.MAX_SAFE_INTEGER || Number.isInteger(v)) return v; - - // Clamp precision to [0, 100] integer to prevent NaN from fractional precisions - // or exponent overflows into Infinity/NaN (e.g. exponent + prec > 308). - const p = Math.min(100, Math.max(0, Math.trunc(prec))); - const sign = v < 0 ? -1 : 1; - // Fast path: rounding to integer with "round half away from zero". - const rounded = - p === 0 ? sign * Math.round(abs) : sign * roundDecimal(abs, p); - - // Preserve non-zero values smaller than precision (e.g. 1/1000000) from collapsing - // to zero, while still snapping true floating-point dust (< 1e-12) to zero. - if (rounded === 0 && abs > NOISE_FLOOR) { - return Number(v.toPrecision(Math.max(p, 1))); - } - return rounded; -} - -// §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords. -/** @param {number} v @return {boolean} */ -function isDegenerate(v) { - return !Number.isFinite(v) || Number.isNaN(v); -} - -/** @param {number} v @return {string} */ -function degenerateKeyword(v) { - if (Number.isNaN(v)) return 'NaN'; - return v > 0 ? 'infinity' : '-infinity'; -} - -/** @param {number} v @return {string} */ -function serializeNumber(v) { - if (Object.is(v, -0)) return '0'; - const text = String(v); - if (text.startsWith('0.')) return text.slice(1); - if (text.startsWith('-0.')) return `-${text.slice(2)}`; - return text; -} - /** @param {SerializeOptions} opts @return {'standard' | 'unwrap-all'} */ function normalizeScalarPolicy(opts) { return opts.unwrapSingleValue || opts.unwrapSingleNegativeNumber @@ -147,429 +40,6 @@ function normalizeScalarPolicy(opts) { : 'standard'; } -/** - * @param {import('./node.js').Num | import('./node.js').Dim} node - * @param {number | false} precision - * @param {number} [value] - * @return {number} - */ -function roundedScalarValue(node, precision, value) { - return round(value ?? node.value, precision); -} - -/** - * @param {import('./node.js').Num | import('./node.js').Dim} node - * @param {string[]} buffer - * @param {number} value - * @return {void} - */ -function emitRoundedScalar(node, buffer, value) { - buffer.push(serializeNumber(value)); - if (node.type === 'Dim') { - buffer.push(node.rawUnit ?? node.unit); - } -} - -/** - * @param {import('./node.js').Num | import('./node.js').Dim} node - * @param {ReturnType} session - * @param {number} [value] - * @return {number} - */ -function emitFiniteScalar(node, session, value) { - const rounded = roundedScalarValue(node, session.precision, value); - emitRoundedScalar(node, session.buffer, rounded); - return rounded; -} - -/** - * @param {import('./node.js').Num | import('./node.js').Dim} node - * @param {ReturnType} session - * @param {number} [value] - * @return {void} - */ -function emitScalar(node, session, value) { - const buffer = session.buffer; - const effective = value ?? node.value; - if (Object.is(effective, -0)) emitSignedZero(buffer, node); - else if (isDegenerate(effective)) { - if (node.type === 'Dim') { - buffer.push( - 'calc(', - degenerateKeyword(effective), - ' * 1', - node.rawUnit ?? node.unit, - ')' - ); - } else { - buffer.push(degenerateKeyword(effective)); - } - } else emitFiniteScalar(node, session, effective); -} - -/** - * @param {string[]} buffer - * @param {import('./node.js').Num | import('./node.js').Dim} node - * @return {void} - */ -function emitSignedZero(buffer, node) { - const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : ''; - buffer.push('calc(-1 * 0', unit, ')'); -} - -/** @param {Node} node @return {node is import('./node.js').Num | import('./node.js').Dim} */ -function isScalar(node) { - return node.type === 'Num' || node.type === 'Dim'; -} - -/** @param {Node} node @return {node is import('./node.js').Num | import('./node.js').Dim} */ -function isSignedZero(node) { - return isScalar(node) ? Object.is(node.value, -0) : false; -} - -/** @param {Node} node @return {number} */ -function precedence(node) { - if (node.type === 'Sum') return SUM_PRECEDENCE; - if (node.type === 'Product') return PRODUCT_PRECEDENCE; - return ATOMIC_PRECEDENCE; -} - -/** - * @param {Node} node - * @param {number} parentPrecedence - * @param {boolean} groupedRequired - * @return {boolean} - */ -function needsParentheses(node, parentPrecedence, groupedRequired) { - return ( - precedence(node) < parentPrecedence || - (node.type === 'Sum' && node.grouped === true && groupedRequired === true) - ); -} - -/** - * @param {Node} node - * @param {ReturnType} session - * @param {number} [parentPrecedence] - * @param {boolean} [groupedRequired] - * @return {void} - */ -function emitNode( - node, - session, - parentPrecedence = 0, - groupedRequired = false -) { - const parenthesized = needsParentheses( - node, - parentPrecedence, - groupedRequired - ); - if (parenthesized) session.buffer.push('('); - emitNodeBody(node, session); - if (parenthesized) session.buffer.push(')'); -} - -/** - * @param {Node} node - * @param {ReturnType} session - * @return {void} - */ -function emitNodeBody(node, session) { - const buffer = session.buffer; - switch (node.type) { - case 'Num': - case 'Dim': - emitScalar(node, session); - return; - case 'Ident': - buffer.push(node.rawName ?? node.name); - return; - case 'Call': - emitCall(node, session); - return; - case 'OpaqueCall': - emitOpaqueCall(node, session); - return; - case 'Sum': - emitSum(node, session); - return; - case 'Product': - emitProduct(node, session); - return; - } -} - -/** - * @param {import('./node.js').Call} node - * @param {ReturnType} session - * @param {string} [callNameOverride] - * @return {void} - */ -function emitCall(node, session, callNameOverride) { - const buffer = session.buffer; - buffer.push(callNameOverride ?? node.rawName ?? node.name, '('); - for (let i = 0; i < node.args.length; i++) { - if (i > 0) buffer.push(', '); - emitNode(node.args[i], session); - } - buffer.push(')'); -} - -/** - * @param {import('./node.js').OpaqueCall} node - * @param {ReturnType} session - * @param {string} [callNameOverride] - * @return {void} - */ -function emitOpaqueCall(node, session, callNameOverride) { - const buffer = session.buffer; - buffer.push(callNameOverride ?? node.rawName ?? node.name, '('); - serializeComponents(node.components, buffer, (child, childBuffer) => { - emitNestedMathResult(child, session, childBuffer); - }); - buffer.push(')'); -} - -/** - * Whether a scalar node is strictly negative after precision rounding - * (excluding signed zero and sub-precision values that round to zero). - * @param {Node} node - * @param {number | false} precision - * @return {node is import('./node.js').Num | import('./node.js').Dim} - */ -function isEffectivelyNegative(node, precision) { - return ( - isScalar(node) && - !Object.is(node.value, -0) && - Number.isFinite(node.value) && - round(node.value, precision) < 0 - ); -} - -/** - * @param {import('./node.js').SumTerm} term - * @param {1 | -1} multiplier - * @param {number | false} precision - * @return {1 | -1} - */ -function termSign(term, multiplier, precision) { - let sign = /** @type {1 | -1} */ (term.sign * multiplier); - if (isEffectivelyNegative(term.node, precision)) { - sign = /** @type {1 | -1} */ (-sign); - } - return sign; -} - -/** - * @param {import('./node.js').SumTerm} term - * @param {ReturnType} session - * @param {1 | -1} sign - * @return {void} - */ -function emitSumTerm(term, session, sign) { - if (sign === 1) { - emitNode(term.node, session, SUM_PRECEDENCE, true); - } else { - emitLeadingNeg(term.node, session); - } -} - -/** - * @param {import('./node.js').SumTerm[]} terms - * @param {ReturnType} session - * @param {1 | -1} [multiplier] - * @return {void} - */ -function emitSumTerms(terms, session, multiplier = 1) { - const buffer = session.buffer; - for (let i = 0; i < terms.length; i++) { - const term = terms[i]; - const termNode = term.node; - if (isScalar(termNode)) { - const effectiveVal = term.sign * multiplier * termNode.value; - if (Object.is(effectiveVal, -0)) { - if (i > 0) buffer.push(' + '); - emitSignedZero(buffer, termNode); - } else if (isDegenerate(effectiveVal)) { - const sign = /** @type {1 | -1} */ (term.sign * multiplier); - if (i === 0) { - if (sign === -1) buffer.push('-'); - emitScalar(termNode, session); - } else { - buffer.push(sign === 1 ? ' + ' : ' - '); - emitScalar(termNode, session); - } - } else { - const rounded = round(effectiveVal, session.precision); - if (rounded < 0) { - if (i === 0) buffer.push('-'); - else buffer.push(' - '); - emitRoundedScalar(termNode, buffer, -rounded); - } else { - if (i > 0) buffer.push(' + '); - emitRoundedScalar(termNode, buffer, rounded); - } - } - continue; - } - const sign = /** @type {1 | -1} */ (term.sign * multiplier); - if (i === 0) { - emitSumTerm(term, session, sign); - } else { - buffer.push(sign === 1 ? ' + ' : ' - '); - emitNode(termNode, session, SUM_PRECEDENCE, true); - } - } -} - -/** @param {Sum} sum @param {ReturnType} session @return {void} */ -function emitSum(sum, session) { - emitSumTerms(sum.terms, session); -} - -/** - * @param {Node} node - * @param {ReturnType} session - * @return {void} - */ -function emitLeadingNeg(node, session) { - if (node.type === 'Product') { - if ( - node.factors.length > 0 && - node.factors[0].exponent === 1 && - node.factors[0].node.type === 'Num' - ) { - const head = node.factors[0].node; - emitProductFactors(node.factors, session, 1, -head.value, head); - return; - } - emitProductFactors(node.factors, session, 0, -1, num(-1)); - return; - } - session.buffer.push('-1 * '); - emitNode(node, session, UNARY_PRECEDENCE, false); -} - -/** - * @param {ProductFactor[]} factors - * @param {ReturnType} session - * @param {number} [start] - * @param {number} [coefficientValue] - * @param {import('./node.js').Num} [coefficientNode] - * @return {void} - */ -function emitProductFactors( - factors, - session, - start = 0, - coefficientValue, - coefficientNode -) { - const buffer = session.buffer; - let first = true; - if (coefficientValue !== undefined && coefficientValue !== 1) { - emitScalar( - /** @type {import('./node.js').Num} */ (coefficientNode), - session, - coefficientValue - ); - first = false; - } - for (let i = start; i < factors.length; i++) { - const factor = factors[i]; - const factorNode = factor.node; - if (first) { - if (factor.exponent === -1) buffer.push('1 / '); - if (isScalar(factorNode)) emitScalar(factorNode, session); - else emitNode(factorNode, session, PRODUCT_PRECEDENCE); - first = false; - } else { - buffer.push(factor.exponent === 1 ? ' * ' : ' / '); - if (isScalar(factorNode)) emitScalar(factorNode, session); - else emitNode(factorNode, session, PRODUCT_PRECEDENCE); - } - } -} - -/** @param {Product} product @param {ReturnType} session @return {void} */ -function emitProduct(product, session) { - emitProductFactors(product.factors, session); -} - -/** @param {Node} node @param {ReturnType} session @return {void} */ -function emitRootExpr(node, session) { - if ( - node.type === 'Sum' && - node.grouped && - node.terms.length > 1 && - termSign(node.terms[0], 1, session.precision) === -1 - ) { - session.buffer.push('-1 * ('); - emitSumTerms(node.terms, session, -1); - session.buffer.push(')'); - return; - } - if (node.type === 'Sum' && node.terms.length === 1) { - emitLeadingNeg(node.terms[0].node, session); - return; - } - emitNode(node, session); -} - -/** - * @param {Node} node - * @param {ReturnType} session - * @param {string} wrapper - * @return {void} - */ -function emitMathResult(node, session, wrapper) { - if (isScalar(node)) { - const scalarValue = roundedScalarValue(node, session.precision); - const buffer = session.buffer; - if (isDegenerate(scalarValue)) { - buffer.push(wrapper, '(', degenerateKeyword(scalarValue)); - if (node.type === 'Dim') buffer.push(' * 1', node.rawUnit ?? node.unit); - buffer.push(')'); - } else if (session.scalarPolicy === 'standard') { - buffer.push(wrapper, '('); - emitRoundedScalar(node, buffer, scalarValue); - buffer.push(')'); - } else { - emitRoundedScalar(node, buffer, scalarValue); - } - return; - } - if ( - node.type === 'Sum' && - node.grouped && - node.terms.length > 1 && - termSign(node.terms[0], 1, session.precision) === -1 - ) { - session.buffer.push(wrapper, '(-1 * ('); - emitSumTerms(node.terms, session, -1); - session.buffer.push('))'); - return; - } - if ( - node.type === 'Ident' || - node.type === 'Call' || - node.type === 'OpaqueCall' - ) { - emitNode(node, session); - return; - } - if (node.type === 'Sum' && node.terms.length === 1) { - session.buffer.push(wrapper, '('); - emitLeadingNeg(node.terms[0].node, session); - session.buffer.push(')'); - return; - } - session.buffer.push(wrapper, '('); - emitNode(node, session); - session.buffer.push(')'); -} - /** * Serialize a scalar result without allocating a render context or buffer. * @param {import('./node.js').Num | import('./node.js').Dim} node @@ -588,20 +58,6 @@ function serializeScalarResult(node, precision, scalarPolicy, wrapper) { return scalarPolicy === 'standard' ? `${wrapper}(${scalar})` : scalar; } -/** - * @param {Node} node - * @param {ReturnType} session - * @param {string[]} [buffer] - * @return {void} - */ -function emitNestedMathResult(node, session, buffer = session.buffer) { - if (isSignedZero(node)) { - emitSignedZero(buffer, node); - return; - } - emitMathResult(node, session, 'calc'); -} - /** * @param {SerializeOptions} opts * @return {{buffer: string[], precision: number | false, scalarPolicy: 'standard' | 'unwrap-all'}} @@ -667,10 +123,30 @@ function planSerializeResult(result, opts) { return { kind: 'math', node: result.tree, session, wrapper }; } +/** @param {Node} node @param {ReturnType} session @return {void} */ +function emitRootExpr(node, session) { + if ( + node.type === 'Sum' && + node.grouped && + node.terms.length > 1 && + termSign(node.terms[0], 1, session.precision) === -1 + ) { + session.buffer.push('-1 * ('); + emitSumTerms(node.terms, session, -1); + session.buffer.push(')'); + return; + } + if (node.type === 'Sum' && node.terms.length === 1) { + emitLeadingNeg(node.terms[0].node, session); + return; + } + emitNode(node, session); +} + /** * @param {ReturnType | ReturnType} renderSpec * @return {string} - * */ + */ function emitOutput(renderSpec) { if (renderSpec.kind === 'original') return renderSpec.text; const { session } = renderSpec; diff --git a/src/lib/serialize/expression.js b/src/lib/serialize/expression.js new file mode 100644 index 0000000..5cd979c --- /dev/null +++ b/src/lib/serialize/expression.js @@ -0,0 +1,376 @@ +// Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree +// Canonical AST expression emission: operator precedence, parenthesization, +// sum terms, product factors, and function calls. + +import { serializeComponents } from '../opaque.js'; +import { num } from '../node.js'; +import { + round, + isDegenerate, + degenerateKeyword, + roundedScalarValue, + emitRoundedScalar, + emitScalar, + emitSignedZero, + isScalar, + isSignedZero, + isEffectivelyNegative, +} from './precision.js'; + +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').Sum} Sum + * @typedef {import('../node.js').Product} Product + * @typedef {import('../node.js').ProductFactor} ProductFactor + * @typedef {import('./precision.js').SerializeSession} SerializeSession + */ + +// The AST is canonical: sums and products are flat, so these precedence +// levels cover every binary expression +const SUM_PRECEDENCE = 1; +const PRODUCT_PRECEDENCE = 2; +const ATOMIC_PRECEDENCE = 3; +// Negation (-1 * ...) binds more tightly than a sum but has the same atomic boundary +// for deciding whether the operand needs parentheses. +const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE; + +/** @param {Node} node @return {number} */ +function precedence(node) { + if (node.type === 'Sum') return SUM_PRECEDENCE; + if (node.type === 'Product') return PRODUCT_PRECEDENCE; + return ATOMIC_PRECEDENCE; +} + +/** + * @param {Node} node + * @param {number} parentPrecedence + * @param {boolean} groupedRequired + * @return {boolean} + */ +function needsParentheses(node, parentPrecedence, groupedRequired) { + return ( + precedence(node) < parentPrecedence || + (node.type === 'Sum' && node.grouped === true && groupedRequired === true) + ); +} + +/** + * @param {Node} node + * @param {SerializeSession} session + * @param {number} [parentPrecedence] + * @param {boolean} [groupedRequired] + * @return {void} + */ +function emitNode( + node, + session, + parentPrecedence = 0, + groupedRequired = false +) { + const parenthesized = needsParentheses( + node, + parentPrecedence, + groupedRequired + ); + if (parenthesized) session.buffer.push('('); + emitNodeBody(node, session); + if (parenthesized) session.buffer.push(')'); +} + +/** + * @param {Node} node + * @param {SerializeSession} session + * @return {void} + */ +function emitNodeBody(node, session) { + const buffer = session.buffer; + switch (node.type) { + case 'Num': + case 'Dim': + emitScalar(node, session); + return; + case 'Ident': + buffer.push(node.rawName ?? node.name); + return; + case 'Call': + emitCall(node, session); + return; + case 'OpaqueCall': + emitOpaqueCall(node, session); + return; + case 'Sum': + emitSum(node, session); + return; + case 'Product': + emitProduct(node, session); + return; + } +} + +/** + * @param {import('../node.js').Call} node + * @param {SerializeSession} session + * @param {string} [callNameOverride] + * @return {void} + */ +function emitCall(node, session, callNameOverride) { + const buffer = session.buffer; + buffer.push(callNameOverride ?? node.rawName ?? node.name, '('); + for (let i = 0; i < node.args.length; i++) { + if (i > 0) buffer.push(', '); + emitNode(node.args[i], session); + } + buffer.push(')'); +} + +/** + * @param {import('../node.js').OpaqueCall} node + * @param {SerializeSession} session + * @param {string} [callNameOverride] + * @return {void} + */ +function emitOpaqueCall(node, session, callNameOverride) { + const buffer = session.buffer; + buffer.push(callNameOverride ?? node.rawName ?? node.name, '('); + serializeComponents(node.components, buffer, (child, childBuffer) => { + emitNestedMathResult(child, session, childBuffer); + }); + buffer.push(')'); +} + +/** + * @param {import('../node.js').SumTerm} term + * @param {1 | -1} multiplier + * @param {number | false} precision + * @return {1 | -1} + */ +function termSign(term, multiplier, precision) { + let sign = /** @type {1 | -1} */ (term.sign * multiplier); + if (isEffectivelyNegative(term.node, precision)) { + sign = /** @type {1 | -1} */ (-sign); + } + return sign; +} + +/** + * @param {import('../node.js').SumTerm} term + * @param {SerializeSession} session + * @param {1 | -1} sign + * @return {void} + */ +function emitSumTerm(term, session, sign) { + if (sign === 1) { + emitNode(term.node, session, SUM_PRECEDENCE, true); + } else { + emitLeadingNeg(term.node, session); + } +} + +/** + * @param {import('../node.js').SumTerm[]} terms + * @param {SerializeSession} session + * @param {1 | -1} [multiplier] + * @return {void} + */ +function emitSumTerms(terms, session, multiplier = 1) { + const buffer = session.buffer; + for (let i = 0; i < terms.length; i++) { + const term = terms[i]; + const termNode = term.node; + if (isScalar(termNode)) { + const effectiveVal = term.sign * multiplier * termNode.value; + if (Object.is(effectiveVal, -0)) { + if (i > 0) buffer.push(' + '); + emitSignedZero(buffer, termNode); + } else if (isDegenerate(effectiveVal)) { + const sign = /** @type {1 | -1} */ (term.sign * multiplier); + if (i === 0) { + if (sign === -1) buffer.push('-'); + emitScalar(termNode, session); + } else { + buffer.push(sign === 1 ? ' + ' : ' - '); + emitScalar(termNode, session); + } + } else { + const rounded = round(effectiveVal, session.precision); + if (rounded < 0) { + if (i === 0) buffer.push('-'); + else buffer.push(' - '); + emitRoundedScalar(termNode, buffer, -rounded); + } else { + if (i > 0) buffer.push(' + '); + emitRoundedScalar(termNode, buffer, rounded); + } + } + continue; + } + const sign = /** @type {1 | -1} */ (term.sign * multiplier); + if (i === 0) { + emitSumTerm(term, session, sign); + } else { + buffer.push(sign === 1 ? ' + ' : ' - '); + emitNode(termNode, session, SUM_PRECEDENCE, true); + } + } +} + +/** @param {Sum} sum @param {SerializeSession} session @return {void} */ +function emitSum(sum, session) { + emitSumTerms(sum.terms, session); +} + +/** + * @param {Node} node + * @param {SerializeSession} session + * @return {void} + */ +function emitLeadingNeg(node, session) { + if (node.type === 'Product') { + if ( + node.factors.length > 0 && + node.factors[0].exponent === 1 && + node.factors[0].node.type === 'Num' + ) { + const head = node.factors[0].node; + emitProductFactors(node.factors, session, 1, -head.value, head); + return; + } + emitProductFactors(node.factors, session, 0, -1, num(-1)); + return; + } + session.buffer.push('-1 * '); + emitNode(node, session, UNARY_PRECEDENCE, false); +} + +/** + * @param {ProductFactor[]} factors + * @param {SerializeSession} session + * @param {number} [start] + * @param {number} [coefficientValue] + * @param {import('../node.js').Num} [coefficientNode] + * @return {void} + */ +function emitProductFactors( + factors, + session, + start = 0, + coefficientValue, + coefficientNode +) { + const buffer = session.buffer; + let first = true; + if (coefficientValue !== undefined && coefficientValue !== 1) { + emitScalar( + /** @type {import('../node.js').Num} */ (coefficientNode), + session, + coefficientValue + ); + first = false; + } + for (let i = start; i < factors.length; i++) { + const factor = factors[i]; + const factorNode = factor.node; + if (first) { + if (factor.exponent === -1) buffer.push('1 / '); + if (isScalar(factorNode)) emitScalar(factorNode, session); + else emitNode(factorNode, session, PRODUCT_PRECEDENCE); + first = false; + } else { + buffer.push(factor.exponent === 1 ? ' * ' : ' / '); + if (isScalar(factorNode)) emitScalar(factorNode, session); + else emitNode(factorNode, session, PRODUCT_PRECEDENCE); + } + } +} + +/** @param {Product} product @param {SerializeSession} session @return {void} */ +function emitProduct(product, session) { + emitProductFactors(product.factors, session); +} + +/** + * @param {Node} node + * @param {SerializeSession} session + * @param {string} wrapper + * @return {void} + */ +function emitMathResult(node, session, wrapper) { + if (isScalar(node)) { + const scalarValue = roundedScalarValue(node, session.precision); + const buffer = session.buffer; + if (isDegenerate(scalarValue)) { + buffer.push(wrapper, '(', degenerateKeyword(scalarValue)); + if (node.type === 'Dim') buffer.push(' * 1', node.rawUnit ?? node.unit); + buffer.push(')'); + } else if (session.scalarPolicy === 'standard') { + buffer.push(wrapper, '('); + emitRoundedScalar(node, buffer, scalarValue); + buffer.push(')'); + } else { + emitRoundedScalar(node, buffer, scalarValue); + } + return; + } + if ( + node.type === 'Sum' && + node.grouped && + node.terms.length > 1 && + termSign(node.terms[0], 1, session.precision) === -1 + ) { + session.buffer.push(wrapper, '(-1 * ('); + emitSumTerms(node.terms, session, -1); + session.buffer.push('))'); + return; + } + if ( + node.type === 'Ident' || + node.type === 'Call' || + node.type === 'OpaqueCall' + ) { + emitNode(node, session); + return; + } + if (node.type === 'Sum' && node.terms.length === 1) { + session.buffer.push(wrapper, '('); + emitLeadingNeg(node.terms[0].node, session); + session.buffer.push(')'); + return; + } + session.buffer.push(wrapper, '('); + emitNode(node, session); + session.buffer.push(')'); +} + +/** + * @param {Node} node + * @param {SerializeSession} session + * @param {string[]} [buffer] + * @return {void} + */ +function emitNestedMathResult(node, session, buffer = session.buffer) { + if (isSignedZero(node)) { + emitSignedZero(buffer, node); + return; + } + emitMathResult(node, session, 'calc'); +} + +export { + SUM_PRECEDENCE, + PRODUCT_PRECEDENCE, + ATOMIC_PRECEDENCE, + UNARY_PRECEDENCE, + precedence, + needsParentheses, + emitNode, + emitCall, + emitOpaqueCall, + termSign, + emitSumTerms, + emitSum, + emitLeadingNeg, + emitProductFactors, + emitProduct, + emitMathResult, + emitNestedMathResult, +}; diff --git a/src/lib/serialize/precision.js b/src/lib/serialize/precision.js new file mode 100644 index 0000000..2ded0ea --- /dev/null +++ b/src/lib/serialize/precision.js @@ -0,0 +1,240 @@ +// Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree +// Precision rounding, degenerate keyword serialization (NaN, Infinity), and +// scalar number/dimension formatting. + +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').Num} Num + * @typedef {import('../node.js').Dim} Dim + * @typedef {object} SerializeSession + * @property {string[]} buffer + * @property {number | false} precision + * @property {'standard' | 'unwrap-all'} scalarPolicy + */ + +const NOISE_FLOOR = 1e-12; + +/** + * Divide a decimal digit string by 10^k, rounding half away from zero, and + * return the resulting integer digit string. `digits` has no leading zeros. + * @param {string} digits + * @param {number} k + * @return {string} + */ +function divideByPowerOfTen(digits, k) { + // 0x30/0x35/0x39 are the char codes of '0'/'5'/'9'. + if (digits.length <= k) { + return digits.length === k && digits.charCodeAt(0) >= 0x35 ? '1' : '0'; + } + const cut = digits.length - k; + if (digits.charCodeAt(cut) < 0x35) return digits.slice(0, cut); + // Round up and propagate the carry through trailing nines. + let index = cut - 1; + while (index >= 0 && digits.charCodeAt(index) === 0x39) index--; + if (index < 0) return `1${'0'.repeat(cut)}`; + return `${digits.slice(0, index)}${String.fromCharCode( + digits.charCodeAt(index) + 1 + )}${'0'.repeat(cut - index - 1)}`; +} + +/** + * Round the shortest decimal representation of a non-negative double to `p` + * fractional digits, half away from zero. + * + * `Number(text + 'e' + p)` reads the exact intended decimal (so `1.005` at + * precision 2 becomes `1.01`), but it is only exact while the shifted value + * fits in `Number.MAX_SAFE_INTEGER`; beyond that the intermediate double + * rounds and can move the rounding boundary (e.g. `312834450754803.44` at + * precision 1 or 6 drifted to `312834450754803.5`). Round the decimal digits + * directly instead. + * + * @param {number} abs + * @param {number} p + * @return {number} + */ +function roundDecimal(abs, p) { + const text = String(abs); + const eIdx = text.indexOf('e'); + const mantissa = eIdx === -1 ? text : text.slice(0, eIdx); + let exponent = eIdx === -1 ? 0 : Number(text.slice(eIdx + 1)); + const dot = mantissa.indexOf('.'); + let digits = mantissa; + if (dot !== -1) { + digits = mantissa.slice(0, dot) + mantissa.slice(dot + 1); + exponent -= mantissa.length - dot - 1; + } + + // value = digits * 10^exponent, so the shortest decimal has -exponent + // fractional digits when it is smaller than 1. + if (exponent >= -p) return abs; + + let start = 0; + while (start < digits.length - 1 && digits.charCodeAt(start) === 0x30) + start++; + const rounded = divideByPowerOfTen(digits.slice(start), -(exponent + p)); + return Number(`${rounded}e-${p}`); +} + +/** + * @param {number} v + * @param {number | false} prec + * @return {number} + */ +function round(v, prec) { + if (prec === false || !Number.isFinite(v)) return v; + if (Object.is(v, -0) || v === 0) return v; + const abs = Math.abs(v); + // Numbers >= MAX_SAFE_INTEGER (2^53 - 1) cannot represent fractional values, and + // integers already have 0 fractional places. Bypassing them avoids float drift. + if (abs >= Number.MAX_SAFE_INTEGER || Number.isInteger(v)) return v; + + // Clamp precision to [0, 100] integer to prevent NaN from fractional precisions + // or exponent overflows into Infinity/NaN (e.g. exponent + prec > 308). + const p = Math.min(100, Math.max(0, Math.trunc(prec))); + const sign = v < 0 ? -1 : 1; + // Fast path: rounding to integer with "round half away from zero". + const rounded = + p === 0 ? sign * Math.round(abs) : sign * roundDecimal(abs, p); + + // Preserve non-zero values smaller than precision (e.g. 1/1000000) from collapsing + // to zero, while still snapping true floating-point dust (< 1e-12) to zero. + if (rounded === 0 && abs > NOISE_FLOOR) { + return Number(v.toPrecision(Math.max(p, 1))); + } + return rounded; +} + +// §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords. +/** @param {number} v @return {boolean} */ +function isDegenerate(v) { + return !Number.isFinite(v) || Number.isNaN(v); +} + +/** @param {number} v @return {string} */ +function degenerateKeyword(v) { + if (Number.isNaN(v)) return 'NaN'; + return v > 0 ? 'infinity' : '-infinity'; +} + +/** @param {number} v @return {string} */ +function serializeNumber(v) { + if (Object.is(v, -0)) return '0'; + const text = String(v); + if (text.startsWith('0.')) return text.slice(1); + if (text.startsWith('-0.')) return `-${text.slice(2)}`; + return text; +} + +/** + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @param {number | false} precision + * @param {number} [value] + * @return {number} + */ +function roundedScalarValue(node, precision, value) { + return round(value ?? node.value, precision); +} + +/** + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @param {string[]} buffer + * @param {number} value + * @return {void} + */ +function emitRoundedScalar(node, buffer, value) { + buffer.push(serializeNumber(value)); + if (node.type === 'Dim') { + buffer.push(node.rawUnit ?? node.unit); + } +} + +/** + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @param {SerializeSession} session + * @param {number} [value] + * @return {number} + */ +function emitFiniteScalar(node, session, value) { + const rounded = roundedScalarValue(node, session.precision, value); + emitRoundedScalar(node, session.buffer, rounded); + return rounded; +} + +/** + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @param {SerializeSession} session + * @param {number} [value] + * @return {void} + */ +function emitScalar(node, session, value) { + const buffer = session.buffer; + const effective = value ?? node.value; + if (Object.is(effective, -0)) emitSignedZero(buffer, node); + else if (isDegenerate(effective)) { + if (node.type === 'Dim') { + buffer.push( + 'calc(', + degenerateKeyword(effective), + ' * 1', + node.rawUnit ?? node.unit, + ')' + ); + } else { + buffer.push(degenerateKeyword(effective)); + } + } else emitFiniteScalar(node, session, effective); +} + +/** + * @param {string[]} buffer + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @return {void} + */ +function emitSignedZero(buffer, node) { + const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : ''; + buffer.push('calc(-1 * 0', unit, ')'); +} + +/** @param {Node} node @return {node is import('../node.js').Num | import('../node.js').Dim} */ +function isScalar(node) { + return node.type === 'Num' || node.type === 'Dim'; +} + +/** @param {Node} node @return {node is import('../node.js').Num | import('../node.js').Dim} */ +function isSignedZero(node) { + return isScalar(node) ? Object.is(node.value, -0) : false; +} + +/** + * Whether a scalar node is strictly negative after precision rounding + * (excluding signed zero and sub-precision values that round to zero). + * @param {Node} node + * @param {number | false} precision + * @return {node is import('../node.js').Num | import('../node.js').Dim} + */ +function isEffectivelyNegative(node, precision) { + return ( + isScalar(node) && + !Object.is(node.value, -0) && + Number.isFinite(node.value) && + round(node.value, precision) < 0 + ); +} + +export { + NOISE_FLOOR, + divideByPowerOfTen, + roundDecimal, + round, + isDegenerate, + degenerateKeyword, + serializeNumber, + roundedScalarValue, + emitRoundedScalar, + emitFiniteScalar, + emitScalar, + emitSignedZero, + isScalar, + isSignedZero, + isEffectivelyNegative, +}; diff --git a/test/benchmark/statistical-simulation.js b/test/benchmark/statistical-simulation.js index a2b2061..01c97c7 100644 --- a/test/benchmark/statistical-simulation.js +++ b/test/benchmark/statistical-simulation.js @@ -4,8 +4,8 @@ import assert from 'node:assert/strict'; import { bootstrapStratifiedMaxT, seededRandom, -} from '../../scripts/lib/benchmark.js'; -import { analyzeParser } from '../../scripts/lib/parser-benchmark.js'; +} from '../../scripts/benchmark/benchmark.js'; +import { analyzeParser } from '../../scripts/benchmark/parser-benchmark.js'; import { syntheticParserArtifact } from '../helpers/benchmark-artifact.js'; const FULL = process.env.POSTCSS_CALC_FULL_CALIBRATION === '1'; diff --git a/test/helpers/benchmark-artifact.js b/test/helpers/benchmark-artifact.js index c7c6b3a..d02f76f 100644 --- a/test/helpers/benchmark-artifact.js +++ b/test/helpers/benchmark-artifact.js @@ -3,7 +3,7 @@ import { DECISION_CONFIG_VERSION, DECISION_INTERVAL_METHOD, PRECISION_METHOD, -} from '../../scripts/lib/benchmark.js'; +} from '../../scripts/benchmark/benchmark.js'; export const SYNTHETIC_DECISION_CONFIG = { decisionConfigVersion: DECISION_CONFIG_VERSION, diff --git a/test/unit/analyze-opaque.test.js b/test/unit/analyze-opaque.test.js new file mode 100644 index 0000000..e38df05 --- /dev/null +++ b/test/unit/analyze-opaque.test.js @@ -0,0 +1,267 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { analyze } from '../../src/lib/analyze.js'; +import { call, num } from '../../src/lib/node.js'; +import { indexBlocks } from '../../src/lib/block-index.js'; +import { parse } from '../../src/lib/parser.js'; +import reduceCalc from '../../src/reduce.js'; +import { tokenize } from '@csstools/css-tokenizer'; + +function analyzeSource(source) { + const tokens = tokenize({ css: source }); + return analyze(parse(tokens, 0, tokens.length, indexBlocks(tokens))); +} + +test('analyze: a non-percentage denominator survives percentage ratio cancellation', () => { + assert.deepEqual(analyzeSource('10% / 5% / var(--x)'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('10% / (5% * var(--x))'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('analyze: a percentage ratio combined with a concrete dimension', () => { + assert.deepEqual(analyzeSource('10% / 5% * 10px'), { + type: { dimension: 'length' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('10% / 5% * 10px + 20px'), { + type: { dimension: 'length' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('10% / 5% * 10px + 1'), { + type: 'unknown', + valid: false, + unresolved: true, + }); +}); + +test('analyze: atan2 never leaks its arguments percentage type', () => { + // The atan2() type table gives «["angle" → 1]»; an unresolved result is + // plain unknown, so a surrounding product cannot cancel it against `%`. + assert.deepEqual(analyzeSource('atan2(10%, 5%)'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('atan2(10%, 5%) / 10%'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('reduceCalc: treats an atan2 percentage ratio like an opaque ratio', () => { + // Both forms are unknown-typed ratios, so both reduce instead of the + // concrete one regressing to a preserved invalid sum. + assert.equal( + reduceCalc('calc(atan2(10%, 5%) / 10% + 1px)'), + 'calc(1px + 1 / 10% * atan2(10%, 5%))' + ); + assert.equal( + reduceCalc('calc(atan2(var(--x), 5%) / 10% + 1px)'), + 'calc(1px + 1 / 10% * atan2(var(--x), 5%))' + ); +}); + +test('analyze: a percentage-preserving builtin keeps its contextual type', () => { + // abs() passes its argument type through, so the ratio still cancels to + // a number exactly like a bare `10% / 10%`. + assert.deepEqual(analyzeSource('abs(10%) / 10%'), { + type: 'number', + valid: true, + unresolved: true, + }); +}); + +test('analyze: a percentage mixed with an opaque term does not cancel', () => { + // The sum loses the guarantee that its value resolves in the percentage + // context, so the surrounding product must not cancel it against `%`. + assert.deepEqual(analyzeSource('(10% + var(--x)) / 5%'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('analyze: an unpaired percentage in a product stays unknown', () => { + // A product never returns a percentage, so its leftover percentage cannot + // participate in a later cancellation; the conservative unknown type only + // ever under-validates, never over-cancels. + assert.deepEqual(analyzeSource('10% * 2'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + // Percentages only cancel as a pair within the same product. + assert.deepEqual(analyzeSource('10% * 2 / 5%'), { + type: 'number', + valid: true, + unresolved: true, + }); +}); + +test('analyze: opaque numerator products retain known dimension constraints', () => { + assert.deepEqual(analyzeSource('var(--x) * 10px'), { + type: { dimension: 'length' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10px + 5'), { + type: 'unknown', + valid: false, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10s + 5'), { + type: 'unknown', + valid: false, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10s'), { + type: { dimension: 'time' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10deg + 1rad'), { + type: { dimension: 'angle' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10hz + 1khz'), { + type: { dimension: 'frequency' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--a) * var(--b) * 10px + 5'), { + type: 'unknown', + valid: false, + unresolved: true, + }); + assert.deepEqual(analyzeSource('10px / var(--x)'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('analyze: rejects sum when multiple incompatible dimensions surround an unresolved term', () => { + assert.deepEqual(analyzeSource('10px + var(--x) + 5s'), { + type: 'unknown', + valid: false, + unresolved: true, + }); +}); + +test('analyze: round() validates arity, types, and single-argument rules', () => { + assert.deepEqual(analyzeSource('round(5)'), { + type: 'number', + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(up, 5)'), { + type: 'number', + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(var(--x))'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('round(10px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(up, 10px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(10px, 20s)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(10px, 20px)'), { + type: { dimension: 'length' }, + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round()'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(1px, 2px, 3px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); +}); + +test('analyze: clamp() validates keywords and matching types', () => { + assert.deepEqual(analyzeSource('clamp(none, 10px, none)'), { + type: { dimension: 'length' }, + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('clamp(10s, 10px, 20px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('clamp(10px, 20px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); +}); + +test('analyze: checks invalid product children after opaque factors', () => { + const expected = { + type: 'unknown', + valid: false, + unresolved: true, + }; + assert.deepEqual(analyzeSource('var(--x) * sqrt(1px)'), expected); + assert.deepEqual(analyzeSource('sqrt(1px) * var(--x)'), expected); + assert.deepEqual(analyzeSource('var(--x) * calc(1px + 1s)'), expected); +}); + +test('analyze: rejects invalid known product dimensions around opaque factors', () => { + const expected = { + type: 'unknown', + valid: false, + unresolved: true, + }; + assert.deepEqual(analyzeSource('1px * 2px * var(--x) * 3px'), expected); + assert.deepEqual(analyzeSource('1px / 2px / var(--x) / 3px'), expected); + assert.deepEqual(analyzeSource('1px / var(--x) / 1s'), expected); +}); + +test('analyze: enforces the calculation depth limit', () => { + let tree = num(1); + for (let depth = 0; depth < 1025; depth++) { + tree = call('abs', [tree]); + } + assert.throws( + () => analyze(tree), + /Calculation nesting exceeds the limit of 1024/ + ); +}); + +test('analyze: treats inherited object names as unknown functions', () => { + assert.deepEqual(analyze(call('toString', [])), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); diff --git a/test/unit/analyze.test.js b/test/unit/analyze.test.js index 871e0ee..2bad273 100644 --- a/test/unit/analyze.test.js +++ b/test/unit/analyze.test.js @@ -1,7 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { analyze } from '../../src/lib/analyze.js'; -import { call, num } from '../../src/lib/node.js'; import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import reduceCalc from '../../src/reduce.js'; @@ -285,257 +284,3 @@ test('reduceCalc: adds a percentage to a reduced percentage ratio', () => { unresolved: true, }); }); - -test('analyze: a non-percentage denominator survives percentage ratio cancellation', () => { - assert.deepEqual(analyzeSource('10% / 5% / var(--x)'), { - type: 'unknown', - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('10% / (5% * var(--x))'), { - type: 'unknown', - valid: true, - unresolved: true, - }); -}); - -test('analyze: a percentage ratio combined with a concrete dimension', () => { - assert.deepEqual(analyzeSource('10% / 5% * 10px'), { - type: { dimension: 'length' }, - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('10% / 5% * 10px + 20px'), { - type: { dimension: 'length' }, - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('10% / 5% * 10px + 1'), { - type: 'unknown', - valid: false, - unresolved: true, - }); -}); - -test('analyze: atan2 never leaks its arguments percentage type', () => { - // The atan2() type table gives «["angle" → 1]»; an unresolved result is - // plain unknown, so a surrounding product cannot cancel it against `%`. - assert.deepEqual(analyzeSource('atan2(10%, 5%)'), { - type: 'unknown', - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('atan2(10%, 5%) / 10%'), { - type: 'unknown', - valid: true, - unresolved: true, - }); -}); - -test('reduceCalc: treats an atan2 percentage ratio like an opaque ratio', () => { - // Both forms are unknown-typed ratios, so both reduce instead of the - // concrete one regressing to a preserved invalid sum. - assert.equal( - reduceCalc('calc(atan2(10%, 5%) / 10% + 1px)'), - 'calc(1px + 1 / 10% * atan2(10%, 5%))' - ); - assert.equal( - reduceCalc('calc(atan2(var(--x), 5%) / 10% + 1px)'), - 'calc(1px + 1 / 10% * atan2(var(--x), 5%))' - ); -}); - -test('analyze: a percentage-preserving builtin keeps its contextual type', () => { - // abs() passes its argument type through, so the ratio still cancels to - // a number exactly like a bare `10% / 10%`. - assert.deepEqual(analyzeSource('abs(10%) / 10%'), { - type: 'number', - valid: true, - unresolved: true, - }); -}); - -test('analyze: a percentage mixed with an opaque term does not cancel', () => { - // The sum loses the guarantee that its value resolves in the percentage - // context, so the surrounding product must not cancel it against `%`. - assert.deepEqual(analyzeSource('(10% + var(--x)) / 5%'), { - type: 'unknown', - valid: true, - unresolved: true, - }); -}); - -test('analyze: an unpaired percentage in a product stays unknown', () => { - // A product never returns a percentage, so its leftover percentage cannot - // participate in a later cancellation; the conservative unknown type only - // ever under-validates, never over-cancels. - assert.deepEqual(analyzeSource('10% * 2'), { - type: 'unknown', - valid: true, - unresolved: true, - }); - // Percentages only cancel as a pair within the same product. - assert.deepEqual(analyzeSource('10% * 2 / 5%'), { - type: 'number', - valid: true, - unresolved: true, - }); -}); - -test('analyze: opaque numerator products retain known dimension constraints', () => { - assert.deepEqual(analyzeSource('var(--x) * 10px'), { - type: { dimension: 'length' }, - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('var(--x) * 10px + 5'), { - type: 'unknown', - valid: false, - unresolved: true, - }); - assert.deepEqual(analyzeSource('var(--x) * 10s + 5'), { - type: 'unknown', - valid: false, - unresolved: true, - }); - assert.deepEqual(analyzeSource('var(--x) * 10s'), { - type: { dimension: 'time' }, - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('var(--x) * 10deg + 1rad'), { - type: { dimension: 'angle' }, - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('var(--x) * 10hz + 1khz'), { - type: { dimension: 'frequency' }, - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('var(--a) * var(--b) * 10px + 5'), { - type: 'unknown', - valid: false, - unresolved: true, - }); - assert.deepEqual(analyzeSource('10px / var(--x)'), { - type: 'unknown', - valid: true, - unresolved: true, - }); -}); - -test('analyze: rejects sum when multiple incompatible dimensions surround an unresolved term', () => { - assert.deepEqual(analyzeSource('10px + var(--x) + 5s'), { - type: 'unknown', - valid: false, - unresolved: true, - }); -}); - -test('analyze: round() validates arity, types, and single-argument rules', () => { - assert.deepEqual(analyzeSource('round(5)'), { - type: 'number', - valid: true, - unresolved: false, - }); - assert.deepEqual(analyzeSource('round(up, 5)'), { - type: 'number', - valid: true, - unresolved: false, - }); - assert.deepEqual(analyzeSource('round(var(--x))'), { - type: 'unknown', - valid: true, - unresolved: true, - }); - assert.deepEqual(analyzeSource('round(10px)'), { - type: 'unknown', - valid: false, - unresolved: false, - }); - assert.deepEqual(analyzeSource('round(up, 10px)'), { - type: 'unknown', - valid: false, - unresolved: false, - }); - assert.deepEqual(analyzeSource('round(10px, 20s)'), { - type: 'unknown', - valid: false, - unresolved: false, - }); - assert.deepEqual(analyzeSource('round(10px, 20px)'), { - type: { dimension: 'length' }, - valid: true, - unresolved: false, - }); - assert.deepEqual(analyzeSource('round()'), { - type: 'unknown', - valid: false, - unresolved: false, - }); - assert.deepEqual(analyzeSource('round(1px, 2px, 3px)'), { - type: 'unknown', - valid: false, - unresolved: false, - }); -}); - -test('analyze: clamp() validates keywords and matching types', () => { - assert.deepEqual(analyzeSource('clamp(none, 10px, none)'), { - type: { dimension: 'length' }, - valid: true, - unresolved: false, - }); - assert.deepEqual(analyzeSource('clamp(10s, 10px, 20px)'), { - type: 'unknown', - valid: false, - unresolved: false, - }); - assert.deepEqual(analyzeSource('clamp(10px, 20px)'), { - type: 'unknown', - valid: false, - unresolved: false, - }); -}); - -test('analyze: checks invalid product children after opaque factors', () => { - const expected = { - type: 'unknown', - valid: false, - unresolved: true, - }; - assert.deepEqual(analyzeSource('var(--x) * sqrt(1px)'), expected); - assert.deepEqual(analyzeSource('sqrt(1px) * var(--x)'), expected); - assert.deepEqual(analyzeSource('var(--x) * calc(1px + 1s)'), expected); -}); - -test('analyze: rejects invalid known product dimensions around opaque factors', () => { - const expected = { - type: 'unknown', - valid: false, - unresolved: true, - }; - assert.deepEqual(analyzeSource('1px * 2px * var(--x) * 3px'), expected); - assert.deepEqual(analyzeSource('1px / 2px / var(--x) / 3px'), expected); - assert.deepEqual(analyzeSource('1px / var(--x) / 1s'), expected); -}); - -test('analyze: enforces the calculation depth limit', () => { - let tree = num(1); - for (let depth = 0; depth < 1025; depth++) { - tree = call('abs', [tree]); - } - assert.throws( - () => analyze(tree), - /Calculation nesting exceeds the limit of 1024/ - ); -}); - -test('analyze: treats inherited object names as unknown functions', () => { - assert.deepEqual(analyze(call('toString', [])), { - type: 'unknown', - valid: true, - unresolved: true, - }); -}); diff --git a/test/unit/benchmark-schema.test.js b/test/unit/benchmark-schema.test.js new file mode 100644 index 0000000..5c7ccf2 --- /dev/null +++ b/test/unit/benchmark-schema.test.js @@ -0,0 +1,284 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + DECISION_CONFIG_VERSION, + PRECISION_METHOD, + validateSchemaV2Artifact, +} from '../../scripts/benchmark/benchmark.js'; +import { analyzeParser } from '../../scripts/benchmark/parser-benchmark.js'; + +function artifact() { + const workloadKeys = ['shape:mode:1', 'shape:mode:16000']; + return { + schema: 2, + seed: 1, + workloadKeys, + config: { blocks: 20, maxAttempts: 30 }, + blocks: Array.from({ length: 20 }, (_, index) => ({ + index, + processOrder: index % 2 ? 'candidate-first' : 'baseline-first', + workloadOrder: workloadKeys, + rejected: false, + rejectionReasons: [], + rejectionReason: null, + drift: [0, 0], + structuralMismatches: [], + revisions: ['baseline', 'candidate'].map((revision) => ({ + revision, + processOrder: index % 2 ? 'candidate-first' : 'baseline-first', + controlBefore: { medianMs: 1, samplesMs: [1] }, + controlAfter: { medianMs: 1, samplesMs: [1] }, + workloads: workloadKeys.map((key) => ({ + key, + repetitions: 1, + calibrationSamplesMs: [1], + warmups: [1], + warmupElapsedMs: [1], + measured: [1, 2], + measuredElapsedMs: [1, 2], + structural: `digest-${key}`, + checksum: `digest-${key}`, + consumed: 1, + })), + })), + seed: index + 2, + })), + }; +} + +function corpusArtifact() { + const groups = ['exact', 'sum', 'source-length-q1']; + const measurements = (group) => ({ + group, + repetitions: 1, + calibrationSamplesMs: [{ oursMs: 1, referenceMs: 1 }], + ours: { ms: 1, elapsedMs: 1, checksum: groups.indexOf(group) }, + reference: { ms: 1, elapsedMs: 1, checksum: groups.indexOf(group) }, + }); + const replicates = Array.from( + { length: 20 }, + (unusedReplicate, replicate) => ({ + replicate, + permutation: Array.from( + { length: 20 }, + (unusedPosition, position) => position + ), + batches: Array.from({ length: 6 }, (unusedBatch, batchIndex) => ({ + order: batchIndex < 3 ? 'ours-first' : 'reference-first', + measurements: groups.map(measurements), + })), + }) + ); + const summaries = Object.fromEntries( + groups.map((group) => [ + group, + { + replicates: 20, + ratios: Array(20).fill(1), + checksums: [groups.indexOf(group)], + }, + ]) + ); + return { + schema: 2, + benchmark: 'corpus', + seed: 1, + config: { replicates: 20, batches: 6 }, + correctness: { + accepted: 20, + counts: Object.fromEntries( + [ + 'accepted', + 'both-failed', + 'known-divergence', + 'malformed-input', + 'parser-rejected', + 'reference-rejected', + 'unexpected-divergence', + ].map((category) => [category, category === 'accepted' ? 20 : 0]) + ), + categoryHashes: Object.fromEntries( + [ + 'accepted', + 'both-failed', + 'known-divergence', + 'malformed-input', + 'parser-rejected', + 'reference-rejected', + 'unexpected-divergence', + ].map((category) => [category, category]) + ), + inputHash: 'inputs', + }, + corpus: { + lengthStrata: { 'source-length-q1': 20 }, + rootShapeCounts: { sum: 20 }, + }, + replicates, + analysis: { groups: summaries }, + }; +} + +test('parser analysis shares one family adjustment across all gated claims', () => { + const matching = analyzeParser(artifact()); + assert.equal(matching.status, 'pass'); + assert.equal(matching.endpoints[0].bootstrap95.familyCount, 4); + assert.equal(matching.slopes.endpoints[0].bootstrap95.familyCount, 4); + assert.equal(matching.growth[0].bootstrap95.familyCount, 4); + + const regression = artifact(); + for (const block of regression.blocks) { + const workloads = block.revisions.find( + (revision) => revision.revision === 'candidate' + ).workloads; + for (const workload of workloads) workload.measured = [2, 4]; + } + assert.equal(analyzeParser(regression).status, 'regression'); +}); + +test('parser regression is inconclusive when its family interval misses precision', () => { + const noisy = artifact(); + for (const [index, block] of noisy.blocks.entries()) { + const candidateFactor = index % 4 < 2 ? 1.35 : 2.5; + for (const revision of block.revisions) { + const isCandidate = revision.revision === 'candidate'; + for (const workload of revision.workloads) { + const value = isCandidate ? candidateFactor : 1; + workload.measured = [value]; + workload.measuredElapsedMs = [value]; + } + } + } + + const analysis = analyzeParser(noisy); + const largest = analysis.endpoints.find((item) => + item.key.endsWith(':16000') + ); + assert.ok(largest.familyAdjusted95.lowerRatio > 1.1); + assert.equal(largest.precision.targetMet, false); + assert.equal(analysis.runtimeStatus, 'inconclusive'); +}); + +test('schema-v2 validation rejects missing keys and nonpositive timings', () => { + assert.doesNotThrow(() => validateSchemaV2Artifact(artifact())); + const missing = artifact(); + missing.blocks[0].revisions[0].workloads.pop(); + assert.throws( + () => validateSchemaV2Artifact(missing), + /missing workload keys/ + ); + const nonpositive = artifact(); + nonpositive.blocks[0].revisions[0].workloads[0].measured[0] = 0; + assert.throws( + () => validateSchemaV2Artifact(nonpositive), + /nonpositive timings/ + ); +}); + +test('schema-v2 decision configuration is complete and authoritative', () => { + const strict = artifact(); + strict.config = { + decisionConfigVersion: DECISION_CONFIG_VERSION, + requestedBlocks: 20, + minimumBlocks: 20, + maxAttempts: 20, + targetBatchMs: 25, + warmupMinimum: 5, + warmupMaximum: 10, + measuredBatchCount: 6, + driftThreshold: 0.15, + bootstrapResamples: 100, + confidence: 0.95, + runtimeNonRegressionMargin: 1.1, + equivalenceMargin: 1.1, + precisionMargin: 1.1, + precisionMethod: PRECISION_METHOD, + growthThreshold: 2.5, + orderInteractionThreshold: Math.log(1.1), + intervalMethod: 'stratified-max-t-studentized-bootstrap', + }; + assert.doesNotThrow(() => validateSchemaV2Artifact(strict)); + for (const key of ['precisionMargin', 'confidence', 'intervalMethod']) { + const missing = structuredClone(strict); + delete missing.config[key]; + assert.throws( + () => validateSchemaV2Artifact(missing), + new RegExp(`missing decision parameter ${key}`) + ); + } + for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + const invalid = structuredClone(strict); + invalid.config.precisionMargin = value; + assert.throws(() => validateSchemaV2Artifact(invalid), /precisionMargin/); + } + const inconsistent = structuredClone(strict); + inconsistent.config.maxAttempts = 19; + assert.throws(() => validateSchemaV2Artifact(inconsistent), /maxAttempts/); +}); + +test('schema-v2 validation enforces the twenty-block statistical floor', () => { + const insufficient = artifact(); + insufficient.blocks = insufficient.blocks.slice(0, 1); + assert.throws( + () => validateSchemaV2Artifact(insufficient), + /fewer than 20 valid blocks/ + ); +}); + +test('schema-v2 validates retained attempts and raw timing fields', () => { + const retained = artifact(); + retained.attempts = structuredClone(retained.blocks); + retained.attempts[0].revisions[0].workloads[0].measuredElapsedMs[0] = 0; + assert.throws( + () => validateSchemaV2Artifact(retained), + /attempt 0 .*invalid measuredElapsedMs/ + ); + + const inconsistent = artifact(); + inconsistent.attempts = structuredClone(inconsistent.blocks); + inconsistent.attempts[0].rejectionReason = 'drift'; + assert.throws( + () => validateSchemaV2Artifact(inconsistent), + /invalid rejection reason/ + ); +}); + +test('schema-v2 corpus validation requires complete replicated groups', () => { + const valid = corpusArtifact(); + assert.doesNotThrow(() => validateSchemaV2Artifact(valid)); + const incomplete = structuredClone(valid); + incomplete.replicates[0].batches[0].measurements = []; + assert.throws( + () => validateSchemaV2Artifact(incomplete), + /incomplete groups/ + ); + const unbalanced = structuredClone(valid); + unbalanced.replicates[0].batches[0].order = 'reference-first'; + assert.throws( + () => validateSchemaV2Artifact(unbalanced), + /unbalanced process orders/ + ); + const invalidPermutation = structuredClone(valid); + invalidPermutation.replicates[0].permutation[0] = 1; + assert.throws( + () => validateSchemaV2Artifact(invalidPermutation), + /invalid permutation/ + ); +}); + +test('parser reanalysis stays inconclusive below the statistical floor', () => { + const insufficient = artifact(); + insufficient.blocks = insufficient.blocks.slice(0, 1); + insufficient.analysis = { status: 'inconclusive' }; + const malformedAttempt = { ...insufficient, attempts: [{}] }; + assert.throws( + () => validateSchemaV2Artifact(malformedAttempt), + /attempt 0 has an invalid process order/ + ); + insufficient.attempts = [insufficient.blocks[0]]; + assert.deepEqual(analyzeParser(insufficient), { + status: 'inconclusive', + validBlocks: 1, + reason: 'fewer than 20 valid blocks', + }); +}); diff --git a/test/unit/benchmark-statistics.test.js b/test/unit/benchmark-statistics.test.js index 8682d53..a8cfbcd 100644 --- a/test/unit/benchmark-statistics.test.js +++ b/test/unit/benchmark-statistics.test.js @@ -7,15 +7,11 @@ import { bootstrapPairedIntervals, bootstrapIndices, bootstrapRatioInterval, - DECISION_CONFIG_VERSION, linearRegression, logRatio, median, - PRECISION_METHOD, variationMetrics, - validateSchemaV2Artifact, -} from '../../scripts/lib/benchmark.js'; -import { analyzeParser } from '../../scripts/lib/parser-benchmark.js'; +} from '../../scripts/benchmark/benchmark.js'; test('benchmark schedules and bootstrap samples are deterministic', () => { assert.deepEqual(balancedOrder(10, 42), balancedOrder(10, 42)); @@ -128,279 +124,3 @@ test('studentized bootstrap gives degenerate outlier resamples a nonzero scale', assert.ok(result.degenerateFallbacks > 0); assert.ok(result.familyCritical > 0); }); - -function artifact() { - const workloadKeys = ['shape:mode:1', 'shape:mode:16000']; - return { - schema: 2, - seed: 1, - workloadKeys, - config: { blocks: 20, maxAttempts: 30 }, - blocks: Array.from({ length: 20 }, (_, index) => ({ - index, - processOrder: index % 2 ? 'candidate-first' : 'baseline-first', - workloadOrder: workloadKeys, - rejected: false, - rejectionReasons: [], - rejectionReason: null, - drift: [0, 0], - structuralMismatches: [], - revisions: ['baseline', 'candidate'].map((revision) => ({ - revision, - processOrder: index % 2 ? 'candidate-first' : 'baseline-first', - controlBefore: { medianMs: 1, samplesMs: [1] }, - controlAfter: { medianMs: 1, samplesMs: [1] }, - workloads: workloadKeys.map((key) => ({ - key, - repetitions: 1, - calibrationSamplesMs: [1], - warmups: [1], - warmupElapsedMs: [1], - measured: [1, 2], - measuredElapsedMs: [1, 2], - structural: `digest-${key}`, - checksum: `digest-${key}`, - consumed: 1, - })), - })), - seed: index + 2, - })), - }; -} - -function corpusArtifact() { - const groups = ['exact', 'sum', 'source-length-q1']; - const measurements = (group) => ({ - group, - repetitions: 1, - calibrationSamplesMs: [{ oursMs: 1, referenceMs: 1 }], - ours: { ms: 1, elapsedMs: 1, checksum: groups.indexOf(group) }, - reference: { ms: 1, elapsedMs: 1, checksum: groups.indexOf(group) }, - }); - const replicates = Array.from( - { length: 20 }, - (unusedReplicate, replicate) => ({ - replicate, - permutation: Array.from( - { length: 20 }, - (unusedPosition, position) => position - ), - batches: Array.from({ length: 6 }, (unusedBatch, batchIndex) => ({ - order: batchIndex < 3 ? 'ours-first' : 'reference-first', - measurements: groups.map(measurements), - })), - }) - ); - const summaries = Object.fromEntries( - groups.map((group) => [ - group, - { - replicates: 20, - ratios: Array(20).fill(1), - checksums: [groups.indexOf(group)], - }, - ]) - ); - return { - schema: 2, - benchmark: 'corpus', - seed: 1, - config: { replicates: 20, batches: 6 }, - correctness: { - accepted: 20, - counts: Object.fromEntries( - [ - 'accepted', - 'both-failed', - 'known-divergence', - 'malformed-input', - 'parser-rejected', - 'reference-rejected', - 'unexpected-divergence', - ].map((category) => [category, category === 'accepted' ? 20 : 0]) - ), - categoryHashes: Object.fromEntries( - [ - 'accepted', - 'both-failed', - 'known-divergence', - 'malformed-input', - 'parser-rejected', - 'reference-rejected', - 'unexpected-divergence', - ].map((category) => [category, category]) - ), - inputHash: 'inputs', - }, - corpus: { - lengthStrata: { 'source-length-q1': 20 }, - rootShapeCounts: { sum: 20 }, - }, - replicates, - analysis: { groups: summaries }, - }; -} - -test('parser analysis shares one family adjustment across all gated claims', () => { - const matching = analyzeParser(artifact()); - assert.equal(matching.status, 'pass'); - assert.equal(matching.endpoints[0].bootstrap95.familyCount, 4); - assert.equal(matching.slopes.endpoints[0].bootstrap95.familyCount, 4); - assert.equal(matching.growth[0].bootstrap95.familyCount, 4); - - const regression = artifact(); - for (const block of regression.blocks) { - const workloads = block.revisions.find( - (revision) => revision.revision === 'candidate' - ).workloads; - for (const workload of workloads) workload.measured = [2, 4]; - } - assert.equal(analyzeParser(regression).status, 'regression'); -}); - -test('parser regression is inconclusive when its family interval misses precision', () => { - const noisy = artifact(); - for (const [index, block] of noisy.blocks.entries()) { - const candidateFactor = index % 4 < 2 ? 1.35 : 2.5; - for (const revision of block.revisions) { - const isCandidate = revision.revision === 'candidate'; - for (const workload of revision.workloads) { - const value = isCandidate ? candidateFactor : 1; - workload.measured = [value]; - workload.measuredElapsedMs = [value]; - } - } - } - - const analysis = analyzeParser(noisy); - const largest = analysis.endpoints.find((item) => - item.key.endsWith(':16000') - ); - assert.ok(largest.familyAdjusted95.lowerRatio > 1.1); - assert.equal(largest.precision.targetMet, false); - assert.equal(analysis.runtimeStatus, 'inconclusive'); -}); - -test('schema-v2 validation rejects missing keys and nonpositive timings', () => { - assert.doesNotThrow(() => validateSchemaV2Artifact(artifact())); - const missing = artifact(); - missing.blocks[0].revisions[0].workloads.pop(); - assert.throws( - () => validateSchemaV2Artifact(missing), - /missing workload keys/ - ); - const nonpositive = artifact(); - nonpositive.blocks[0].revisions[0].workloads[0].measured[0] = 0; - assert.throws( - () => validateSchemaV2Artifact(nonpositive), - /nonpositive timings/ - ); -}); - -test('schema-v2 decision configuration is complete and authoritative', () => { - const strict = artifact(); - strict.config = { - decisionConfigVersion: DECISION_CONFIG_VERSION, - requestedBlocks: 20, - minimumBlocks: 20, - maxAttempts: 20, - targetBatchMs: 25, - warmupMinimum: 5, - warmupMaximum: 10, - measuredBatchCount: 6, - driftThreshold: 0.15, - bootstrapResamples: 100, - confidence: 0.95, - runtimeNonRegressionMargin: 1.1, - equivalenceMargin: 1.1, - precisionMargin: 1.1, - precisionMethod: PRECISION_METHOD, - growthThreshold: 2.5, - orderInteractionThreshold: Math.log(1.1), - intervalMethod: 'stratified-max-t-studentized-bootstrap', - }; - assert.doesNotThrow(() => validateSchemaV2Artifact(strict)); - for (const key of ['precisionMargin', 'confidence', 'intervalMethod']) { - const missing = structuredClone(strict); - delete missing.config[key]; - assert.throws( - () => validateSchemaV2Artifact(missing), - new RegExp(`missing decision parameter ${key}`) - ); - } - for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { - const invalid = structuredClone(strict); - invalid.config.precisionMargin = value; - assert.throws(() => validateSchemaV2Artifact(invalid), /precisionMargin/); - } - const inconsistent = structuredClone(strict); - inconsistent.config.maxAttempts = 19; - assert.throws(() => validateSchemaV2Artifact(inconsistent), /maxAttempts/); -}); - -test('schema-v2 validation enforces the twenty-block statistical floor', () => { - const insufficient = artifact(); - insufficient.blocks = insufficient.blocks.slice(0, 1); - assert.throws( - () => validateSchemaV2Artifact(insufficient), - /fewer than 20 valid blocks/ - ); -}); - -test('schema-v2 validates retained attempts and raw timing fields', () => { - const retained = artifact(); - retained.attempts = structuredClone(retained.blocks); - retained.attempts[0].revisions[0].workloads[0].measuredElapsedMs[0] = 0; - assert.throws( - () => validateSchemaV2Artifact(retained), - /attempt 0 .*invalid measuredElapsedMs/ - ); - - const inconsistent = artifact(); - inconsistent.attempts = structuredClone(inconsistent.blocks); - inconsistent.attempts[0].rejectionReason = 'drift'; - assert.throws( - () => validateSchemaV2Artifact(inconsistent), - /invalid rejection reason/ - ); -}); - -test('schema-v2 corpus validation requires complete replicated groups', () => { - const valid = corpusArtifact(); - assert.doesNotThrow(() => validateSchemaV2Artifact(valid)); - const incomplete = structuredClone(valid); - incomplete.replicates[0].batches[0].measurements = []; - assert.throws( - () => validateSchemaV2Artifact(incomplete), - /incomplete groups/ - ); - const unbalanced = structuredClone(valid); - unbalanced.replicates[0].batches[0].order = 'reference-first'; - assert.throws( - () => validateSchemaV2Artifact(unbalanced), - /unbalanced process orders/ - ); - const invalidPermutation = structuredClone(valid); - invalidPermutation.replicates[0].permutation[0] = 1; - assert.throws( - () => validateSchemaV2Artifact(invalidPermutation), - /invalid permutation/ - ); -}); - -test('parser reanalysis stays inconclusive below the statistical floor', () => { - const insufficient = artifact(); - insufficient.blocks = insufficient.blocks.slice(0, 1); - insufficient.analysis = { status: 'inconclusive' }; - const malformedAttempt = { ...insufficient, attempts: [{}] }; - assert.throws( - () => validateSchemaV2Artifact(malformedAttempt), - /attempt 0 has an invalid process order/ - ); - insufficient.attempts = [insufficient.blocks[0]]; - assert.deepEqual(analyzeParser(insufficient), { - status: 'inconclusive', - validBlocks: 1, - reason: 'fewer than 20 valid blocks', - }); -}); diff --git a/test/unit/compare-parser-benchmarks.test.js b/test/unit/compare-parser-benchmarks.test.js index 953f2f6..416931d 100644 --- a/test/unit/compare-parser-benchmarks.test.js +++ b/test/unit/compare-parser-benchmarks.test.js @@ -8,13 +8,13 @@ import { compareParserBenchmarks, exitCodeFor, reanalyzeParserBenchmark, -} from '../../scripts/compare-parser-benchmarks.js'; +} from '../../scripts/benchmark/compare-parser-benchmarks.js'; import { syntheticParserArtifact } from '../helpers/benchmark-artifact.js'; import { CORPUS_INTERVAL_METHOD, DECISION_CONFIG_VERSION, PRECISION_METHOD, -} from '../../scripts/lib/benchmark.js'; +} from '../../scripts/benchmark/benchmark.js'; test('exitCodeFor maps benchmark analysis statuses to exit codes', () => { assert.equal(exitCodeFor('pass'), 0); @@ -151,7 +151,7 @@ test('rejects non-schema-v2 artifacts and non-string paths', () => { ); assert.throws( () => compareParserBenchmarks([invalidPath]), - /Usage: node scripts\/compare-parser-benchmarks\.js / + /Usage: node scripts\/benchmark\/compare-parser-benchmarks\.js / ); } finally { rmSync(directory, { recursive: true, force: true }); @@ -173,7 +173,7 @@ test('CLI outputs analysis and exits with expected codes', () => { const scriptPath = join( process.cwd(), - 'scripts/compare-parser-benchmarks.js' + 'scripts/benchmark/compare-parser-benchmarks.js' ); const validRun = spawnSync(process.execPath, [scriptPath, artifactPath], { diff --git a/test/unit/corpus-benchmark.test.js b/test/unit/corpus-benchmark.test.js index ea9fd52..77026d1 100644 --- a/test/unit/corpus-benchmark.test.js +++ b/test/unit/corpus-benchmark.test.js @@ -8,12 +8,12 @@ import { analyzeCorpus, CORPUS_EQUIVALENCE_MARGIN, groupResults, -} from '../../scripts/lib/corpus-benchmark.js'; +} from '../../scripts/benchmark/corpus-benchmark.js'; import { CORPUS_INTERVAL_METHOD, DECISION_CONFIG_VERSION, PRECISION_METHOD, -} from '../../scripts/lib/benchmark.js'; +} from '../../scripts/benchmark/benchmark.js'; function artifact(ratioForReplicate) { const groups = ['exact', 'sum']; @@ -226,7 +226,7 @@ test('corpus worker verifies public reducer outputs and hashes their content', ( return spawnSync( process.execPath, [ - join(process.cwd(), 'scripts/corpus-benchmark-worker.js'), + join(process.cwd(), 'scripts/benchmark/corpus-benchmark-worker.js'), JSON.stringify(payload), ], { encoding: 'utf8' } diff --git a/test/unit/reduceCalc-core.test.js b/test/unit/reduceCalc-core.test.js index ebc6bb7..6c83341 100644 --- a/test/unit/reduceCalc-core.test.js +++ b/test/unit/reduceCalc-core.test.js @@ -3,14 +3,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import reduceCalc from 'postcss-calc/reduce'; -import { - hasPotentialMathFunction, - QUICK_MATH_TEST, -} from '../../src/lib/functions.js'; -import { createReduceCalcTestHarness } from '../helpers/reduceCalc.js'; - -const { reduceWithWarnings, assertIdempotent } = - createReduceCalcTestHarness(reduceCalc); describe('reduceCalc: basic pipeline', () => { test('reduceCalc: reduces simple calc in a value', () => { @@ -264,222 +256,4 @@ describe('reduceCalc: basic pipeline', () => { ); }); }); - - test('reduceCalc: unwrapSingleNegativeNumber aliases unwrapSingleValue', () => { - assert.equal( - reduceCalc('a:nth-child(calc(1 - 2))', { - unwrapSingleNegativeNumber: true, - }), - 'a:nth-child(-1)' - ); - assert.equal( - reduceCalc('calc(1 - 2)', { unwrapSingleNegativeNumber: true }), - '-1' - ); - assert.equal( - reduceCalc('calc(1 / 2)', { unwrapSingleNegativeNumber: true }), - '.5' - ); - assert.equal( - reduceCalc('calc(-1 / 2)', { unwrapSingleNegativeNumber: true }), - '-.5' - ); - }); - - test('reduceCalc: unwrapSingleValue unwraps negative and fractional scalars', () => { - assert.equal(reduceCalc('calc(1 - 2)', { unwrapSingleValue: true }), '-1'); - assert.equal(reduceCalc('calc(1 / 2)', { unwrapSingleValue: true }), '.5'); - }); - - test('reduceCalc: multiple calcs in one value', () => { - assert.equal( - reduceCalc('calc(1px + 1px) calc(2px + 2px)'), - 'calc(2px) calc(4px)' - ); - }); - - test('reduceCalc: one value preserves bytes around several token-slice calculations', () => { - assert.equal( - reduceCalc( - '\\66 oo calc(/*a*/-2px + +5px) /\\*keep*\\/ MIN(4px,2px)\\9' - ), - '\\66 oo calc(3px) /\\*keep*\\/ calc(2px)\\9' - ); - }); - - test('reduceCalc: non-math functions are preserved quickly without change', () => { - assert.equal(reduceCalc('rgb(255, 0, 0)'), 'rgb(255, 0, 0)'); - assert.equal(reduceCalc('translate(10px, 20px)'), 'translate(10px, 20px)'); - assert.equal(reduceCalc('var(--my-color)'), 'var(--my-color)'); - }); - - test('reduceCalc: hasPotentialMathFunction detects all supported math functions and escapes', () => { - const supported = [ - 'calc', - '-webkit-calc', - '-moz-calc', - 'min', - 'max', - 'clamp', - 'abs', - 'sign', - 'mod', - 'rem', - 'round', - 'sin', - 'cos', - 'tan', - 'asin', - 'acos', - 'atan', - 'atan2', - 'pow', - 'sqrt', - 'hypot', - 'log', - 'exp', - ]; - for (const fn of supported) { - assert.equal( - hasPotentialMathFunction(`${fn}(10px)`), - true, - `Expected ${fn}() to be detected` - ); - assert.equal( - hasPotentialMathFunction(`${fn.toUpperCase()}(10PX)`), - true, - `Expected ${fn.toUpperCase()}() to be detected` - ); - assert.equal( - QUICK_MATH_TEST.test(`${fn}(10px)`), - true, - `Expected QUICK_MATH_TEST to match ${fn}()` - ); - } - - // Escapes bypass regex check - assert.equal(hasPotentialMathFunction('\\63 alc(10px)'), true); - assert.equal(hasPotentialMathFunction('foo\\(bar'), true); - - // Negative cases - assert.equal(hasPotentialMathFunction('10px'), false); - assert.equal(hasPotentialMathFunction('red'), false); - assert.equal(hasPotentialMathFunction('rgb(255, 0, 0)'), false); - assert.equal(hasPotentialMathFunction('var(--my-var)'), false); - assert.equal(hasPotentialMathFunction('translate(10px, 20px)'), false); - }); - - test('reduceCalc: nested calculations inside non-math functions are reduced', () => { - assert.equal( - reduceCalc('translate(calc(10px + 20px), calc(5px * 2))'), - 'translate(calc(30px), calc(10px))' - ); - }); - - test('reduceCalc: transformations are idempotent', () => { - const opts = { warnWhenCannotResolve: true }; - assertIdempotent('calc(1px + 2px) calc(2px + 3px)', opts); - const unresolved = 'calc(100% + var(--x))'; - const first = reduceWithWarnings(unresolved, opts); - const second = reduceWithWarnings(first.output, opts); - assert.equal(first.output, unresolved); - assert.equal(second.output, first.output); - assert.deepEqual(second.warnings, first.warnings); - }); - - test('reduceCalc: removes leading zero from resolved decimals', () => { - assert.equal(reduceCalc('calc(1px / 4)'), 'calc(.25px)'); - assert.equal(reduceCalc('calc(1 / 2000000)'), 'calc(5e-7)'); - }); - - test('reduceCalc: fractional unitless math results retain calc()', () => { - assert.equal(reduceCalc('calc(1 / 2)'), 'calc(.5)'); - assert.equal(reduceCalc('sqrt(2)'), 'calc(1.41421)'); - assert.equal(reduceCalc('calc(2 / 1)'), 'calc(2)'); - }); - - test('reduceCalc: preserves the unparsable unary minus form byte-for-byte', () => { - // `-(...)` has no production in the grammar; browsers drop - // the declaration, so the reducer must not rewrite it into valid CSS. - assert.equal( - reduceCalc('calc(-(var(--a) + var(--b)))'), - 'calc(-(var(--a) + var(--b)))' - ); - assert.equal( - reduceCalc('calc(-(10px + var(--a)))'), - 'calc(-(10px + var(--a)))' - ); - }); - - test('reduceCalc: preserves the unparsable unary plus form byte-for-byte', () => { - // `+(...)` has no production in the grammar; browsers drop - // the declaration, so the reducer must not rewrite it into valid CSS. - assert.equal(reduceCalc('calc(+(10px + 20px))'), 'calc(+(10px + 20px))'); - assert.equal(reduceCalc('calc(+var(--x))'), 'calc(+var(--x))'); - assert.equal( - reduceCalc('calc(+(var(--a) + var(--b)))'), - 'calc(+(var(--a) + var(--b)))' - ); - }); - - test('reduceCalc: unary plus on a signed number token simplifies to the bare value', () => { - // A leading `+` before a / token is a valid no-op, so - // this form is parsed and reduced rather than preserved verbatim. - assert.equal(reduceCalc('calc(+10px)'), 'calc(10px)'); - }); - - test('reduceCalc: preserves grouping through explicit -1 multiplication', () => { - assert.equal( - reduceCalc('calc((var(--a) + var(--b)) * -1)'), - 'calc(-1 * (var(--a) + var(--b)))' - ); - }); - - test('reduceCalc: preserves grouping for opaque subtraction', () => { - assert.equal( - reduceCalc('calc(5px - (var(--var-1) + var(--var-2)))'), - 'calc(5px - (var(--var-1) + var(--var-2)))' - ); - assert.equal( - reduceCalc('calc(var(--a) - (var(--b) + var(--c)))'), - 'calc(var(--a) - (var(--b) + var(--c)))' - ); - assert.equal( - reduceCalc('calc(var(--a) - (var(--b) - var(--c)))'), - 'calc(var(--a) - (var(--b) - var(--c)))' - ); - assert.equal( - reduceCalc('calc(5px - (10px + var(--a)))'), - 'calc(5px - (10px + var(--a)))' - ); - }); - - test('reduceCalc: preserves nested opaque grouping and simplifies var fallbacks', () => { - assert.equal( - reduceCalc( - 'calc(var(--a) - (var(--b) - (var(--c, calc(1px + 2px)) + var(--d))))' - ), - 'calc(var(--a) - (var(--b) - (var(--c, calc(3px)) + var(--d))))' - ); - }); - - test('reduceCalc: preserves unresolved calc grouping in opaque fallbacks', () => { - assert.equal( - reduceCalc('calc(env(foo, calc(var(--x) + 1px) solid))'), - 'calc(env(foo, calc(1px + var(--x)) solid))' - ); - assert.equal( - reduceCalc('calc(2 * env(foo, calc(var(--x) + 1px)))'), - 'calc(2 * env(foo, calc(1px + var(--x))))' - ); - }); - - test('reduceCalc: simplifies supported math anywhere in valid var() fallbacks', () => { - assert.equal( - reduceCalc( - 'calc(var(--theme\\-size , foo(calc(1px + 2px), [max(4px, 5px)]), calc(6px + 7px)) + 1px)' - ), - 'calc(1px + var(--theme\\-size , foo(calc(3px), [calc(5px)]), calc(13px)))' - ); - }); }); diff --git a/test/unit/reduceCalc-fallbacks.test.js b/test/unit/reduceCalc-fallbacks.test.js index c6fef54..d8679ff 100644 --- a/test/unit/reduceCalc-fallbacks.test.js +++ b/test/unit/reduceCalc-fallbacks.test.js @@ -22,6 +22,18 @@ describe('reduceCalc: nested fallbacks and prefixes', () => { ); }); + test('reduceCalc: reduces var() fallbacks with very many components', () => { + const count = 150_000; + /** @type {Error[]} */ + const errors = []; + const output = reduceCalc( + `calc(var(--x, ${'calc(1px + 1px) '.repeat(count)}) + 1px)`, + { onParseError: (error) => errors.push(error) } + ); + assert.deepEqual(errors, []); + assert.equal(output, `calc(1px + var(--x, ${'calc(2px) '.repeat(count)}))`); + }); + test('reduceCalc: mixed relative and convertible absolute units fold correctly regardless of order', () => { assert.equal(reduceCalc('calc(1em + 1px + 1in)'), 'calc(1em + 97px)'); assert.equal(reduceCalc('calc(1px + 1em + 1in)'), 'calc(97px + 1em)'); diff --git a/test/unit/reduceCalc-pipeline.test.js b/test/unit/reduceCalc-pipeline.test.js new file mode 100644 index 0000000..18971f5 --- /dev/null +++ b/test/unit/reduceCalc-pipeline.test.js @@ -0,0 +1,232 @@ +// Standalone reduceCalc tests for unwrapping, grouping, and idempotence. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import reduceCalc from 'postcss-calc/reduce'; +import { + hasPotentialMathFunction, + QUICK_MATH_TEST, +} from '../../src/lib/functions.js'; +import { createReduceCalcTestHarness } from '../helpers/reduceCalc.js'; + +const { reduceWithWarnings, assertIdempotent } = + createReduceCalcTestHarness(reduceCalc); + +describe('reduceCalc: basic pipeline', () => { + test('reduceCalc: unwrapSingleNegativeNumber aliases unwrapSingleValue', () => { + assert.equal( + reduceCalc('a:nth-child(calc(1 - 2))', { + unwrapSingleNegativeNumber: true, + }), + 'a:nth-child(-1)' + ); + assert.equal( + reduceCalc('calc(1 - 2)', { unwrapSingleNegativeNumber: true }), + '-1' + ); + assert.equal( + reduceCalc('calc(1 / 2)', { unwrapSingleNegativeNumber: true }), + '.5' + ); + assert.equal( + reduceCalc('calc(-1 / 2)', { unwrapSingleNegativeNumber: true }), + '-.5' + ); + }); + + test('reduceCalc: unwrapSingleValue unwraps negative and fractional scalars', () => { + assert.equal(reduceCalc('calc(1 - 2)', { unwrapSingleValue: true }), '-1'); + assert.equal(reduceCalc('calc(1 / 2)', { unwrapSingleValue: true }), '.5'); + }); + + test('reduceCalc: multiple calcs in one value', () => { + assert.equal( + reduceCalc('calc(1px + 1px) calc(2px + 2px)'), + 'calc(2px) calc(4px)' + ); + }); + + test('reduceCalc: one value preserves bytes around several token-slice calculations', () => { + assert.equal( + reduceCalc( + '\\66 oo calc(/*a*/-2px + +5px) /\\*keep*\\/ MIN(4px,2px)\\9' + ), + '\\66 oo calc(3px) /\\*keep*\\/ calc(2px)\\9' + ); + }); + + test('reduceCalc: non-math functions are preserved quickly without change', () => { + assert.equal(reduceCalc('rgb(255, 0, 0)'), 'rgb(255, 0, 0)'); + assert.equal(reduceCalc('translate(10px, 20px)'), 'translate(10px, 20px)'); + assert.equal(reduceCalc('var(--my-color)'), 'var(--my-color)'); + }); + + test('reduceCalc: hasPotentialMathFunction detects all supported math functions and escapes', () => { + const supported = [ + 'calc', + '-webkit-calc', + '-moz-calc', + 'min', + 'max', + 'clamp', + 'abs', + 'sign', + 'mod', + 'rem', + 'round', + 'sin', + 'cos', + 'tan', + 'asin', + 'acos', + 'atan', + 'atan2', + 'pow', + 'sqrt', + 'hypot', + 'log', + 'exp', + ]; + for (const fn of supported) { + assert.equal( + hasPotentialMathFunction(`${fn}(10px)`), + true, + `Expected ${fn}() to be detected` + ); + assert.equal( + hasPotentialMathFunction(`${fn.toUpperCase()}(10PX)`), + true, + `Expected ${fn.toUpperCase()}() to be detected` + ); + assert.equal( + QUICK_MATH_TEST.test(`${fn}(10px)`), + true, + `Expected QUICK_MATH_TEST to match ${fn}()` + ); + } + + // Escapes bypass regex check + assert.equal(hasPotentialMathFunction('\\63 alc(10px)'), true); + assert.equal(hasPotentialMathFunction('foo\\(bar'), true); + + // Negative cases + assert.equal(hasPotentialMathFunction('10px'), false); + assert.equal(hasPotentialMathFunction('red'), false); + assert.equal(hasPotentialMathFunction('rgb(255, 0, 0)'), false); + assert.equal(hasPotentialMathFunction('var(--my-var)'), false); + assert.equal(hasPotentialMathFunction('translate(10px, 20px)'), false); + }); + + test('reduceCalc: nested calculations inside non-math functions are reduced', () => { + assert.equal( + reduceCalc('translate(calc(10px + 20px), calc(5px * 2))'), + 'translate(calc(30px), calc(10px))' + ); + }); + + test('reduceCalc: transformations are idempotent', () => { + const opts = { warnWhenCannotResolve: true }; + assertIdempotent('calc(1px + 2px) calc(2px + 3px)', opts); + const unresolved = 'calc(100% + var(--x))'; + const first = reduceWithWarnings(unresolved, opts); + const second = reduceWithWarnings(first.output, opts); + assert.equal(first.output, unresolved); + assert.equal(second.output, first.output); + assert.deepEqual(second.warnings, first.warnings); + }); + + test('reduceCalc: removes leading zero from resolved decimals', () => { + assert.equal(reduceCalc('calc(1px / 4)'), 'calc(.25px)'); + assert.equal(reduceCalc('calc(1 / 2000000)'), 'calc(5e-7)'); + }); + + test('reduceCalc: fractional unitless math results retain calc()', () => { + assert.equal(reduceCalc('calc(1 / 2)'), 'calc(.5)'); + assert.equal(reduceCalc('sqrt(2)'), 'calc(1.41421)'); + assert.equal(reduceCalc('calc(2 / 1)'), 'calc(2)'); + }); + + test('reduceCalc: preserves the unparsable unary minus form byte-for-byte', () => { + // `-(...)` has no production in the grammar; browsers drop + // the declaration, so the reducer must not rewrite it into valid CSS. + assert.equal( + reduceCalc('calc(-(var(--a) + var(--b)))'), + 'calc(-(var(--a) + var(--b)))' + ); + assert.equal( + reduceCalc('calc(-(10px + var(--a)))'), + 'calc(-(10px + var(--a)))' + ); + }); + + test('reduceCalc: preserves the unparsable unary plus form byte-for-byte', () => { + // `+(...)` has no production in the grammar; browsers drop + // the declaration, so the reducer must not rewrite it into valid CSS. + assert.equal(reduceCalc('calc(+(10px + 20px))'), 'calc(+(10px + 20px))'); + assert.equal(reduceCalc('calc(+var(--x))'), 'calc(+var(--x))'); + assert.equal( + reduceCalc('calc(+(var(--a) + var(--b)))'), + 'calc(+(var(--a) + var(--b)))' + ); + }); + + test('reduceCalc: unary plus on a signed number token simplifies to the bare value', () => { + // A leading `+` before a / token is a valid no-op, so + // this form is parsed and reduced rather than preserved verbatim. + assert.equal(reduceCalc('calc(+10px)'), 'calc(10px)'); + }); + + test('reduceCalc: preserves grouping through explicit -1 multiplication', () => { + assert.equal( + reduceCalc('calc((var(--a) + var(--b)) * -1)'), + 'calc(-1 * (var(--a) + var(--b)))' + ); + }); + + test('reduceCalc: preserves grouping for opaque subtraction', () => { + assert.equal( + reduceCalc('calc(5px - (var(--var-1) + var(--var-2)))'), + 'calc(5px - (var(--var-1) + var(--var-2)))' + ); + assert.equal( + reduceCalc('calc(var(--a) - (var(--b) + var(--c)))'), + 'calc(var(--a) - (var(--b) + var(--c)))' + ); + assert.equal( + reduceCalc('calc(var(--a) - (var(--b) - var(--c)))'), + 'calc(var(--a) - (var(--b) - var(--c)))' + ); + assert.equal( + reduceCalc('calc(5px - (10px + var(--a)))'), + 'calc(5px - (10px + var(--a)))' + ); + }); + + test('reduceCalc: preserves nested opaque grouping and simplifies var fallbacks', () => { + assert.equal( + reduceCalc( + 'calc(var(--a) - (var(--b) - (var(--c, calc(1px + 2px)) + var(--d))))' + ), + 'calc(var(--a) - (var(--b) - (var(--c, calc(3px)) + var(--d))))' + ); + }); + + test('reduceCalc: preserves unresolved calc grouping in opaque fallbacks', () => { + assert.equal( + reduceCalc('calc(env(foo, calc(var(--x) + 1px) solid))'), + 'calc(env(foo, calc(1px + var(--x)) solid))' + ); + assert.equal( + reduceCalc('calc(2 * env(foo, calc(var(--x) + 1px)))'), + 'calc(2 * env(foo, calc(1px + var(--x))))' + ); + }); + + test('reduceCalc: simplifies supported math anywhere in valid var() fallbacks', () => { + assert.equal( + reduceCalc( + 'calc(var(--theme\\-size , foo(calc(1px + 2px), [max(4px, 5px)]), calc(6px + 7px)) + 1px)' + ), + 'calc(1px + var(--theme\\-size , foo(calc(3px), [calc(5px)]), calc(13px)))' + ); + }); +}); diff --git a/test/unit/serialize-core.test.js b/test/unit/serialize-core.test.js new file mode 100644 index 0000000..d4b6ad0 --- /dev/null +++ b/test/unit/serialize-core.test.js @@ -0,0 +1,292 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { serialize as serializeSource } from '../../src/lib/serialize.js'; +import { + num, + dim, + call, + opaqueCall, + ident, + mkSum, + mkProduct, +} from '../../src/lib/node.js'; + +const serialize = (node, opts = {}) => serializeSource(node, opts); + +describe('serialize: core syntax and expressions', () => { + test('serialize: single number uses standard calculation syntax', () => { + assert.equal(serialize(num(42)), 'calc(42)'); + }); + + test('serialize: single dimension uses standard calculation syntax', () => { + assert.equal(serialize(dim(10, 'px')), 'calc(10px)'); + }); + + test('serialize: sum wrapped in calc(), spaces around +/-', () => { + const ast = mkSum([ + { sign: 1, node: dim(1, 'px') }, + { sign: 1, node: dim(2, 'px') }, + ]); + assert.equal(serialize(ast), 'calc(1px + 2px)'); + }); + + test('serialize: spaces around every binary operator', () => { + const ast = mkProduct([ + { exponent: 1, node: num(2) }, + { exponent: 1, node: dim(3, 'px') }, + ]); + assert.equal(serialize(ast), 'calc(2 * 3px)'); + }); + + test('serialize: self-wrapping call — no extra calc()', () => { + const ast = { + type: 'Call', + name: 'min', + args: [dim(1, 'px'), dim(2, 'px')], + }; + assert.equal(serialize(ast), 'min(1px, 2px)'); + }); + + test('serialize: var() call', () => { + const ast = opaqueCall('var', [ident('--x')]); + assert.equal(serialize(ast), 'var(--x)'); + }); + + test('serialize: Sum inside Product gets parens', () => { + // (1 + 2) * 3 — the Sum as a factor must be parenthesized. + const innerSum = mkSum([ + { sign: 1, node: num(1) }, + { sign: 1, node: num(2) }, + ]); + const ast = mkProduct([ + { exponent: 1, node: innerSum }, + { exponent: 1, node: num(3) }, + ]); + assert.equal(serialize(ast), 'calc((1 + 2) * 3)'); + }); + + test('serialize: negative Dim via signed leaf → calc(-Xpx)', () => { + // Negatives live directly in the Dim value. The constructor helper + // `dim(-1, 'px')` returns a Dim with value -1, no Sum wrapper. + assert.equal(serialize(dim(-1, 'px')), 'calc(-1px)'); + }); + + test('serialize: single-term Sum with opaque gets calc() function', () => { + // `-1 * var(--x)` needs calc() so the leading minus isn't ambiguous. + const ast = mkSum([ + { + sign: -1, + node: opaqueCall('var', [ident('--x')]), + }, + ]); + assert.equal(serialize(ast), 'calc(-1 * var(--x))'); + }); + + test('serialize: custom calcName', () => { + const ast = mkSum([ + { sign: 1, node: dim(1, 'px') }, + { sign: 1, node: dim(2, 'px') }, + ]); + assert.equal( + serialize(ast, { calcName: '-webkit-calc' }), + '-webkit-calc(1px + 2px)' + ); + }); + + test('does not carry a negative scalar magnitude into positive siblings', () => { + const ast = mkSum([ + { sign: 1, node: num(-2) }, + { sign: 1, node: num(3) }, + { sign: 1, node: dim(4, 'px') }, + ]); + assert.equal(serialize(ast), 'calc(-2 + 3 + 4px)'); + }); + + test('scopes negated product coefficients to one product', () => { + const withCoefficient = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(2) }, + { exponent: 1, node: ident('x') }, + ]), + }, + ]); + const withoutCoefficient = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: ident('a') }, + { exponent: 1, node: ident('b') }, + ]), + }, + ]); + assert.equal(serialize(withCoefficient), 'calc(-2 * x)'); + assert.equal(serialize(withoutCoefficient), 'calc(-1 * a * b)'); + assert.equal( + serialize(call('min', [withCoefficient, withoutCoefficient])), + 'min(-2 * x, -1 * a * b)' + ); + }); + + test('isolates nested sums and products across call arguments', () => { + const ast = call('min', [ + mkSum([ + { sign: 1, node: num(1) }, + { sign: 1, node: num(2) }, + ]), + mkProduct([ + { exponent: 1, node: num(2) }, + { exponent: 1, node: dim(3, 'px') }, + ]), + ]); + assert.equal(serialize(ast), 'min(1 + 2, 2 * 3px)'); + }); + + test('serialize: displaySign flips negative Num to `-` operator', () => { + // `5 + Num(-3)` should render as `5 - 3`, not `5 + -3`. + // This kills the displaySign branch for Num with value<0. + const ast = mkSum([ + { sign: 1, node: { type: 'Num', value: 5 } }, + { sign: 1, node: { type: 'Num', value: -3 } }, + ]); + assert.equal(serialize(ast), 'calc(5 - 3)'); + }); + + test('serialize: displaySign flips negative Dim to `-` operator', () => { + // Same but for Dim leaves — `5px + Dim(-2, em)` → `5px - 2em`. + const ast = mkSum([ + { sign: 1, node: { type: 'Dim', value: 5, unit: 'px' } }, + { sign: 1, node: { type: 'Dim', value: -2, unit: 'em' } }, + ]); + assert.equal(serialize(ast), 'calc(5px - 2em)'); + }); + + test('serialize: negative leading Num keeps calc() function', () => { + assert.equal(serialize(num(-5)), 'calc(-5)'); + }); + + test('serialize: single-term Sum with sign=-1 and opaque call → calc(-1 * call)', () => { + // `-1 * var(--x)` shape — only reachable as a directly-constructed Sum + // (parser never produces it; mkSum would collapse if leaf). + const ast = { + type: 'Sum', + terms: [ + { + sign: -1, + node: opaqueCall('var', [ident('--x')]), + }, + ], + }; + assert.equal(serialize(ast), 'calc(-1 * var(--x))'); + }); + + test('serialize: single-term Sum with sign=-1 and Product serializes as -1 * factors', () => { + // `-1 * a * b` serializes without extra parentheses because multiplication is associative. + const ast = { + type: 'Sum', + terms: [ + { + sign: -1, + node: { + type: 'Product', + factors: [ + { exponent: 1, node: { type: 'Ident', name: 'a' } }, + { exponent: 1, node: { type: 'Ident', name: 'b' } }, + ], + }, + }, + ], + }; + assert.equal(serialize(ast), 'calc(-1 * a * b)'); + }); + + test('serialize: multi-term Sum with trailing zero-valued Dim', () => { + // `1px + 0em` — both non-zero positions tested; exercises the + // iteration body and operator choice for non-first terms. + const ast = mkSum([ + { sign: 1, node: dim(1, 'px') }, + { sign: 1, node: dim(0, 'em') }, + ]); + assert.equal(serialize(ast), 'calc(1px + 0em)'); + }); + + test('serialize: Product with leading denominator emits implicit 1', () => { + // `Product([{-1, 2px}])` (impossible from parser but constructible) + // should emit `1 / 2px`, exercising the exponent=-1 first-factor branch. + const ast = { + type: 'Product', + factors: [{ exponent: -1, node: dim(2, 'px') }], + }; + assert.equal(serialize(ast), 'calc(1 / 2px)'); + }); + + test('serialize: negated Product with leading denominator emits -1 / value', () => { + // Negation of `1 / 2px` cannot fold into a signed Dim leaf, so it must + // serialize as `-1 / 2px` rather than distributing over the denominator. + const ast = mkSum([ + { + sign: -1, + node: mkProduct([{ exponent: -1, node: dim(2, 'px') }]), + }, + ]); + assert.equal(serialize(ast), 'calc(-1 / 2px)'); + }); + + test('serialize: negating a Product with leading zero coefficient emits calc(-1 * 0) times the rest', () => { + // The negated coefficient is -0, which must take the signed-zero path + // (calc(-1 * 0)) instead of collapsing to plain `0` or `1`. + const ast = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(0) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]), + }, + ]); + assert.equal(serialize(ast), 'calc(calc(-1 * 0) * var(--x))'); + }); + + test('serialize: negating a Product with leading -0 coefficient emits positive zero times the rest', () => { + // Negating -0 yields +0, so the signed-zero path must not trigger. + const ast = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(-0) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]), + }, + ]); + assert.equal(serialize(ast), 'calc(0 * var(--x))'); + }); + + test('serialize: negating a Product with leading Infinity coefficient emits -infinity times the rest', () => { + // The negated coefficient is -Infinity and must take the degenerate + // keyword path rather than the finite rounding path. + const ast = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(Infinity) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]), + }, + ]); + assert.equal(serialize(ast), 'calc(-infinity * var(--x))'); + }); + + test('serialize: negating a Product with leading -Infinity coefficient emits infinity times the rest', () => { + const ast = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(-Infinity) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]), + }, + ]); + assert.equal(serialize(ast), 'calc(infinity * var(--x))'); + }); +}); diff --git a/test/unit/serialize-policy.test.js b/test/unit/serialize-policy.test.js new file mode 100644 index 0000000..2916909 --- /dev/null +++ b/test/unit/serialize-policy.test.js @@ -0,0 +1,150 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { serialize as serializeSource } from '../../src/lib/serialize.js'; +import { num, dim } from '../../src/lib/node.js'; +import { tokenize } from '@csstools/css-tokenizer'; +import { indexBlocks } from '../../src/lib/block-index.js'; +import { parse } from '../../src/lib/parser.js'; +import { simplify } from '../../src/lib/simplify.js'; + +const serialize = (node, opts = {}) => serializeSource(node, opts); + +describe('serialize: scalar context policy', () => { + const policies = [ + { name: 'standard', options: {} }, + { name: 'unwrapped', options: { unwrapSingleValue: true } }, + ]; + const cases = [ + { + name: 'negative integral Num', + node: num(-2), + expected: ['calc(-2)', '-2'], + }, + { + name: 'negative fractional Num', + node: num(-0.5), + expected: ['calc(-.5)', '-.5'], + }, + { + name: 'positive integral Num', + node: num(2), + expected: ['calc(2)', '2'], + }, + { + name: 'positive fractional Num', + node: num(0.5), + expected: ['calc(.5)', '.5'], + }, + { + name: 'positive zero Num', + node: num(0), + expected: ['calc(0)', '0'], + }, + { + name: 'negative zero Num', + node: num(-0), + expected: ['calc(0)', '0'], + }, + { + name: 'negative integral Dim', + node: dim(-2, 'px'), + expected: ['calc(-2px)', '-2px'], + }, + { + name: 'negative fractional Dim', + node: dim(-0.5, 'px'), + expected: ['calc(-.5px)', '-.5px'], + }, + { + name: 'positive integral Dim', + node: dim(2, 'px'), + expected: ['calc(2px)', '2px'], + }, + { + name: 'positive fractional Dim', + node: dim(0.5, 'px'), + expected: ['calc(.5px)', '.5px'], + }, + { + name: 'negative zero Dim', + node: dim(-0, 'px'), + expected: ['calc(0px)', '0px'], + }, + { + name: 'Infinity Num', + node: num(Infinity), + expected: ['calc(infinity)', 'calc(infinity)'], + }, + { + name: 'NaN Dim', + node: dim(Number.NaN, 'px'), + expected: ['calc(NaN * 1px)', 'calc(NaN * 1px)'], + }, + ]; + + for (const scalarCase of cases) { + for (const [policyIndex, policy] of policies.entries()) { + test(`${scalarCase.name} uses ${policy.name}`, () => { + const output = serialize(scalarCase.node, policy.options); + assert.equal(output, scalarCase.expected[policyIndex]); + + const tokens = tokenize({ css: output }); + const reparsed = simplify( + parse(tokens, 0, tokens.length, indexBlocks(tokens)) + ); + assert.equal( + serialize(reparsed, policy.options), + output, + 'formatted output must round-trip under the same policy' + ); + }); + } + } + + const precisionCases = [ + { + name: 'precision false keeps a fractional Num', + node: num(0.5), + options: { precision: false }, + expected: ['calc(.5)', '.5'], + }, + { + name: 'precision zero classifies a rounded Num as integral', + node: num(1.4), + options: { precision: 0 }, + expected: ['calc(1)', '1'], + }, + { + name: 'decimal precision classifies the formatted fraction', + node: num(1.4), + options: { precision: 1 }, + expected: ['calc(1.4)', '1.4'], + }, + { + name: 'precision zero censors a tiny negative value to zero', + node: num(-1e-13), + options: { precision: 0 }, + expected: ['calc(0)', '0'], + }, + { + name: 'decimal precision applies to a dimensional fraction', + node: dim(1.234, 'px'), + options: { precision: 2 }, + expected: ['calc(1.23px)', '1.23px'], + }, + ]; + + for (const precisionCase of precisionCases) { + for (const [policyIndex, policy] of policies.entries()) { + test(`${precisionCase.name} uses ${policy.name}`, () => { + assert.equal( + serialize(precisionCase.node, { + ...precisionCase.options, + ...policy.options, + }), + precisionCase.expected[policyIndex] + ); + }); + } + } +}); diff --git a/test/unit/serialize-precision.test.js b/test/unit/serialize-precision.test.js new file mode 100644 index 0000000..a97dd9b --- /dev/null +++ b/test/unit/serialize-precision.test.js @@ -0,0 +1,271 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { serialize as serializeSource } from '../../src/lib/serialize.js'; +import { + num, + dim, + call, + opaqueCall, + ident, + mkSum, + mkProduct, +} from '../../src/lib/node.js'; + +const serialize = (node, opts = {}) => serializeSource(node, opts); + +describe('serialize: precision and rounding', () => { + test('serialize: precision option applied to numbers and dimensions', () => { + assert.equal( + serialize(dim(1.123456789, 'px'), { precision: 2 }), + 'calc(1.12px)' + ); + assert.equal(serialize(num(1.123456789), { precision: 0 }), 'calc(1)'); + assert.equal(serialize(num(1.4), { precision: 0 }), 'calc(1)'); + assert.equal(serialize(num(1.4), { precision: 1 }), 'calc(1.4)'); + }); + + test('serialize: precision false keeps full value', () => { + assert.equal( + serialize(dim(1.123456789, 'px'), { precision: false }), + 'calc(1.123456789px)' + ); + assert.equal( + serialize(num(1 / 3), { precision: false }), + 'calc(.3333333333333333)' + ); + assert.equal( + serialize(dim(1 / 3, 'px'), { precision: false }), + 'calc(.3333333333333333px)' + ); + }); + + test('serialize: rounds decimal midpoints away from zero accurately', () => { + assert.equal(serialize(num(1.005), { precision: 2 }), 'calc(1.01)'); + assert.equal(serialize(num(-1.005), { precision: 2 }), 'calc(-1.01)'); + assert.equal(serialize(dim(1.005, 'px'), { precision: 2 }), 'calc(1.01px)'); + assert.equal( + serialize(dim(-1.005, 'px'), { precision: 2 }), + 'calc(-1.01px)' + ); + assert.equal(serialize(num(1.000005), { precision: 5 }), 'calc(1.00001)'); + assert.equal(serialize(num(-1.000005), { precision: 5 }), 'calc(-1.00001)'); + }); + + test('serialize: rounds large fractional magnitudes without float drift', () => { + // Scaling through Number(text + 'e' + p) loses the rounding boundary once + // the shifted value exceeds Number.MAX_SAFE_INTEGER. + assert.equal( + serialize(num(312834450754803.44), { precision: 1 }), + 'calc(312834450754803.4)' + ); + assert.equal( + serialize(num(312834450754803.44), { precision: 6 }), + 'calc(312834450754803.44)' + ); + assert.equal( + serialize(dim(-312834450754803.44, 'px'), { precision: 1 }), + 'calc(-312834450754803.4px)' + ); + assert.equal( + serialize(num(39969.492943459234), { precision: 11 }), + 'calc(39969.49294345923)' + ); + }); + + test('serialize: carries a rounding carry through trailing nines', () => { + // Rounding up 999.995 must propagate the carry across all nines to 1000. + assert.equal(serialize(num(999.995), { precision: 2 }), 'calc(1000)'); + assert.equal(serialize(num(-999.995), { precision: 2 }), 'calc(-1000)'); + // All-nines carry combined with digit-string rounding beyond the safe + // shift range. + assert.equal( + serialize(num(999999999999.995), { precision: 2 }), + 'calc(1000000000000)' + ); + }); + + test('serialize: rounds sub-1 midpoints away from zero and preserves sub-precision values', () => { + assert.equal(serialize(num(0.05), { precision: 1 }), 'calc(.1)'); + assert.equal(serialize(num(-0.05), { precision: 1 }), 'calc(-.1)'); + assert.equal(serialize(num(0.005), { precision: 2 }), 'calc(.01)'); + // 0.004 rounds to zero at 1 place but exceeds the noise floor, so the + // value is preserved rather than collapsed to 0. + assert.equal(serialize(num(0.004), { precision: 1 }), 'calc(.004)'); + assert.equal(serialize(num(-0.004), { precision: 1 }), 'calc(-.004)'); + }); + + test('serialize: leaves values unchanged when precision exceeds the shortest representation', () => { + // The shortest decimal of 7341.0297734398655 has 14 fractional digits, + // so rounding at precision 14 must return the value untouched instead of + // rescaling through digit strings. + assert.equal( + serialize(num(7341.0297734398655), { precision: 14 }), + 'calc(7341.0297734398655)' + ); + }); + + test('serialize: precision 0 rounds to integers away from zero', () => { + assert.equal(serialize(num(1.5), { precision: 0 }), 'calc(2)'); + assert.equal(serialize(num(-1.5), { precision: 0 }), 'calc(-2)'); + assert.equal(serialize(dim(1.2, 'px'), { precision: 0 }), 'calc(1px)'); + assert.equal(serialize(dim(-1.2, 'px'), { precision: 0 }), 'calc(-1px)'); + }); + + test('serialize: negative and fractional precisions are clamped and truncated', () => { + assert.equal(serialize(num(1.5), { precision: -1 }), 'calc(2)'); + assert.equal(serialize(num(-1.5), { precision: -2 }), 'calc(-2)'); + assert.equal(serialize(num(1.005), { precision: 2.5 }), 'calc(1.01)'); + assert.equal( + serialize(dim(1.005, 'px'), { precision: 2.9 }), + 'calc(1.01px)' + ); + }); + + test('serialize: boundary precisions avoid NaN overflow', () => { + assert.equal(serialize(num(1), { precision: 20 }), 'calc(1)'); + assert.equal(serialize(dim(1, 'px'), { precision: 100 }), 'calc(1px)'); + assert.equal(serialize(num(1), { precision: 310 }), 'calc(1)'); + assert.equal(serialize(dim(1, 'px'), { precision: 310 }), 'calc(1px)'); + assert.equal(serialize(num(1.5), { precision: 25 }), 'calc(1.5)'); + assert.equal(serialize(dim(1.5, 'px'), { precision: 25 }), 'calc(1.5px)'); + }); + + test('serialize: handles input magnitudes in scientific notation, MAX_SAFE_INTEGER, and noise floor', () => { + assert.equal(serialize(num(1e-7), { precision: 5 }), 'calc(1e-7)'); + assert.equal(serialize(num(1e-15), { precision: 5 }), 'calc(0)'); + assert.equal(serialize(num(1e21), { precision: 5 }), 'calc(1e+21)'); + assert.equal( + serialize(num(Number.MAX_SAFE_INTEGER), { precision: 2 }), + 'calc(9007199254740991)' + ); + assert.equal( + serialize(dim(Number.MAX_SAFE_INTEGER, 'px'), { precision: 2 }), + 'calc(9007199254740991px)' + ); + }); + + test('serialize: preserves signed zero vs zero under custom precision', () => { + const negNested = call('min', [num(-0), num(1)]); + const posNested = call('min', [num(0), num(1)]); + assert.equal( + serialize(negNested, { precision: 2 }), + 'min(calc(-1 * 0), 1)' + ); + assert.equal(serialize(posNested, { precision: 2 }), 'min(0, 1)'); + + const negDim = call('min', [dim(-0, 'px'), dim(1, 'px')]); + const posDim = call('min', [dim(0, 'px'), dim(1, 'px')]); + assert.equal( + serialize(negDim, { precision: 2 }), + 'min(calc(-1 * 0px), 1px)' + ); + assert.equal(serialize(posDim, { precision: 2 }), 'min(0px, 1px)'); + }); + + test('serialize: omits the leading zero from fractional numbers', () => { + assert.equal(serialize(num(0.5)), 'calc(.5)'); + assert.equal(serialize(num(-0.000001)), 'calc(-.000001)'); + assert.equal(serialize(dim(0.25, 'px')), 'calc(.25px)'); + assert.equal(serialize(num(0)), 'calc(0)'); + assert.equal(serialize(num(1e-7)), 'calc(1e-7)'); + }); + + test('serialize: lowers a signed zero number inside a calculation', () => { + const nested = call('min', [num(-0), num(1)]); + assert.equal( + serialize(nested, { precision: false }), + 'min(calc(-1 * 0), 1)' + ); + }); + + test('serialize: lowers signed zero leaves inside structural expressions', () => { + const ast = mkProduct([ + { exponent: 1, node: num(-0) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]); + assert.equal( + serialize(ast, { precision: false }), + 'calc(calc(-1 * 0) * var(--x))' + ); + + const dimensional = call('min', [dim(-0, 'px'), dim(1, 'px')]); + assert.equal( + serialize(dimensional, { precision: false }), + 'min(calc(-1 * 0px), 1px)' + ); + }); + + describe('serialize: sub-precision negative terms in sums and grouped sums', () => { + test('sub-precision negative number term serializes as 0 in sums', () => { + const ast = mkSum([ + { sign: 1, node: num(-1e-20) }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ]); + assert.equal(serialize(ast), 'calc(0 + var(--x))'); + }); + + test('sub-precision negative number term with precision: false retains negative value in sums', () => { + const ast = mkSum([ + { sign: 1, node: num(-1e-20) }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ]); + assert.equal( + serialize(ast, { precision: false }), + 'calc(-1e-20 + var(--x))' + ); + }); + + test('sub-precision negative number term serializes as 0 in grouped sums', () => { + const ast = { + type: /** @type {const} */ ('Sum'), + grouped: true, + terms: [ + { sign: 1, node: num(-1e-20) }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ], + }; + assert.equal(serialize(ast), 'calc(0 + var(--x))'); + }); + + test('sub-precision negative number term with precision: false retains grouped negative sum inversion', () => { + const ast = { + type: /** @type {const} */ ('Sum'), + grouped: true, + terms: [ + { sign: 1, node: num(-1e-20) }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ], + }; + assert.equal( + serialize(ast, { precision: false }), + 'calc(-1 * (1e-20 - var(--x)))' + ); + }); + + test('negated grouped sum with non-leading sub-precision negative term serializes with positive sign', () => { + const ast = { + type: /** @type {const} */ ('Sum'), + grouped: true, + terms: [ + { sign: 1, node: dim(-10, 'px') }, + { sign: 1, node: dim(-1e-20, 'em') }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ], + }; + assert.equal(serialize(ast), 'calc(-1 * (10px + 0em - var(--x)))'); + }); + + test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { + const ast = { + type: /** @type {const} */ ('Sum'), + grouped: true, + terms: [ + { sign: 1, node: dim(-10, 'px') }, + { sign: 1, node: dim(1e-20, 'em') }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ], + }; + assert.equal(serialize(ast), 'calc(-1 * (10px + 0em - var(--x)))'); + }); + }); +}); diff --git a/test/unit/serialize-result.test.js b/test/unit/serialize-result.test.js new file mode 100644 index 0000000..11ca224 --- /dev/null +++ b/test/unit/serialize-result.test.js @@ -0,0 +1,223 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + serialize as serializeSource, + serializeResult as serializeResultSource, +} from '../../src/lib/serialize.js'; +import { + num, + dim, + opaqueCall, + call, + ident, + mkSum, + mkProduct, +} from '../../src/lib/node.js'; + +const serialize = (node, opts = {}) => serializeSource(node, opts); +const serializeResult = (result, opts = {}) => + serializeResultSource(result, opts); + +/** @param {number} depth */ +function nestedOpaque(depth) { + let tree = ident('--x'); + for (let i = 0; i < depth; i++) tree = opaqueCall('var', [tree]); + return tree; +} + +/** @param {import('../../src/lib/node.js').Node} tree */ +function serializeResultInput(tree) { + return { + tree, + status: /** @type {'resolved'} */ ('resolved'), + rootName: 'calc', + rootSpelling: 'calc', + original: 'calc(var(--x))', + }; +} + +// --- §10.13 degenerate-numeric serialization ---------------------------- +describe('serialize: degenerate numeric', () => { + test('serialize: Num(Infinity) → calc(infinity)', () => { + assert.equal(serialize(num(Infinity)), 'calc(infinity)'); + }); + + test('serialize: Num(-Infinity) → calc(-infinity)', () => { + assert.equal(serialize(num(-Infinity)), 'calc(-infinity)'); + }); + + test('serialize: Num(NaN) → calc(NaN)', () => { + assert.equal(serialize(num(Number.NaN)), 'calc(NaN)'); + }); + + test('serialize: Dim(Infinity, px) → calc(infinity * 1px)', () => { + assert.equal(serialize(dim(Infinity, 'px')), 'calc(infinity * 1px)'); + }); + + test('serialize: Dim(-Infinity, px) → calc(-infinity * 1px)', () => { + assert.equal(serialize(dim(-Infinity, 'px')), 'calc(-infinity * 1px)'); + }); + + test('serialize: Dim(NaN, deg) → calc(NaN * 1deg)', () => { + assert.equal(serialize(dim(Number.NaN, 'deg')), 'calc(NaN * 1deg)'); + }); + + test('serialize: degenerate Dim preserves escaped raw unit', () => { + assert.equal( + serialize(dim(Infinity, 'f,oo', String.raw`f\2c oo`)), + String.raw`calc(infinity * 1f\2c oo)` + ); + }); + + test('serialize: nested degenerate Dim preserves escaped raw unit', () => { + const ast = mkProduct([ + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + { exponent: 1, node: dim(Number.NaN, 'f,oo', String.raw`f\2c oo`) }, + ]); + assert.equal( + serialize(ast), + String.raw`calc(var(--x) * calc(NaN * 1f\2c oo))` + ); + }); + + test('serialize: degenerate uses calcName option (vendor prefix)', () => { + assert.equal( + serialize(num(Infinity), { calcName: '-webkit-calc' }), + '-webkit-calc(infinity)' + ); + assert.equal( + serialize(dim(Number.NaN, 'px'), { calcName: '-moz-calc' }), + '-moz-calc(NaN * 1px)' + ); + }); + + test('serialize: precision does not round Infinity / NaN', () => { + assert.equal(serialize(num(Infinity), { precision: 2 }), 'calc(infinity)'); + assert.equal( + serialize(dim(Number.NaN, 'px'), { precision: 0 }), + 'calc(NaN * 1px)' + ); + }); + + test('serialize: degenerate Num inside Sum context emits keyword', () => { + // var(--x) + Infinity → keyword spelling, no nested calc(). + const ast = mkSum([ + { sign: 1, node: { type: 'Ident', name: 'var(--x)' } }, + { sign: 1, node: num(Infinity) }, + ]); + assert.equal(serialize(ast), 'calc(var(--x) + infinity)'); + }); + + test('serialize: NaN keeps canonical casing (never nan/NAN)', () => { + // §10.7.2 line 1182. + assert.equal(serialize(num(Number.NaN)).includes('NaN'), true); + assert.equal(serialize(num(Number.NaN)).includes('nan'), false); + }); +}); + +describe('serializeResult: root planning', () => { + test('preserves the original unresolved non-root call', () => { + assert.equal( + serializeResult({ + tree: opaqueCall('sin', [ident('--x')]), + status: 'unresolved', + rootName: 'custom', + rootSpelling: 'CUSTOM', + original: 'CUSTOM(var(--x))', + }), + 'CUSTOM(var(--x))' + ); + }); + + test('overrides an unresolved root call name without slicing a child string', () => { + assert.equal( + serializeResult({ + tree: opaqueCall('sin', [opaqueCall('var', [ident('--x')])]), + status: 'unresolved', + rootName: 'sin', + rootSpelling: 'SIN', + original: 'SIN(var(--x))', + }), + 'SIN(var(--x))' + ); + }); + + test('preserves a vendor wrapper at the resolved calculation boundary', () => { + const tree = mkSum([ + { sign: 1, node: dim(1, 'px') }, + { sign: 1, node: dim(2, 'px') }, + ]); + assert.equal( + serializeResult({ + tree, + status: 'resolved', + rootName: '-webkit-calc', + rootSpelling: '-webkit-calc', + original: '-webkit-calc(1px + 2px)', + }), + '-webkit-calc(1px + 2px)' + ); + }); + + test('threads scalar policy through nested opaque fallbacks', () => { + const tree = opaqueCall('var', [ + ident('--x'), + ', ', + mkSum([ + { sign: 1, node: dim(1, 'px') }, + { sign: 1, node: dim(2, 'px') }, + ]), + ]); + const result = { + tree, + status: /** @type {'resolved'} */ ('resolved'), + rootName: 'calc', + rootSpelling: 'calc', + original: 'calc(var(--x, 1px + 2px))', + }; + assert.equal(serializeResult(result), 'calc(var(--x, calc(1px + 2px)))'); + assert.equal( + serializeResult(result, { unwrapSingleValue: true }), + 'var(--x, calc(1px + 2px))' + ); + }); + + test('keeps valid nested opaque depth and rejects one level beyond the limit', () => { + assert.doesNotThrow(() => + serializeResult(serializeResultInput(nestedOpaque(512))) + ); + assert.throws( + () => serializeResult(serializeResultInput(nestedOpaque(513))), + /Calculation nesting exceeds the limit of 1024/ + ); + }); + + test('keeps a root call spelling override out of nested calls', () => { + assert.equal( + serializeResult({ + tree: call('sin', [call('cos', [ident('--x')])]), + status: 'unresolved', + rootName: 'sin', + rootSpelling: 'SIN', + original: 'SIN(cos(--x))', + }), + 'SIN(cos(--x))' + ); + }); + + test('writes nested opaque fallbacks directly into the parent buffer', () => { + const ast = opaqueCall('var', [ + ident('--outer'), + ', ', + opaqueCall('var', [ + ident('--inner'), + ', ', + mkSum([ + { sign: 1, node: dim(1, 'px') }, + { sign: 1, node: dim(2, 'px') }, + ]), + ]), + ]); + assert.equal(serialize(ast), 'var(--outer, var(--inner, calc(1px + 2px)))'); + }); +}); diff --git a/test/unit/serialize.test.js b/test/unit/serialize.test.js deleted file mode 100644 index f693d51..0000000 --- a/test/unit/serialize.test.js +++ /dev/null @@ -1,907 +0,0 @@ -import { describe, test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - serialize as serializeSource, - serializeResult as serializeResultSource, -} from '../../src/lib/serialize.js'; -import { - num, - dim, - call, - opaqueCall, - ident, - mkSum, - mkProduct, -} from '../../src/lib/node.js'; -import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; -import { parse } from '../../src/lib/parser.js'; -import { simplify } from '../../src/lib/simplify.js'; - -const serialize = (node, opts = {}) => serializeSource(node, opts); -const serializeResult = (result, opts = {}) => - serializeResultSource(result, opts); - -/** @param {number} depth */ -function nestedOpaque(depth) { - let tree = ident('--x'); - for (let i = 0; i < depth; i++) tree = opaqueCall('var', [tree]); - return tree; -} - -/** @param {import('../../src/lib/node.js').Node} tree */ -function serializeResultInput(tree) { - return { - tree, - status: /** @type {'resolved'} */ ('resolved'), - rootName: 'calc', - rootSpelling: 'calc', - original: 'calc(var(--x))', - }; -} - -// Direct serialize() tests — build canonical AST nodes by hand to pin -// output shape without depending on the parser/simplify. -// Signed-leaf canonical form: negatives live directly in the Num/Dim value. - -describe('serialize: numbers', () => { - test('serialize: single number uses standard calculation syntax', () => { - assert.equal(serialize(num(42)), 'calc(42)'); - }); - - test('serialize: single dimension uses standard calculation syntax', () => { - assert.equal(serialize(dim(10, 'px')), 'calc(10px)'); - }); - - test('serialize: sum wrapped in calc(), spaces around +/-', () => { - const ast = mkSum([ - { sign: 1, node: dim(1, 'px') }, - { sign: 1, node: dim(2, 'px') }, - ]); - assert.equal(serialize(ast), 'calc(1px + 2px)'); - }); - - test('serialize: spaces around every binary operator', () => { - const ast = mkProduct([ - { exponent: 1, node: num(2) }, - { exponent: 1, node: dim(3, 'px') }, - ]); - assert.equal(serialize(ast), 'calc(2 * 3px)'); - }); - - test('serialize: self-wrapping call — no extra calc()', () => { - const ast = { - type: 'Call', - name: 'min', - args: [dim(1, 'px'), dim(2, 'px')], - }; - assert.equal(serialize(ast), 'min(1px, 2px)'); - }); - - test('serialize: var() call', () => { - const ast = opaqueCall('var', [ident('--x')]); - assert.equal(serialize(ast), 'var(--x)'); - }); - - test('serialize: Sum inside Product gets parens', () => { - // (1 + 2) * 3 — the Sum as a factor must be parenthesized. - const innerSum = mkSum([ - { sign: 1, node: num(1) }, - { sign: 1, node: num(2) }, - ]); - const ast = mkProduct([ - { exponent: 1, node: innerSum }, - { exponent: 1, node: num(3) }, - ]); - assert.equal(serialize(ast), 'calc((1 + 2) * 3)'); - }); - - test('serialize: negative Dim via signed leaf → calc(-Xpx)', () => { - // Negatives live directly in the Dim value. The constructor helper - // `dim(-1, 'px')` returns a Dim with value -1, no Sum wrapper. - assert.equal(serialize(dim(-1, 'px')), 'calc(-1px)'); - }); - - test('serialize: single-term Sum with opaque gets calc() function', () => { - // `-1 * var(--x)` needs calc() so the leading minus isn't ambiguous. - const ast = mkSum([ - { - sign: -1, - node: opaqueCall('var', [ident('--x')]), - }, - ]); - assert.equal(serialize(ast), 'calc(-1 * var(--x))'); - }); - - test('serialize: precision option applied to numbers and dimensions', () => { - assert.equal( - serialize(dim(1.123456789, 'px'), { precision: 2 }), - 'calc(1.12px)' - ); - assert.equal(serialize(num(1.123456789), { precision: 0 }), 'calc(1)'); - assert.equal(serialize(num(1.4), { precision: 0 }), 'calc(1)'); - assert.equal(serialize(num(1.4), { precision: 1 }), 'calc(1.4)'); - }); - - test('serialize: precision false keeps full value', () => { - assert.equal( - serialize(dim(1.123456789, 'px'), { precision: false }), - 'calc(1.123456789px)' - ); - assert.equal( - serialize(num(1 / 3), { precision: false }), - 'calc(.3333333333333333)' - ); - assert.equal( - serialize(dim(1 / 3, 'px'), { precision: false }), - 'calc(.3333333333333333px)' - ); - }); - - test('serialize: rounds decimal midpoints away from zero accurately', () => { - assert.equal(serialize(num(1.005), { precision: 2 }), 'calc(1.01)'); - assert.equal(serialize(num(-1.005), { precision: 2 }), 'calc(-1.01)'); - assert.equal(serialize(dim(1.005, 'px'), { precision: 2 }), 'calc(1.01px)'); - assert.equal( - serialize(dim(-1.005, 'px'), { precision: 2 }), - 'calc(-1.01px)' - ); - assert.equal(serialize(num(1.000005), { precision: 5 }), 'calc(1.00001)'); - assert.equal(serialize(num(-1.000005), { precision: 5 }), 'calc(-1.00001)'); - }); - - test('serialize: rounds large fractional magnitudes without float drift', () => { - // Scaling through Number(text + 'e' + p) loses the rounding boundary once - // the shifted value exceeds Number.MAX_SAFE_INTEGER. - assert.equal( - serialize(num(312834450754803.44), { precision: 1 }), - 'calc(312834450754803.4)' - ); - assert.equal( - serialize(num(312834450754803.44), { precision: 6 }), - 'calc(312834450754803.44)' - ); - assert.equal( - serialize(dim(-312834450754803.44, 'px'), { precision: 1 }), - 'calc(-312834450754803.4px)' - ); - assert.equal( - serialize(num(39969.492943459234), { precision: 11 }), - 'calc(39969.49294345923)' - ); - }); - - test('serialize: carries a rounding carry through trailing nines', () => { - // Rounding up 999.995 must propagate the carry across all nines to 1000. - assert.equal(serialize(num(999.995), { precision: 2 }), 'calc(1000)'); - assert.equal(serialize(num(-999.995), { precision: 2 }), 'calc(-1000)'); - // All-nines carry combined with digit-string rounding beyond the safe - // shift range. - assert.equal( - serialize(num(999999999999.995), { precision: 2 }), - 'calc(1000000000000)' - ); - }); - - test('serialize: rounds sub-1 midpoints away from zero and preserves sub-precision values', () => { - assert.equal(serialize(num(0.05), { precision: 1 }), 'calc(.1)'); - assert.equal(serialize(num(-0.05), { precision: 1 }), 'calc(-.1)'); - assert.equal(serialize(num(0.005), { precision: 2 }), 'calc(.01)'); - // 0.004 rounds to zero at 1 place but exceeds the noise floor, so the - // value is preserved rather than collapsed to 0. - assert.equal(serialize(num(0.004), { precision: 1 }), 'calc(.004)'); - assert.equal(serialize(num(-0.004), { precision: 1 }), 'calc(-.004)'); - }); - - test('serialize: leaves values unchanged when precision exceeds the shortest representation', () => { - // The shortest decimal of 7341.0297734398655 has 14 fractional digits, - // so rounding at precision 14 must return the value untouched instead of - // rescaling through digit strings. - assert.equal( - serialize(num(7341.0297734398655), { precision: 14 }), - 'calc(7341.0297734398655)' - ); - }); - - test('serialize: precision 0 rounds to integers away from zero', () => { - assert.equal(serialize(num(1.5), { precision: 0 }), 'calc(2)'); - assert.equal(serialize(num(-1.5), { precision: 0 }), 'calc(-2)'); - assert.equal(serialize(dim(1.2, 'px'), { precision: 0 }), 'calc(1px)'); - assert.equal(serialize(dim(-1.2, 'px'), { precision: 0 }), 'calc(-1px)'); - }); - - test('serialize: negative and fractional precisions are clamped and truncated', () => { - assert.equal(serialize(num(1.5), { precision: -1 }), 'calc(2)'); - assert.equal(serialize(num(-1.5), { precision: -2 }), 'calc(-2)'); - assert.equal(serialize(num(1.005), { precision: 2.5 }), 'calc(1.01)'); - assert.equal( - serialize(dim(1.005, 'px'), { precision: 2.9 }), - 'calc(1.01px)' - ); - }); - - test('serialize: boundary precisions avoid NaN overflow', () => { - assert.equal(serialize(num(1), { precision: 20 }), 'calc(1)'); - assert.equal(serialize(dim(1, 'px'), { precision: 100 }), 'calc(1px)'); - assert.equal(serialize(num(1), { precision: 310 }), 'calc(1)'); - assert.equal(serialize(dim(1, 'px'), { precision: 310 }), 'calc(1px)'); - assert.equal(serialize(num(1.5), { precision: 25 }), 'calc(1.5)'); - assert.equal(serialize(dim(1.5, 'px'), { precision: 25 }), 'calc(1.5px)'); - }); - - test('serialize: handles input magnitudes in scientific notation, MAX_SAFE_INTEGER, and noise floor', () => { - assert.equal(serialize(num(1e-7), { precision: 5 }), 'calc(1e-7)'); - assert.equal(serialize(num(1e-15), { precision: 5 }), 'calc(0)'); - assert.equal(serialize(num(1e21), { precision: 5 }), 'calc(1e+21)'); - assert.equal( - serialize(num(Number.MAX_SAFE_INTEGER), { precision: 2 }), - 'calc(9007199254740991)' - ); - assert.equal( - serialize(dim(Number.MAX_SAFE_INTEGER, 'px'), { precision: 2 }), - 'calc(9007199254740991px)' - ); - }); - - test('serialize: preserves signed zero vs zero under custom precision', () => { - const negNested = call('min', [num(-0), num(1)]); - const posNested = call('min', [num(0), num(1)]); - assert.equal( - serialize(negNested, { precision: 2 }), - 'min(calc(-1 * 0), 1)' - ); - assert.equal(serialize(posNested, { precision: 2 }), 'min(0, 1)'); - - const negDim = call('min', [dim(-0, 'px'), dim(1, 'px')]); - const posDim = call('min', [dim(0, 'px'), dim(1, 'px')]); - assert.equal( - serialize(negDim, { precision: 2 }), - 'min(calc(-1 * 0px), 1px)' - ); - assert.equal(serialize(posDim, { precision: 2 }), 'min(0px, 1px)'); - }); - - test('serialize: omits the leading zero from fractional numbers', () => { - assert.equal(serialize(num(0.5)), 'calc(.5)'); - assert.equal(serialize(num(-0.000001)), 'calc(-.000001)'); - assert.equal(serialize(dim(0.25, 'px')), 'calc(.25px)'); - assert.equal(serialize(num(0)), 'calc(0)'); - assert.equal(serialize(num(1e-7)), 'calc(1e-7)'); - }); - - test('serialize: lowers a signed zero number inside a calculation', () => { - const nested = call('min', [num(-0), num(1)]); - assert.equal( - serialize(nested, { precision: false }), - 'min(calc(-1 * 0), 1)' - ); - }); - - test('serialize: lowers signed zero leaves inside structural expressions', () => { - const ast = mkProduct([ - { exponent: 1, node: num(-0) }, - { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, - ]); - assert.equal( - serialize(ast, { precision: false }), - 'calc(calc(-1 * 0) * var(--x))' - ); - - const dimensional = call('min', [dim(-0, 'px'), dim(1, 'px')]); - assert.equal( - serialize(dimensional, { precision: false }), - 'min(calc(-1 * 0px), 1px)' - ); - }); - - describe('serialize: sub-precision negative terms in sums and grouped sums', () => { - test('sub-precision negative number term serializes as 0 in sums', () => { - const ast = mkSum([ - { sign: 1, node: num(-1e-20) }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ]); - assert.equal(serialize(ast), 'calc(0 + var(--x))'); - }); - - test('sub-precision negative number term with precision: false retains negative value in sums', () => { - const ast = mkSum([ - { sign: 1, node: num(-1e-20) }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ]); - assert.equal( - serialize(ast, { precision: false }), - 'calc(-1e-20 + var(--x))' - ); - }); - - test('sub-precision negative number term serializes as 0 in grouped sums', () => { - const ast = { - type: /** @type {const} */ ('Sum'), - grouped: true, - terms: [ - { sign: 1, node: num(-1e-20) }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ], - }; - assert.equal(serialize(ast), 'calc(0 + var(--x))'); - }); - - test('sub-precision negative number term with precision: false retains grouped negative sum inversion', () => { - const ast = { - type: /** @type {const} */ ('Sum'), - grouped: true, - terms: [ - { sign: 1, node: num(-1e-20) }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ], - }; - assert.equal( - serialize(ast, { precision: false }), - 'calc(-1 * (1e-20 - var(--x)))' - ); - }); - - test('negated grouped sum with non-leading sub-precision negative term serializes with positive sign', () => { - const ast = { - type: /** @type {const} */ ('Sum'), - grouped: true, - terms: [ - { sign: 1, node: dim(-10, 'px') }, - { sign: 1, node: dim(-1e-20, 'em') }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ], - }; - assert.equal(serialize(ast), 'calc(-1 * (10px + 0em - var(--x)))'); - }); - - test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { - const ast = { - type: /** @type {const} */ ('Sum'), - grouped: true, - terms: [ - { sign: 1, node: dim(-10, 'px') }, - { sign: 1, node: dim(1e-20, 'em') }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ], - }; - assert.equal(serialize(ast), 'calc(-1 * (10px + 0em - var(--x)))'); - }); - }); - - test('serialize: custom calcName', () => { - const ast = mkSum([ - { sign: 1, node: dim(1, 'px') }, - { sign: 1, node: dim(2, 'px') }, - ]); - assert.equal( - serialize(ast, { calcName: '-webkit-calc' }), - '-webkit-calc(1px + 2px)' - ); - }); -}); - -describe('serialize: scalar context policy', () => { - const policies = [ - { name: 'standard', options: {} }, - { name: 'unwrapped', options: { unwrapSingleValue: true } }, - ]; - const cases = [ - { - name: 'negative integral Num', - node: num(-2), - expected: ['calc(-2)', '-2'], - }, - { - name: 'negative fractional Num', - node: num(-0.5), - expected: ['calc(-.5)', '-.5'], - }, - { - name: 'positive integral Num', - node: num(2), - expected: ['calc(2)', '2'], - }, - { - name: 'positive fractional Num', - node: num(0.5), - expected: ['calc(.5)', '.5'], - }, - { - name: 'positive zero Num', - node: num(0), - expected: ['calc(0)', '0'], - }, - { - name: 'negative zero Num', - node: num(-0), - expected: ['calc(0)', '0'], - }, - { - name: 'negative integral Dim', - node: dim(-2, 'px'), - expected: ['calc(-2px)', '-2px'], - }, - { - name: 'negative fractional Dim', - node: dim(-0.5, 'px'), - expected: ['calc(-.5px)', '-.5px'], - }, - { - name: 'positive integral Dim', - node: dim(2, 'px'), - expected: ['calc(2px)', '2px'], - }, - { - name: 'positive fractional Dim', - node: dim(0.5, 'px'), - expected: ['calc(.5px)', '.5px'], - }, - { - name: 'negative zero Dim', - node: dim(-0, 'px'), - expected: ['calc(0px)', '0px'], - }, - { - name: 'Infinity Num', - node: num(Infinity), - expected: ['calc(infinity)', 'calc(infinity)'], - }, - { - name: 'NaN Dim', - node: dim(Number.NaN, 'px'), - expected: ['calc(NaN * 1px)', 'calc(NaN * 1px)'], - }, - ]; - - for (const scalarCase of cases) { - for (const [policyIndex, policy] of policies.entries()) { - test(`${scalarCase.name} uses ${policy.name}`, () => { - const output = serialize(scalarCase.node, policy.options); - assert.equal(output, scalarCase.expected[policyIndex]); - - const tokens = tokenize({ css: output }); - const reparsed = simplify( - parse(tokens, 0, tokens.length, indexBlocks(tokens)) - ); - assert.equal( - serialize(reparsed, policy.options), - output, - 'formatted output must round-trip under the same policy' - ); - }); - } - } - - const precisionCases = [ - { - name: 'precision false keeps a fractional Num', - node: num(0.5), - options: { precision: false }, - expected: ['calc(.5)', '.5'], - }, - { - name: 'precision zero classifies a rounded Num as integral', - node: num(1.4), - options: { precision: 0 }, - expected: ['calc(1)', '1'], - }, - { - name: 'decimal precision classifies the formatted fraction', - node: num(1.4), - options: { precision: 1 }, - expected: ['calc(1.4)', '1.4'], - }, - { - name: 'precision zero censors a tiny negative value to zero', - node: num(-1e-13), - options: { precision: 0 }, - expected: ['calc(0)', '0'], - }, - { - name: 'decimal precision applies to a dimensional fraction', - node: dim(1.234, 'px'), - options: { precision: 2 }, - expected: ['calc(1.23px)', '1.23px'], - }, - ]; - - for (const precisionCase of precisionCases) { - for (const [policyIndex, policy] of policies.entries()) { - test(`${precisionCase.name} uses ${policy.name}`, () => { - assert.equal( - serialize(precisionCase.node, { - ...precisionCase.options, - ...policy.options, - }), - precisionCase.expected[policyIndex] - ); - }); - } - } -}); - -// --- Mutation-targeted tests --------------------------------------------- -describe('serialize: mutation-targeted tests', () => { - test('does not carry a negative scalar magnitude into positive siblings', () => { - const ast = mkSum([ - { sign: 1, node: num(-2) }, - { sign: 1, node: num(3) }, - { sign: 1, node: dim(4, 'px') }, - ]); - assert.equal(serialize(ast), 'calc(-2 + 3 + 4px)'); - }); - - test('scopes negated product coefficients to one product', () => { - const withCoefficient = mkSum([ - { - sign: -1, - node: mkProduct([ - { exponent: 1, node: num(2) }, - { exponent: 1, node: ident('x') }, - ]), - }, - ]); - const withoutCoefficient = mkSum([ - { - sign: -1, - node: mkProduct([ - { exponent: 1, node: ident('a') }, - { exponent: 1, node: ident('b') }, - ]), - }, - ]); - assert.equal(serialize(withCoefficient), 'calc(-2 * x)'); - assert.equal(serialize(withoutCoefficient), 'calc(-1 * a * b)'); - assert.equal( - serialize(call('min', [withCoefficient, withoutCoefficient])), - 'min(-2 * x, -1 * a * b)' - ); - }); - - test('isolates nested sums and products across call arguments', () => { - const ast = call('min', [ - mkSum([ - { sign: 1, node: num(1) }, - { sign: 1, node: num(2) }, - ]), - mkProduct([ - { exponent: 1, node: num(2) }, - { exponent: 1, node: dim(3, 'px') }, - ]), - ]); - assert.equal(serialize(ast), 'min(1 + 2, 2 * 3px)'); - }); - - test('keeps a root call spelling override out of nested calls', () => { - assert.equal( - serializeResult({ - tree: call('sin', [call('cos', [ident('--x')])]), - status: 'unresolved', - rootName: 'sin', - rootSpelling: 'SIN', - original: 'SIN(cos(--x))', - }), - 'SIN(cos(--x))' - ); - }); - - test('writes nested opaque fallbacks directly into the parent buffer', () => { - const ast = opaqueCall('var', [ - ident('--outer'), - ', ', - opaqueCall('var', [ - ident('--inner'), - ', ', - mkSum([ - { sign: 1, node: dim(1, 'px') }, - { sign: 1, node: dim(2, 'px') }, - ]), - ]), - ]); - assert.equal(serialize(ast), 'var(--outer, var(--inner, calc(1px + 2px)))'); - }); - - test('serialize: displaySign flips negative Num to `-` operator', () => { - // `5 + Num(-3)` should render as `5 - 3`, not `5 + -3`. - // This kills the displaySign branch for Num with value<0. - const ast = mkSum([ - { sign: 1, node: { type: 'Num', value: 5 } }, - { sign: 1, node: { type: 'Num', value: -3 } }, - ]); - assert.equal(serialize(ast), 'calc(5 - 3)'); - }); - - test('serialize: displaySign flips negative Dim to `-` operator', () => { - // Same but for Dim leaves — `5px + Dim(-2, em)` → `5px - 2em`. - const ast = mkSum([ - { sign: 1, node: { type: 'Dim', value: 5, unit: 'px' } }, - { sign: 1, node: { type: 'Dim', value: -2, unit: 'em' } }, - ]); - assert.equal(serialize(ast), 'calc(5px - 2em)'); - }); - - test('serialize: negative leading Num keeps calc() function', () => { - assert.equal(serialize(num(-5)), 'calc(-5)'); - }); - - test('serialize: single-term Sum with sign=-1 and opaque call → calc(-1 * call)', () => { - // `-1 * var(--x)` shape — only reachable as a directly-constructed Sum - // (parser never produces it; mkSum would collapse if leaf). - const ast = { - type: 'Sum', - terms: [ - { - sign: -1, - node: opaqueCall('var', [ident('--x')]), - }, - ], - }; - assert.equal(serialize(ast), 'calc(-1 * var(--x))'); - }); - - test('serialize: single-term Sum with sign=-1 and Product serializes as -1 * factors', () => { - // `-1 * a * b` serializes without extra parentheses because multiplication is associative. - const ast = { - type: 'Sum', - terms: [ - { - sign: -1, - node: { - type: 'Product', - factors: [ - { exponent: 1, node: { type: 'Ident', name: 'a' } }, - { exponent: 1, node: { type: 'Ident', name: 'b' } }, - ], - }, - }, - ], - }; - assert.equal(serialize(ast), 'calc(-1 * a * b)'); - }); - - test('serialize: multi-term Sum with trailing zero-valued Dim', () => { - // `1px + 0em` — both non-zero positions tested; exercises the - // iteration body and operator choice for non-first terms. - const ast = mkSum([ - { sign: 1, node: dim(1, 'px') }, - { sign: 1, node: dim(0, 'em') }, - ]); - assert.equal(serialize(ast), 'calc(1px + 0em)'); - }); - - test('serialize: Product with leading denominator emits implicit 1', () => { - // `Product([{-1, 2px}])` (impossible from parser but constructible) - // should emit `1 / 2px`, exercising the exponent=-1 first-factor branch. - const ast = { - type: 'Product', - factors: [{ exponent: -1, node: dim(2, 'px') }], - }; - assert.equal(serialize(ast), 'calc(1 / 2px)'); - }); - - test('serialize: negated Product with leading denominator emits -1 / value', () => { - // Negation of `1 / 2px` cannot fold into a signed Dim leaf, so it must - // serialize as `-1 / 2px` rather than distributing over the denominator. - const ast = mkSum([ - { - sign: -1, - node: mkProduct([{ exponent: -1, node: dim(2, 'px') }]), - }, - ]); - assert.equal(serialize(ast), 'calc(-1 / 2px)'); - }); - - test('serialize: negating a Product with leading zero coefficient emits calc(-1 * 0) times the rest', () => { - // The negated coefficient is -0, which must take the signed-zero path - // (calc(-1 * 0)) instead of collapsing to plain `0` or `1`. - const ast = mkSum([ - { - sign: -1, - node: mkProduct([ - { exponent: 1, node: num(0) }, - { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, - ]), - }, - ]); - assert.equal(serialize(ast), 'calc(calc(-1 * 0) * var(--x))'); - }); - - test('serialize: negating a Product with leading -0 coefficient emits positive zero times the rest', () => { - // Negating -0 yields +0, so the signed-zero path must not trigger. - const ast = mkSum([ - { - sign: -1, - node: mkProduct([ - { exponent: 1, node: num(-0) }, - { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, - ]), - }, - ]); - assert.equal(serialize(ast), 'calc(0 * var(--x))'); - }); - - test('serialize: negating a Product with leading Infinity coefficient emits -infinity times the rest', () => { - // The negated coefficient is -Infinity and must take the degenerate - // keyword path rather than the finite rounding path. - const ast = mkSum([ - { - sign: -1, - node: mkProduct([ - { exponent: 1, node: num(Infinity) }, - { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, - ]), - }, - ]); - assert.equal(serialize(ast), 'calc(-infinity * var(--x))'); - }); - - test('serialize: negating a Product with leading -Infinity coefficient emits infinity times the rest', () => { - const ast = mkSum([ - { - sign: -1, - node: mkProduct([ - { exponent: 1, node: num(-Infinity) }, - { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, - ]), - }, - ]); - assert.equal(serialize(ast), 'calc(infinity * var(--x))'); - }); -}); - -// --- §10.13 degenerate-numeric serialization ---------------------------- -describe('serialize: degenerate numeric', () => { - test('serialize: Num(Infinity) → calc(infinity)', () => { - assert.equal(serialize(num(Infinity)), 'calc(infinity)'); - }); - - test('serialize: Num(-Infinity) → calc(-infinity)', () => { - assert.equal(serialize(num(-Infinity)), 'calc(-infinity)'); - }); - - test('serialize: Num(NaN) → calc(NaN)', () => { - assert.equal(serialize(num(Number.NaN)), 'calc(NaN)'); - }); - - test('serialize: Dim(Infinity, px) → calc(infinity * 1px)', () => { - assert.equal(serialize(dim(Infinity, 'px')), 'calc(infinity * 1px)'); - }); - - test('serialize: Dim(-Infinity, px) → calc(-infinity * 1px)', () => { - assert.equal(serialize(dim(-Infinity, 'px')), 'calc(-infinity * 1px)'); - }); - - test('serialize: Dim(NaN, deg) → calc(NaN * 1deg)', () => { - assert.equal(serialize(dim(Number.NaN, 'deg')), 'calc(NaN * 1deg)'); - }); - - test('serialize: degenerate Dim preserves escaped raw unit', () => { - assert.equal( - serialize(dim(Infinity, 'f,oo', String.raw`f\2c oo`)), - String.raw`calc(infinity * 1f\2c oo)` - ); - }); - - test('serialize: nested degenerate Dim preserves escaped raw unit', () => { - const ast = mkProduct([ - { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, - { exponent: 1, node: dim(Number.NaN, 'f,oo', String.raw`f\2c oo`) }, - ]); - assert.equal( - serialize(ast), - String.raw`calc(var(--x) * calc(NaN * 1f\2c oo))` - ); - }); - - test('serialize: degenerate uses calcName option (vendor prefix)', () => { - assert.equal( - serialize(num(Infinity), { calcName: '-webkit-calc' }), - '-webkit-calc(infinity)' - ); - assert.equal( - serialize(dim(Number.NaN, 'px'), { calcName: '-moz-calc' }), - '-moz-calc(NaN * 1px)' - ); - }); - - test('serialize: precision does not round Infinity / NaN', () => { - assert.equal(serialize(num(Infinity), { precision: 2 }), 'calc(infinity)'); - assert.equal( - serialize(dim(Number.NaN, 'px'), { precision: 0 }), - 'calc(NaN * 1px)' - ); - }); - - test('serialize: degenerate Num inside Sum context emits keyword', () => { - // var(--x) + Infinity → keyword spelling, no nested calc(). - const ast = mkSum([ - { sign: 1, node: { type: 'Ident', name: 'var(--x)' } }, - { sign: 1, node: num(Infinity) }, - ]); - assert.equal(serialize(ast), 'calc(var(--x) + infinity)'); - }); - - test('serialize: NaN keeps canonical casing (never nan/NAN)', () => { - // §10.7.2 line 1182. - assert.equal(serialize(num(Number.NaN)).includes('NaN'), true); - assert.equal(serialize(num(Number.NaN)).includes('nan'), false); - }); -}); - -describe('serializeResult: root planning', () => { - test('preserves the original unresolved non-root call', () => { - assert.equal( - serializeResult({ - tree: opaqueCall('sin', [ident('--x')]), - status: 'unresolved', - rootName: 'custom', - rootSpelling: 'CUSTOM', - original: 'CUSTOM(var(--x))', - }), - 'CUSTOM(var(--x))' - ); - }); - - test('overrides an unresolved root call name without slicing a child string', () => { - assert.equal( - serializeResult({ - tree: opaqueCall('sin', [opaqueCall('var', [ident('--x')])]), - status: 'unresolved', - rootName: 'sin', - rootSpelling: 'SIN', - original: 'SIN(var(--x))', - }), - 'SIN(var(--x))' - ); - }); - - test('preserves a vendor wrapper at the resolved calculation boundary', () => { - const tree = mkSum([ - { sign: 1, node: dim(1, 'px') }, - { sign: 1, node: dim(2, 'px') }, - ]); - assert.equal( - serializeResult({ - tree, - status: 'resolved', - rootName: '-webkit-calc', - rootSpelling: '-webkit-calc', - original: '-webkit-calc(1px + 2px)', - }), - '-webkit-calc(1px + 2px)' - ); - }); - - test('threads scalar policy through nested opaque fallbacks', () => { - const tree = opaqueCall('var', [ - ident('--x'), - ', ', - mkSum([ - { sign: 1, node: dim(1, 'px') }, - { sign: 1, node: dim(2, 'px') }, - ]), - ]); - const result = { - tree, - status: /** @type {'resolved'} */ ('resolved'), - rootName: 'calc', - rootSpelling: 'calc', - original: 'calc(var(--x, 1px + 2px))', - }; - assert.equal(serializeResult(result), 'calc(var(--x, calc(1px + 2px)))'); - assert.equal( - serializeResult(result, { unwrapSingleValue: true }), - 'var(--x, calc(1px + 2px))' - ); - }); - - test('keeps valid nested opaque depth and rejects one level beyond the limit', () => { - assert.doesNotThrow(() => - serializeResult(serializeResultInput(nestedOpaque(512))) - ); - assert.throws( - () => serializeResult(serializeResultInput(nestedOpaque(513))), - /Calculation nesting exceeds the limit of 1024/ - ); - }); -}); diff --git a/types/lib/parser.d.ts b/types/lib/parser.d.ts index 7e8657a..d88fcca 100644 --- a/types/lib/parser.d.ts +++ b/types/lib/parser.d.ts @@ -1,49 +1,9 @@ export type CSSToken = import('@csstools/css-tokenizer').CSSToken; export type Node = import('./node.js').Node; -export type OpaqueComponent = import('./node.js').OpaqueComponent; -export type BlockIndex = ReturnType; -export type TokenBase = { - raw: string; - pos: number; - ws: boolean; - index: number; -}; -export type NumberToken = TokenBase & { - type: 'number'; - value: number; - signCharacter?: '+' | '-'; -}; -export type DimensionToken = TokenBase & { - type: 'dimension'; - value: number; - unit: string; - rawUnit: string; - signCharacter?: '+' | '-'; -}; -export type IdentToken = TokenBase & { - type: 'ident'; - value: string; -}; -export type FunctionToken = TokenBase & { - type: 'function'; - value: string; -}; -export type Punctuator = '(' | ')' | ',' | '+' | '-' | '*' | '/'; -export type PunctToken = TokenBase & { - type: 'punct'; - value: Punctuator; -}; -export type EofToken = TokenBase & { - type: 'eof'; - value: ''; - raw: ''; -}; -export type Token = NumberToken | DimensionToken | IdentToken | FunctionToken | PunctToken | EofToken; -export type ParseInput = Readonly<{ - tokens: CSSToken[]; - end: number; - index: BlockIndex; -}>; +export type BlockIndex = import('./parser/tokens.js').BlockIndex; +export type ParseInput = import('./parser/tokens.js').ParseInput; +export type Token = import('./parser/tokens.js').Token; +export type FunctionToken = import('./parser/tokens.js').FunctionToken; /** @param {CSSToken[]} tokens @param {number} start @param {number} end @param {BlockIndex} index @return {Node} */ declare function parse(tokens: CSSToken[], start: number, end: number, index: BlockIndex): Node; export { parse }; diff --git a/types/lib/parser/opaque.d.ts b/types/lib/parser/opaque.d.ts new file mode 100644 index 0000000..1c07428 --- /dev/null +++ b/types/lib/parser/opaque.d.ts @@ -0,0 +1,12 @@ +export type CSSToken = import('@csstools/css-tokenizer').CSSToken; +export type Node = import('../node.js').Node; +export type OpaqueComponent = import('../node.js').OpaqueComponent; +export type ParseInput = import('./tokens.js').ParseInput; +export type Cursor = import('./tokens.js').Cursor; +export type FunctionToken = import('./tokens.js').FunctionToken; +export type ParseRange = (input: ParseInput, start: number, end: number) => Node; +/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */ +declare function parseOpaqueCall(input: ParseInput, cursor: Cursor, token: FunctionToken, name: string, rawName: string, parseRange: ParseRange): Node; +/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */ +declare function parseVar(input: ParseInput, cursor: Cursor, token: FunctionToken, name: string, rawName: string, parseRange: ParseRange): Node; +export { parseOpaqueCall, parseVar }; diff --git a/types/lib/parser/tokens.d.ts b/types/lib/parser/tokens.d.ts new file mode 100644 index 0000000..c9c33d6 --- /dev/null +++ b/types/lib/parser/tokens.d.ts @@ -0,0 +1,83 @@ +export type CSSToken = import('@csstools/css-tokenizer').CSSToken; +export type BlockIndex = ReturnType; +export type TokenBase = { + raw: string; + pos: number; + ws: boolean; + index: number; +}; +export type NumberToken = TokenBase & { + type: 'number'; + value: number; + signCharacter?: '+' | '-'; +}; +export type DimensionToken = TokenBase & { + type: 'dimension'; + value: number; + unit: string; + rawUnit: string; + signCharacter?: '+' | '-'; +}; +export type IdentToken = TokenBase & { + type: 'ident'; + value: string; +}; +export type FunctionToken = TokenBase & { + type: 'function'; + value: string; +}; +export type Punctuator = '(' | ')' | ',' | '+' | '-' | '*' | '/'; +export type PunctToken = TokenBase & { + type: 'punct'; + value: Punctuator; +}; +export type EofToken = TokenBase & { + type: 'eof'; + value: ''; + raw: ''; +}; +export type Token = NumberToken | DimensionToken | IdentToken | FunctionToken | PunctToken | EofToken; +export type ParseInput = Readonly<{ + tokens: CSSToken[]; + end: number; + index: BlockIndex; +}>; +/** @param {string} raw @param {string} decoded */ +declare function sourceSpelling(raw: string, decoded: string): string | undefined; +/** + * Mutable navigation state only. `index` is always the next native token + * position; trivia is intentionally left visible to `scanToken`. + */ +declare class Cursor { + /** @type {number} */ + index: number; + /** @type {boolean} */ + firstToken: boolean; + /** @type {Token | null} */ + lookahead: Token | null; + /** @type {number} */ + lookaheadNextIndex: number; + /** @param {number} start */ + constructor(start: number); + /** @param {number} index @return {void} */ + skipTo(index: number): void; +} +/** @param {ParseInput} input @param {number} index @return {number} */ +declare function eofPositionAt(input: ParseInput, index: number): number; +/** @param {ParseInput} input @param {Cursor} cursor @return {Token} */ +declare function peekToken(input: ParseInput, cursor: Cursor): Token; +/** + * Consume the cached token and advance to its native next index. This is one + * of the only two operations allowed to advance `cursor.index`. + * @param {ParseInput} input + * @param {Cursor} cursor + * @return {Token} + */ +declare function takeToken(input: ParseInput, cursor: Cursor): Token; +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @param {Punctuator} [value2] @return {boolean} */ +declare function isPunct(input: ParseInput, cursor: Cursor, value: Punctuator, value2?: Punctuator): boolean; +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {boolean} */ +declare function matchPunct(input: ParseInput, cursor: Cursor, value: Punctuator): boolean; +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {PunctToken} */ +declare function expectPunct(input: ParseInput, cursor: Cursor, value: Punctuator): PunctToken; +export { Cursor, eofPositionAt, expectPunct, isPunct, matchPunct, peekToken, sourceSpelling, takeToken, }; diff --git a/types/lib/serialize/expression.d.ts b/types/lib/serialize/expression.d.ts new file mode 100644 index 0000000..d551225 --- /dev/null +++ b/types/lib/serialize/expression.d.ts @@ -0,0 +1,95 @@ +export type Node = import('../node.js').Node; +export type Sum = import('../node.js').Sum; +export type Product = import('../node.js').Product; +export type ProductFactor = import('../node.js').ProductFactor; +export type SerializeSession = import('./precision.js').SerializeSession; +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').Sum} Sum + * @typedef {import('../node.js').Product} Product + * @typedef {import('../node.js').ProductFactor} ProductFactor + * @typedef {import('./precision.js').SerializeSession} SerializeSession + */ +declare const SUM_PRECEDENCE = 1; +declare const PRODUCT_PRECEDENCE = 2; +declare const ATOMIC_PRECEDENCE = 3; +declare const UNARY_PRECEDENCE = 3; +/** @param {Node} node @return {number} */ +declare function precedence(node: Node): number; +/** + * @param {Node} node + * @param {number} parentPrecedence + * @param {boolean} groupedRequired + * @return {boolean} + */ +declare function needsParentheses(node: Node, parentPrecedence: number, groupedRequired: boolean): boolean; +/** + * @param {Node} node + * @param {SerializeSession} session + * @param {number} [parentPrecedence] + * @param {boolean} [groupedRequired] + * @return {void} + */ +declare function emitNode(node: Node, session: SerializeSession, parentPrecedence?: number, groupedRequired?: boolean): void; +/** + * @param {import('../node.js').Call} node + * @param {SerializeSession} session + * @param {string} [callNameOverride] + * @return {void} + */ +declare function emitCall(node: import('../node.js').Call, session: SerializeSession, callNameOverride?: string): void; +/** + * @param {import('../node.js').OpaqueCall} node + * @param {SerializeSession} session + * @param {string} [callNameOverride] + * @return {void} + */ +declare function emitOpaqueCall(node: import('../node.js').OpaqueCall, session: SerializeSession, callNameOverride?: string): void; +/** + * @param {import('../node.js').SumTerm} term + * @param {1 | -1} multiplier + * @param {number | false} precision + * @return {1 | -1} + */ +declare function termSign(term: import('../node.js').SumTerm, multiplier: 1 | -1, precision: number | false): 1 | -1; +/** + * @param {import('../node.js').SumTerm[]} terms + * @param {SerializeSession} session + * @param {1 | -1} [multiplier] + * @return {void} + */ +declare function emitSumTerms(terms: import('../node.js').SumTerm[], session: SerializeSession, multiplier?: 1 | -1): void; +/** @param {Sum} sum @param {SerializeSession} session @return {void} */ +declare function emitSum(sum: Sum, session: SerializeSession): void; +/** + * @param {Node} node + * @param {SerializeSession} session + * @return {void} + */ +declare function emitLeadingNeg(node: Node, session: SerializeSession): void; +/** + * @param {ProductFactor[]} factors + * @param {SerializeSession} session + * @param {number} [start] + * @param {number} [coefficientValue] + * @param {import('../node.js').Num} [coefficientNode] + * @return {void} + */ +declare function emitProductFactors(factors: ProductFactor[], session: SerializeSession, start?: number, coefficientValue?: number, coefficientNode?: import('../node.js').Num): void; +/** @param {Product} product @param {SerializeSession} session @return {void} */ +declare function emitProduct(product: Product, session: SerializeSession): void; +/** + * @param {Node} node + * @param {SerializeSession} session + * @param {string} wrapper + * @return {void} + */ +declare function emitMathResult(node: Node, session: SerializeSession, wrapper: string): void; +/** + * @param {Node} node + * @param {SerializeSession} session + * @param {string[]} [buffer] + * @return {void} + */ +declare function emitNestedMathResult(node: Node, session: SerializeSession, buffer?: string[]): void; +export { SUM_PRECEDENCE, PRODUCT_PRECEDENCE, ATOMIC_PRECEDENCE, UNARY_PRECEDENCE, precedence, needsParentheses, emitNode, emitCall, emitOpaqueCall, termSign, emitSumTerms, emitSum, emitLeadingNeg, emitProductFactors, emitProduct, emitMathResult, emitNestedMathResult, }; diff --git a/types/lib/serialize/precision.d.ts b/types/lib/serialize/precision.d.ts new file mode 100644 index 0000000..ffe3270 --- /dev/null +++ b/types/lib/serialize/precision.d.ts @@ -0,0 +1,101 @@ +export type Node = import('../node.js').Node; +export type Num = import('../node.js').Num; +export type Dim = import('../node.js').Dim; +export type SerializeSession = { + buffer: string[]; + precision: number | false; + scalarPolicy: 'standard' | 'unwrap-all'; +}; +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').Num} Num + * @typedef {import('../node.js').Dim} Dim + * @typedef {object} SerializeSession + * @property {string[]} buffer + * @property {number | false} precision + * @property {'standard' | 'unwrap-all'} scalarPolicy + */ +declare const NOISE_FLOOR = 1e-12; +/** + * Divide a decimal digit string by 10^k, rounding half away from zero, and + * return the resulting integer digit string. `digits` has no leading zeros. + * @param {string} digits + * @param {number} k + * @return {string} + */ +declare function divideByPowerOfTen(digits: string, k: number): string; +/** + * Round the shortest decimal representation of a non-negative double to `p` + * fractional digits, half away from zero. + * + * `Number(text + 'e' + p)` reads the exact intended decimal (so `1.005` at + * precision 2 becomes `1.01`), but it is only exact while the shifted value + * fits in `Number.MAX_SAFE_INTEGER`; beyond that the intermediate double + * rounds and can move the rounding boundary (e.g. `312834450754803.44` at + * precision 1 or 6 drifted to `312834450754803.5`). Round the decimal digits + * directly instead. + * + * @param {number} abs + * @param {number} p + * @return {number} + */ +declare function roundDecimal(abs: number, p: number): number; +/** + * @param {number} v + * @param {number | false} prec + * @return {number} + */ +declare function round(v: number, prec: number | false): number; +/** @param {number} v @return {boolean} */ +declare function isDegenerate(v: number): boolean; +/** @param {number} v @return {string} */ +declare function degenerateKeyword(v: number): string; +/** @param {number} v @return {string} */ +declare function serializeNumber(v: number): string; +/** + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @param {number | false} precision + * @param {number} [value] + * @return {number} + */ +declare function roundedScalarValue(node: import('../node.js').Num | import('../node.js').Dim, precision: number | false, value?: number): number; +/** + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @param {string[]} buffer + * @param {number} value + * @return {void} + */ +declare function emitRoundedScalar(node: import('../node.js').Num | import('../node.js').Dim, buffer: string[], value: number): void; +/** + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @param {SerializeSession} session + * @param {number} [value] + * @return {number} + */ +declare function emitFiniteScalar(node: import('../node.js').Num | import('../node.js').Dim, session: SerializeSession, value?: number): number; +/** + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @param {SerializeSession} session + * @param {number} [value] + * @return {void} + */ +declare function emitScalar(node: import('../node.js').Num | import('../node.js').Dim, session: SerializeSession, value?: number): void; +/** + * @param {string[]} buffer + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @return {void} + */ +declare function emitSignedZero(buffer: string[], node: import('../node.js').Num | import('../node.js').Dim): void; +/** @param {Node} node @return {node is import('../node.js').Num | import('../node.js').Dim} */ +declare function isScalar(node: Node): node is import('../node.js').Num | import('../node.js').Dim; +/** @param {Node} node @return {node is import('../node.js').Num | import('../node.js').Dim} */ +declare function isSignedZero(node: Node): node is import('../node.js').Num | import('../node.js').Dim; +/** + * Whether a scalar node is strictly negative after precision rounding + * (excluding signed zero and sub-precision values that round to zero). + * @param {Node} node + * @param {number | false} precision + * @return {node is import('../node.js').Num | import('../node.js').Dim} + */ +declare function isEffectivelyNegative(node: Node, precision: number | false): node is import('../node.js').Num | import('../node.js').Dim; +export { NOISE_FLOOR, divideByPowerOfTen, roundDecimal, round, isDegenerate, degenerateKeyword, serializeNumber, roundedScalarValue, emitRoundedScalar, emitFiniteScalar, emitScalar, emitSignedZero, isScalar, isSignedZero, isEffectivelyNegative, };