diff --git a/CHANGELOG.md b/CHANGELOG.md index c795089..2cafd8c 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,28 +7,27 @@ All notable changes to this project will be documented in this file. See [commit ### Bug fixes - transform a division or unit conversion only when the result is exact at the - configured `precision`. `calc(100% / 3)` stays `calc(100% / 3)` instead of - becoming `calc(33.33333%)`, and `calc(1cm + 1px)` - is no longer converted to an approximate `1.02646cm`. + configured `precision`. For example, keep `calc(100% / 3)` as `calc(100% / 3)` instead of + transforming to `calc(33.33333%)`, and no longer convert `calc(1cm + 1px)` + to an approximate `1.02646cm`. Use `precision: false` to transform every division. - keep `precision` significant digits for values below 1, so `calc(1px / 150000)` is no longer rounded to `.00001px` -- `min()`, `max()` and `clamp()` preserve the original unit -- keep the order of terms and factors around `var()`, `env()`, `attr()` and - other substitution functions. These are replaced by raw tokens before the - value is computed, so with `--a: 1px + 2px`, `var(--a) * 2` means - `1px + 2px * 2`. Factors are no longer reordered or cancelled: - `var(--a) * 2` is not rewritten to `2 * var(--a)`, `var(--a) / var(--a)` is - not cancelled, and parentheses are kept (`1px - (2 * var(--a))`). - Unrecognised functions such as `anchor-size()` are treated the same way, so +- preserve the original unit in `min()`, `max()` and `clamp()` +- keep the order of factors around `var()`, `env()`, `attr()` and + other substitution functions. + Factors are no longer reordered or cancelled: + do not rewrite `var(--a) * 2` to `2 * var(--a)`, do not cancel `var(--a) / var(--a)` + and keep parentheses (`1px - (2 * var(--a))`). + Treat unrecognised functions such as `anchor-size()` the same way, so `2 * anchor-size(width) * .5` is no longer folded - keep the parentheses of a nested `calc()` that contains unresolved values, so `calc(var(--a) - calc(var(--b) - var(--c)))` is no longer rewritten to `calc(var(--a) - var(--b) + var(--c))` - do not flip every sign of a parenthesized sum that starts with a negative - term: `calc((var(--b) - 7 - 2))` is now `calc(-9 + var(--b))` instead of - `calc(-1 * (9 - var(--b)))`, which changed the value when `--b` expands to - several tokens + term: `calc((var(--b) - 7 - 2))` now becomes `calc(-9 + var(--b))` instead of + `calc(-1 * (9 - var(--b)))`, because this last transformation changes the value + when `--b` expands to several tokens ## 11.2.1 (2026-09-20) diff --git a/scripts/benchmark/bootstrap-matrix.js b/scripts/benchmark/bootstrap-matrix.js new file mode 100644 index 0000000..d6d5b16 --- /dev/null +++ b/scripts/benchmark/bootstrap-matrix.js @@ -0,0 +1,155 @@ +import { seededRandom } from './random.js'; +import { finiteValues } from './statistics.js'; + +export function matrixWidth(rows) { + const width = rows[0]?.length; + if (!Number.isInteger(width) || width <= 0) + throw new RangeError('cannot bootstrap a matrix without columns'); + return width; +} + +export function validateRows(rows, width) { + for (const row of rows) { + if (!Array.isArray(row) || row.length !== width) + throw new TypeError('bootstrap rows must have equal widths'); + finiteValues(row); + } +} + +export 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); +} + +export function resampleRows( + rows, + width, + resamples, + seed, + observed, + standardErrors +) { + 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 deviation = pairedDeviation( + mean, + observed[column], + standardErrors[column] + ); + maxDeviation = Math.max(maxDeviation, deviation); + } + studentizedDeviations[sample] = maxDeviation; + } + return { distributions, studentizedDeviations }; +} + +export function pairedDeviation(mean, observed, standardError) { + if (standardError !== 0) return Math.abs((mean - observed) / standardError); + return mean === observed ? 0 : Infinity; +} + +export function splitStrata(rows, strata) { + 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'); + return { ordered, strataRows }; +} + +export function resampleStratum(group, indices, means, width, random) { + 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; +} + +export function sampledEffect(sampledMeans, column) { + let effect = 0; + for (const means of sampledMeans) effect += means[column]; + return effect / sampledMeans.length; +} + +export function sampledVariance( + strataRows, + sampledIndices, + sampledMeans, + column +) { + let variance = 0; + for (let stratum = 0; stratum < strataRows.length; stratum++) { + const group = strataRows[stratum]; + const indices = sampledIndices[stratum]; + const mean = sampledMeans[stratum][column]; + 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; + } + return variance; +} + +export function studentizedStatistic( + deviation, + sampledSE, + observedSE, + counters +) { + if (sampledSE !== 0) return deviation / sampledSE; + counters.degenerateEndpoint = true; + if (deviation === 0) return 0; + if (observedSE === 0) + throw new RangeError( + 'nonzero bootstrap deviation has no positive standard error' + ); + counters.degenerateFallbacks++; + return deviation / observedSE; +} + +export 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); + }); +} + +function compareRows(left, right) { + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} diff --git a/scripts/benchmark/bootstrap.js b/scripts/benchmark/bootstrap.js index 00216ef..47f8432 100644 --- a/scripts/benchmark/bootstrap.js +++ b/scripts/benchmark/bootstrap.js @@ -1,6 +1,17 @@ -/* oxlint-disable complexity */ import { BOOTSTRAP_RESAMPLES, DECISION_INTERVAL_METHOD } from './config.js'; import { seededRandom } from './random.js'; +import { + columnMeans, + matrixWidth, + resampleRows, + resampleStratum, + sampledEffect, + sampledVariance, + splitStrata, + standardErrorsForSample, + studentizedStatistic, + validateRows, +} from './bootstrap-matrix.js'; import { finiteValues, percentile } from './statistics.js'; /** @param {number[]} values @param {number} seed @param {number} [resamples] */ @@ -70,25 +81,12 @@ export function bootstrapPairedIntervals( 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'); + const width = matrixWidth(rows); 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; + validateRows(rows, width); + const observed = columnMeans(rows, width); const standardErrors = Array.from({ length: width }, (_, column) => { const variance = rows.reduce( @@ -97,29 +95,14 @@ export function bootstrapPairedIntervals( ) / 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 { distributions, studentizedDeviations } = resampleRows( + rows, + width, + resamples, + seed, + observed, + standardErrors + ); const familyCritical = percentile(studentizedDeviations, 0.95); return { @@ -167,31 +150,11 @@ export function bootstrapStratifiedMaxT({ 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 width = matrixWidth(rows); + validateRows(rows, width); + const { ordered, strataRows } = splitStrata(rows, strata); - const observedByStratum = strataRows.map((group) => - columnMeans(group, width) - ); + const observedByStratum = strataRows.map((g) => columnMeans(g, width)); const observed = Array.from( { length: width }, (_, column) => @@ -213,64 +176,42 @@ export function bootstrapStratifiedMaxT({ (group) => new Int32Array(group.length) ); const sampledMeans = strataRows.map(() => new Float64Array(width)); - let degenerateResamples = 0; - let degenerateFallbacks = 0; + const counters = { + degenerateResamples: 0, + degenerateFallbacks: 0, + degenerateEndpoint: false, + }; 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; - } + for (let stratum = 0; stratum < strataCount; stratum++) + resampleStratum( + strataRows[stratum], + sampledIndices[stratum], + sampledMeans[stratum], + width, + random + ); let maxT = 0; - let hasDegenerateEndpoint = false; + counters.degenerateEndpoint = 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); + const effect = sampledEffect(sampledMeans, column); + const variance = sampledVariance( + strataRows, + sampledIndices, + sampledMeans, + column + ); 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' - ); - } else { - statistic = deviation / observedSE[column]; - degenerateFallbacks++; - } - } else { - statistic = (effect - observed[column]) / sampledSE; - } + const statistic = studentizedStatistic( + effect - observed[column], + Math.sqrt(variance / strataCount ** 2), + observedSE[column], + counters + ); maxT = Math.max(maxT, Math.abs(statistic)); } - if (hasDegenerateEndpoint) degenerateResamples++; + if (counters.degenerateEndpoint) counters.degenerateResamples++; studentizedMax[sample] = maxT; } @@ -284,8 +225,8 @@ export function bootstrapStratifiedMaxT({ observed, standardErrors: observedSE, familyCritical, - degenerateResamples, - degenerateFallbacks, + degenerateResamples: counters.degenerateResamples, + degenerateFallbacks: counters.degenerateFallbacks, intervals: distributions.map((values, column) => { const alpha = (1 - confidence) / 2; const halfWidth = @@ -353,32 +294,3 @@ export function bootstrapCorpusInterval(strata, seed, resamples, confidence) { : 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/benchmark/config.js b/scripts/benchmark/config.js index 1fb91a2..d736098 100644 --- a/scripts/benchmark/config.js +++ b/scripts/benchmark/config.js @@ -1,4 +1,3 @@ -/* oxlint-disable complexity */ export const TARGET_BATCH_MS = 25; export const MIN_WARMUPS = 5; export const MAX_WARMUPS = 10; @@ -40,62 +39,84 @@ export const DECISION_CONFIG_KEYS = [ '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) +function isPositiveFinite(value) { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function requireParameters(config, kind, keys, isValid) { + for (const key of keys) + if (!isValid(config[key])) throw new TypeError(`${kind} has invalid decision parameter ${key}`); +} + +function validateCounts(config, kind) { + requireParameters( + config, + kind, + [ + 'requestedBlocks', + 'minimumBlocks', + 'maxAttempts', + 'measuredBatchCount', + 'bootstrapResamples', + ], + (value) => Number.isInteger(value) && value > 0 + ); 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}`); + requireParameters( + config, + kind, + ['warmupMinimum', 'warmupMaximum'], + (value) => Number.isInteger(value) && value >= 0 + ); 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`); +} + +function validateMargins(config, kind) { + requireParameters( + config, + kind, + [ + 'targetBatchMs', + 'driftThreshold', + 'bootstrapResamples', + 'runtimeNonRegressionMargin', + 'equivalenceMargin', + 'precisionMargin', + 'growthThreshold', + 'orderInteractionThreshold', + ], + isPositiveFinite + ); + requireParameters( + config, + kind, + ['confidence'], + (value) => isPositiveFinite(value) && value < 1 + ); 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`); +} + +/** @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` + ); + validateCounts(config, kind); + validateMargins(config, kind); if ( ![DECISION_INTERVAL_METHOD, CORPUS_INTERVAL_METHOD].includes( config.intervalMethod @@ -107,6 +128,51 @@ export function validateDecisionConfig(config, kind = 'artifact') { return config; } +function legacyDefaults(requestedBlocks, isCorpus) { + return { + requestedBlocks, + minimumBlocks: MIN_VALID_BLOCKS, + maxAttempts: Math.max(30, requestedBlocks), + targetBatchMs: TARGET_BATCH_MS, + warmupMinimum: isCorpus ? 0 : MIN_WARMUPS, + warmupMaximum: isCorpus ? 0 : MAX_WARMUPS, + measuredBatchCount: MEASURED_BATCHES, + driftThreshold: DRIFT_THRESHOLD, + bootstrapResamples: BOOTSTRAP_RESAMPLES, + confidence: 0.95, + runtimeNonRegressionMargin: NON_REGRESSION_MARGIN, + equivalenceMargin: isCorpus ? CORPUS_EQUIVALENCE_MARGIN : 1.1, + precisionMargin: 1.1, + growthThreshold: GROWTH_THRESHOLD, + orderInteractionThreshold: Math.log(1.1), + intervalMethod: isCorpus + ? CORPUS_INTERVAL_METHOD + : DECISION_INTERVAL_METHOD, + }; +} + +function legacySource(artifact) { + const config = artifact?.config ?? {}; + const blocks = artifact?.blocks?.length ?? artifact?.replicates?.length ?? 0; + const requestedBlocks = + config.requestedBlocks ?? config.blocks ?? config.replicates ?? blocks; + const source = { + ...config, + requestedBlocks, + measuredBatchCount: + config.measuredBatchCount ?? config.batches ?? MEASURED_BATCHES, + }; + return { config, blocks, source }; +} + +function legacyCorpusFields(config, blocks) { + return { + replicates: config.replicates ?? blocks, + batches: config.batches ?? 6, + calibrationOrderBalanced: config.calibrationOrderBalanced ?? false, + }; +} + /** * Migrate the pre-contract artifacts that were emitted by the first schema-v2 * implementation. This is intentionally the only place where repository @@ -116,46 +182,15 @@ 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, - }; + const isCorpus = kind === 'corpus'; + const { config, blocks, source } = legacySource(artifact); + const requestedBlocks = source.requestedBlocks; + const defaults = legacyDefaults(requestedBlocks, isCorpus); + const base = { decisionConfigVersion: DECISION_CONFIG_VERSION }; + for (const [key, fallback] of Object.entries(defaults)) + base[key] = source[key] ?? fallback; + base.precisionMethod = PRECISION_METHOD; + if (isCorpus) return { ...base, ...legacyCorpusFields(config, blocks) }; return base; } diff --git a/scripts/benchmark/corpus-analysis.js b/scripts/benchmark/corpus-analysis.js index 2512994..5ccc7ad 100644 --- a/scripts/benchmark/corpus-analysis.js +++ b/scripts/benchmark/corpus-analysis.js @@ -1,4 +1,4 @@ -/* oxlint-disable no-bitwise, complexity */ +/* oxlint-disable no-bitwise */ import { stableHash } from '../lib/corpus-policy.js'; import { BOOTSTRAP_RESAMPLES, diff --git a/scripts/benchmark/corpus-runner.js b/scripts/benchmark/corpus-runner.js index 5a773c7..f46ce97 100644 --- a/scripts/benchmark/corpus-runner.js +++ b/scripts/benchmark/corpus-runner.js @@ -1,4 +1,4 @@ -/* oxlint-disable no-bitwise, complexity */ +/* oxlint-disable no-bitwise */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/scripts/benchmark/parser-analysis.js b/scripts/benchmark/parser-analysis.js index 6625917..adddf05 100644 --- a/scripts/benchmark/parser-analysis.js +++ b/scripts/benchmark/parser-analysis.js @@ -1,4 +1,4 @@ -/* oxlint-disable no-bitwise, complexity */ +/* oxlint-disable no-bitwise */ import { bootstrapStratifiedMaxT, decisionConfigForArtifact, @@ -33,6 +33,120 @@ function revisionResults(blocks, revision) { return values; } +function pairedLogRatios(blocks, keys) { + const baseline = revisionResults(blocks, 'baseline'); + const candidate = revisionResults(blocks, 'candidate'); + return 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])); + }); +} + +function initialEndpoint(blocks, key, logs) { + 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), + }; + endpoint.observedLogRatioSd = variationMetrics(logs).sd; + return endpoint; +} + +function applyFamilyBootstrap(endpoints, familyBootstrap, config, blockCount) { + 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, + blockCount, + index + ); + } +} + +function growthVerdict(growth, threshold) { + let status = 'pass'; + if (growth.some((item) => item.candidateLowerRatio > threshold)) + status = 'regression'; + else if (growth.some((item) => item.candidateUpperRatio > threshold)) + status = 'inconclusive'; + return applyPrecision(status, growth); +} + +function combineStatuses(runtimeStatus, slopeStatus, growthStatus) { + const statuses = [runtimeStatus, slopeStatus, growthStatus]; + if (statuses.includes('regression')) return 'regression'; + if (statuses.every((item) => item === 'pass')) return 'pass'; + return 'inconclusive'; +} + +function addSensitivity(result, artifact, blocks) { + 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'; +} + export function analyzeParser( artifact, { skipValidation = false, sensitivity = true } = {} @@ -48,54 +162,14 @@ export function analyzeParser( 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 logsByKey = pairedLogRatios(blocks, keys); 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 endpoints = keys.map((key, index) => + initialEndpoint(blocks, key, logsByKey[index]) + ); const largestKeys = largestSizeKeys(artifact.workloadKeys); const largest = endpoints.filter((endpoint) => largestKeys.has(endpoint.key)); @@ -113,23 +187,7 @@ export function analyzeParser( 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 - ); - } + applyFamilyBootstrap(endpoints, familyBootstrap, config, blocks.length); addSlopeIntervals(slopes, familyBootstrap, keys.length, config); const growth = addGrowthIntervals( growthData, @@ -154,29 +212,10 @@ export function analyzeParser( ), 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'; + const growthStatus = growthVerdict(growth, config.growthThreshold); + let status = combineStatuses(runtimeStatus, slopeStatus, growthStatus); if (orderEffect.diagnostic) status = 'inconclusive'; + const rejections = rejectionSummary(artifact.attempts); const result = { status, intervalMethod: DECISION_INTERVAL_METHOD, @@ -187,48 +226,13 @@ export function analyzeParser( slopes, growth, orderEffect, - rejections: rejectionSummary(artifact.attempts), - rejectionCounts: rejectionSummary(artifact.attempts).byReason, - rejectionRate: rejectionSummary(artifact.attempts).rate, + rejections, + rejectionCounts: rejections.byReason, + rejectionRate: rejections.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'; - } + if (sensitivity) addSensitivity(result, artifact, blocks); return result; } diff --git a/scripts/benchmark/parser-runner.js b/scripts/benchmark/parser-runner.js index 341d5a4..ee5d42d 100644 --- a/scripts/benchmark/parser-runner.js +++ b/scripts/benchmark/parser-runner.js @@ -1,4 +1,4 @@ -/* oxlint-disable complexity, no-bitwise */ +/* oxlint-disable no-bitwise */ import { mkdirSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; @@ -31,15 +31,7 @@ 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, -} = {}) { +function validateOptions(blocks, maxAttempts) { if ( !Number.isInteger(blocks) || blocks < MIN_VALID_BLOCKS || @@ -50,10 +42,10 @@ export function runParserBenchmark({ ); 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 = { +} + +function buildConfig({ benchmark, baseline, blocks, maxAttempts }) { + return { decisionConfigVersion: DECISION_CONFIG_VERSION, benchmark, baseline, @@ -75,6 +67,160 @@ export function runParserBenchmark({ orderInteractionThreshold: Math.log(1.1), intervalMethod: DECISION_INTERVAL_METHOD, }; +} + +function processSchedule(blocks, seed) { + return seededShuffle( + Array.from({ length: blocks }, (_, index) => + index < blocks / 2 ? 'baseline-first' : 'candidate-first' + ), + seed + ); +} + +function revisionSources(processOrder, root, materialized) { + const baseline = ['baseline', materialized.sourceRoot]; + const candidate = ['candidate', join(root, 'src')]; + return processOrder === 'baseline-first' + ? [baseline, candidate] + : [candidate, baseline]; +} + +function runAttempt({ + attempt, + seed, + processOrder, + workloads, + workloadKeys, + config, + root, + materialized, +}) { + const blockSeed = (seed + Math.imul(attempt + 1, 0x9e3779b9)) >>> 0; + const order = seededShuffle(workloads, blockSeed); + const revisions = revisionSources(processOrder, root, materialized).map( + ([revision, sourceRoot]) => + runChild( + WORKER, + { + sourceRoot, + revision, + processOrder, + workloads: order, + targetBatchMs: config.targetBatchMs, + warmupMinimum: config.warmupMinimum, + warmupMaximum: config.warmupMaximum, + measuredBatchCount: config.measuredBatchCount, + }, + root + ) + ); + 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 drifted = drift.some((value) => value > config.driftThreshold); + const rejectionReasons = [ + ...(drifted ? ['drift'] : []), + ...(mismatches.length > 0 ? ['structural-mismatch'] : []), + ]; + return { + index: attempt, + seed: blockSeed, + processOrder, + workloadOrder: order.map(endpointKey), + rejected: rejectionReasons.length > 0, + rejectionReasons, + rejectionReason: rejectionReasons.join('+') || null, + drift, + structuralMismatches: mismatches, + revisions, + }; +} + +function collectBlocks({ blocks, maxAttempts, seed, ...context }) { + const attempts = []; + const validBlocks = []; + const schedule = processSchedule(blocks, seed); + let correctnessFailure = null; + let attempt = 0; + while (validBlocks.length < blocks && attempt < maxAttempts) { + // Rejected attempts retry the same acceptance slot. This preserves the + // randomized, balanced process-order schedule among retained blocks. + const record = runAttempt({ + ...context, + attempt, + seed, + processOrder: schedule[validBlocks.length], + }); + attempts.push(record); + if (record.structuralMismatches.length > 0) { + correctnessFailure = record; + break; + } + if (!record.rejected) validBlocks.push(record); + attempt++; + } + return { attempts, validBlocks, correctnessFailure }; +} + +function analyze(artifact, correctnessFailure) { + const validCount = artifact.blocks.length; + if (correctnessFailure) + return { + status: 'correctness-failure', + validBlocks: validCount, + reason: 'baseline and candidate parser structures differ', + attempt: correctnessFailure.index, + structuralMismatches: correctnessFailure.structuralMismatches, + }; + if (validCount >= MIN_VALID_BLOCKS) return analyzeParser(artifact); + return { + status: 'inconclusive', + validBlocks: validCount, + reason: 'fewer than twenty valid blocks', + }; +} + +function outputPath({ root, output, benchmark, seed }) { + return output + ? resolve(root, output) + : join( + root, + 'reports/benchmarks', + `${benchmark}-${Date.now()}-${seed}.json` + ); +} + +export function runParserBenchmark({ + root = process.cwd(), + benchmark = 'arithmetic-chains', + baseline = 'HEAD', + blocks = MIN_VALID_BLOCKS, + maxAttempts = Math.max(30, blocks), + seed = 0x51f15eed, + output, +} = {}) { + validateOptions(blocks, maxAttempts); + const normalizedSeed = normalizeSeed(seed); + const workloads = parserWorkloads(benchmark); + const workloadKeys = workloads.map((item) => item.key); + const config = buildConfig({ benchmark, baseline, blocks, maxAttempts }); const environment = collectBenchmarkProvenance(root, { baselineRef: baseline, benchmark: `parser-${benchmark}`, @@ -82,100 +228,16 @@ export function runParserBenchmark({ }); 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 { attempts, validBlocks, correctnessFailure } = collectBlocks({ + blocks, + maxAttempts, + seed: normalizedSeed, + workloads, + workloadKeys, + config, + root, + materialized, + }); const artifact = { schema: 2, benchmark: `parser-${benchmark}`, @@ -191,31 +253,14 @@ export function runParserBenchmark({ 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', - }; - } + artifact.analysis = analyze(artifact, correctnessFailure); validateSchemaV2Artifact(artifact); - const path = output - ? resolve(root, output) - : join( - root, - 'reports/benchmarks', - `${benchmark}-${Date.now()}-${normalizedSeed}.json` - ); + const path = outputPath({ + root, + output, + benchmark, + seed: normalizedSeed, + }); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`); if (correctnessFailure) diff --git a/scripts/benchmark/validate-corpus-summaries.js b/scripts/benchmark/validate-corpus-summaries.js index 63f18f4..2aedce7 100644 --- a/scripts/benchmark/validate-corpus-summaries.js +++ b/scripts/benchmark/validate-corpus-summaries.js @@ -1,4 +1,4 @@ -/* oxlint-disable complexity, no-bitwise */ +/* oxlint-disable no-bitwise */ import { stableHash } from '../lib/corpus-policy.js'; import { bootstrapCorpusInterval } from './bootstrap.js'; import { BOOTSTRAP_RESAMPLES } from './config.js'; @@ -20,109 +20,111 @@ export function validateCorpusGroupSummaries( 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`); + validateSummaryShape(artifact, group, summary); + validateSummaryRatios(rawRatios, group, summary); + if (summary.bootstrap95) validateSummaryIntervals(artifact, group, summary); + validateSummaryChecksums(group, summary, allChecksumsByGroup); } - 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 + if (artifact.analysis?.status) validateAnalysisStatus(artifact); +} + +function isNonNegativeInteger(value) { + return Number.isInteger(value) && value >= 0; +} + +function validateSummaryShape(artifact, group, summary) { + const count = artifact.replicates.length; + if ( + !summary || + summary.replicates !== count || + !Array.isArray(summary.ratios) || + summary.ratios.length !== count || + summary.ratios.some((ratio) => !positiveFinite(ratio)) || + !Array.isArray(summary.checksums) || + summary.checksums.length === 0 || + !summary.checksums.every(isNonNegativeInteger) + ) + throw new TypeError(`corpus group ${group} has invalid summary`); +} + +function validateSummaryRatios(rawRatios, group, summary) { + if ( + rawRatios.length !== summary.ratios.length || + rawRatios.some( + (ratio, index) => Math.abs(ratio - summary.ratios[index]) > 1e-12 ) - 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 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` + ); +} + +function intervalMatches(actual, expected) { + return ( + Math.abs(actual.lowerRatio - Math.exp(expected.lower)) <= 1e-12 && + Math.abs(actual.upperRatio - Math.exp(expected.upper)) <= 1e-12 + ); +} + +function validateSummaryIntervals(artifact, group, summary) { + const digest = stableHash(group); + const groupSeed = + (artifact.seed ^ Number.parseInt(digest.slice(0, 8), 16)) >>> 0; + const resamples = artifact.config?.bootstrapResamples ?? BOOTSTRAP_RESAMPLES; + const pairs = corpusRawReplicatePairs(artifact, group); + const expected95 = bootstrapCorpusInterval(pairs, groupSeed, resamples, 0.95); + if (!intervalMatches(summary.bootstrap95, expected95)) + throw new TypeError( + `corpus group ${group} interval is not derived from raw observations` + ); + if (!summary.bootstrap90) return; + const expected90 = bootstrapCorpusInterval( + pairs, + (groupSeed ^ 0x9e3779b9) >>> 0, + resamples, + 0.9 + ); + if (!intervalMatches(summary.bootstrap90, expected90)) + throw new TypeError( + `corpus group ${group} practical interval is not derived from raw observations` + ); +} + +function validateSummaryChecksums(group, summary, allChecksumsByGroup) { + 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`); +} + +function validateAnalysisStatus(artifact) { + const { status, statistical, practical } = artifact.analysis; + if ( + !['postcss-calc faster', 'postcss-calc slower', 'inconclusive'].includes( + status ) - throw new TypeError( - 'corpus artifact has inconsistent equivalence margin' - ); - } + ) + throw new TypeError('corpus artifact has an invalid statistical status'); + if (statistical?.status !== undefined && statistical.status !== status) + throw new TypeError('corpus artifact has inconsistent statistical status'); + if ( + practical?.margin !== undefined && + practical.margin !== artifact.config.equivalenceMargin + ) + throw new TypeError('corpus artifact has inconsistent equivalence margin'); } export function corpusRawRatios(artifact, group) { diff --git a/scripts/benchmark/validate-corpus.js b/scripts/benchmark/validate-corpus.js index 35f72b4..9fb2154 100644 --- a/scripts/benchmark/validate-corpus.js +++ b/scripts/benchmark/validate-corpus.js @@ -1,4 +1,3 @@ -/* oxlint-disable complexity */ import { CORPUS_CATEGORIES, NEUTRAL_CORPUS_CATEGORIES, @@ -12,6 +11,8 @@ import { import { positiveFinite } from './validate-parser.js'; import { validateCorpusGroupSummaries } from './validate-corpus-summaries.js'; +const ORDERS = ['ours-first', 'reference-first']; + export function validateCorpusArtifact(artifact) { if ( !Array.isArray(artifact.replicates) || @@ -41,91 +42,115 @@ export function validateCorpusArtifact(artifact) { 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 state = { + config, + expectedGroups, + repetitionsByGroup: new Map(), + checksumsByGroup: new Map(), + allChecksumsByGroup: new Map(), + }; const replicateIds = new Set(); for (const [index, replicate] of artifact.replicates.entries()) { + validateReplicateHeader(artifact, replicate, index, replicateIds, config); + validateReplicateBatches(replicate, index, state); + } + validateCorpusGroupSummaries( + artifact, + expectedGroups, + state.allChecksumsByGroup + ); + return artifact; +} + +function validateReplicateHeader( + artifact, + replicate, + index, + replicateIds, + config +) { + 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) + validateCalibrationBalance(artifact, replicate, index); + 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`); +} + +function validateCalibrationBalance(artifact, replicate, index) { + if (!ORDERS.includes(replicate.calibrationOrder)) + throw new TypeError( + `corpus replicate ${index} has an invalid calibration order` + ); + for (const expectedOrder of ORDERS) { + 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'); + } +} + +function validateReplicateBatches(replicate, index, state) { + const orderCounts = { 'ours-first': 0, 'reference-first': 0 }; + for (const [batchIndex, batch] of replicate.batches.entries()) { if ( - !Number.isInteger(replicate?.replicate) || - replicate.replicate < 0 || - replicateIds.has(replicate.replicate) + !batch || + !ORDERS.includes(batch.order) || + !Array.isArray(batch.measurements) ) - 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'); - } - } + throw new TypeError(`corpus replicate ${index} has invalid batch order`); + orderCounts[batch.order]++; + validateBatchMeasurements(batch, batchIndex, replicate, index, state); + } + if (orderCounts['ours-first'] !== 3 || orderCounts['reference-first'] !== 3) + throw new TypeError( + `corpus replicate ${index} has unbalanced process orders` + ); +} + +function validateBatchMeasurements(batch, batchIndex, replicate, index, state) { + const { expectedGroups } = state; + 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 ( - !Array.isArray(replicate.permutation) || - !isPermutation(replicate.permutation, artifact.correctness.accepted) + !measurement || + typeof measurement.group !== 'string' || + !expectedGroups.includes(measurement.group) || + seenGroups.has(measurement.group) ) 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); - validateCorpusMeasurement( - measurement, - replicate, - index, - config, - repetitionsByGroup, - checksumsByGroup, - allChecksumsByGroup - ); - } - 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` + `corpus replicate ${index} batch ${batchIndex} has invalid groups` ); + seenGroups.add(measurement.group); + validateCorpusMeasurement( + measurement, + replicate, + index, + state.config, + state.repetitionsByGroup, + state.checksumsByGroup, + state.allChecksumsByGroup + ); } - validateCorpusGroupSummaries(artifact, expectedGroups, allChecksumsByGroup); - return artifact; + if (seenGroups.size !== expectedGroups.length) + throw new TypeError( + `corpus replicate ${index} batch ${batchIndex} has missing groups` + ); } function validateCorpusCorrectness(artifact) { @@ -202,6 +227,50 @@ function validateCorpusMeasurement( repetitionsByGroup, checksumsByGroup, allChecksumsByGroup +) { + validateMeasurementRepetitions( + measurement, + replicate, + index, + repetitionsByGroup + ); + validateCalibrationSamples(measurement, index); + 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); + } +} + +function validateMeasurementRepetitions( + measurement, + replicate, + index, + repetitionsByGroup ) { if ( !Number.isInteger(measurement.repetitions) || @@ -218,6 +287,9 @@ function validateCorpusMeasurement( `corpus group ${measurement.group} has inconsistent repetitions` ); repetitionsByGroup.set(replicateGroup, measurement.repetitions); +} + +function validateCalibrationSamples(measurement, index) { if ( !Array.isArray(measurement.calibrationSamplesMs) || measurement.calibrationSamplesMs.length === 0 @@ -225,7 +297,7 @@ function validateCorpusMeasurement( throw new TypeError( `corpus replicate ${index} has invalid calibration samples` ); - for (const sample of measurement.calibrationSamplesMs) { + for (const sample of measurement.calibrationSamplesMs) if ( !sample || !positiveFinite(sample.oursMs) || @@ -234,34 +306,4 @@ function validateCorpusMeasurement( 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); - } } diff --git a/scripts/benchmark/validate-parser.js b/scripts/benchmark/validate-parser.js index db7919c..68b7cf4 100644 --- a/scripts/benchmark/validate-parser.js +++ b/scripts/benchmark/validate-parser.js @@ -1,4 +1,3 @@ -/* oxlint-disable complexity */ export function positiveFinite(value) { return typeof value === 'number' && Number.isFinite(value) && value > 0; } @@ -10,6 +9,14 @@ export function validateParserRecord( isAttempt = false, driftThreshold = 0.15 ) { + validateRecordHeader(record, label, expected, isAttempt); + if (isAttempt) validateAttemptFlags(record, label, driftThreshold); + for (const revision of record.revisions) + validateParserRevision(revision, label, expected, record.processOrder); + if (isAttempt) validateAttemptDerivations(record, label, expected); +} + +function validateRecordHeader(record, label, expected, isAttempt) { if ( !record || typeof record !== 'object' || @@ -42,76 +49,80 @@ export function validateParserRecord( !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') +} + +function validateAttemptFlags(record, label, driftThreshold) { + if (typeof record.rejected !== 'boolean') + throw new TypeError(`${label} has an invalid rejection flag`); + if (!hasValidRejectionReasons(record)) + 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 inconsistent structural rejection`); - if ( - record.drift.some((value) => value > driftThreshold) !== - record.rejectionReasons.includes('drift') + ) + 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`); +} + +function hasValidRejectionReasons(record) { + const reasons = record.rejectionReasons; + return ( + Array.isArray(reasons) && + reasons.every((reason) => + ['drift', 'structural-mismatch'].includes(reason) + ) && + new Set(reasons).size === reasons.length && + record.rejected === Boolean(reasons.length) && + record.rejectionReason === (reasons.join('+') || null) + ); +} + +function validateAttemptDerivations(record, label, expected) { + const expectedDrift = record.revisions.map((revision) => + Math.abs( + revision.controlAfter.medianMs / revision.controlBefore.medianMs - 1 ) - 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 - ) + ); + 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 drift`); + const structural = new Map( + record.revisions.flatMap((revision) => + revision.workloads.map((workload) => [ + `${revision.revision}:${workload.key}`, + workload.structural, + ]) ) - throw new TypeError(`${label} has inconsistent structural mismatches`); - } + ); + 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) { diff --git a/scripts/benchmark/validate.js b/scripts/benchmark/validate.js index bd99f35..1dddfa6 100644 --- a/scripts/benchmark/validate.js +++ b/scripts/benchmark/validate.js @@ -1,4 +1,3 @@ -/* oxlint-disable complexity */ import { DECISION_CONFIG_VERSION, MIN_VALID_BLOCKS, @@ -20,14 +19,17 @@ export function validateSchemaV2Artifact(artifact) { ) throw new TypeError('artifact has an invalid seed'); if (artifact.benchmark === 'corpus') return validateCorpusArtifact(artifact); + return validateParserArtifact(artifact); +} + +function validateParserArtifact(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; + const { requestedBlocks, maxAttempts, minimumBlocks } = config; if ( !Number.isInteger(requestedBlocks) || requestedBlocks < MIN_VALID_BLOCKS || @@ -36,16 +38,8 @@ export function validateSchemaV2Artifact(artifact) { 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) + if (underFloor && !isAllowedUnderFloor(artifact)) throw new TypeError( `artifact has fewer than ${minimumBlocks} valid blocks` ); @@ -54,56 +48,61 @@ export function validateSchemaV2Artifact(artifact) { 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) { + if (artifact.attempts !== undefined) + validateAttempts(artifact, expected, config); + else if (underFloor) throw new TypeError('under-floor artifact must retain attempts'); + validateBlockBalance(artifact.blocks, requestedBlocks, minimumBlocks); + return artifact; +} + +function isAllowedUnderFloor(artifact) { + const status = artifact.analysis?.status; + const isInconclusive = + status === 'inconclusive' && + Array.isArray(artifact.attempts) && + artifact.attempts.length > 0; + return isInconclusive || status === 'correctness-failure'; +} + +function validateAttempts(artifact, expected, config) { + const { attempts, blocks } = artifact; + if (!Array.isArray(attempts) || attempts.length === 0) + throw new TypeError('artifact attempts must be a non-empty array'); + if (attempts.length > config.maxAttempts) + throw new TypeError('artifact contains more attempts than maxAttempts'); + for (const [attemptIndex, attempt] of attempts.entries()) { + validateParserRecord( + attempt, + `attempt ${attemptIndex}`, + expected, + true, + config.driftThreshold + ); + if (attempt.index !== attemptIndex) + throw new TypeError(`attempt ${attemptIndex} has an inconsistent index`); } - if (artifact.blocks.length > requestedBlocks) + const accepted = attempts.filter((attempt) => !attempt.rejected); + if (accepted.length !== blocks.length) + throw new TypeError('artifact attempts and blocks are inconsistent'); + for (const [index, block] of blocks.entries()) + if (JSON.stringify(accepted[index]) !== JSON.stringify(block)) + throw new TypeError('artifact attempts and blocks are inconsistent'); +} + +function validateBlockBalance(blocks, requestedBlocks, minimumBlocks) { + if (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; + const countFirst = (order) => + blocks.filter((block) => block.processOrder === order).length; + if ( + blocks.length === requestedBlocks && + countFirst('baseline-first') !== requestedBlocks / 2 + ) + throw new TypeError('accepted blocks have an unbalanced process order'); + if ( + blocks.length >= minimumBlocks && + countFirst('baseline-first') !== countFirst('candidate-first') + ) + throw new TypeError('accepted blocks have an unbalanced process order'); } diff --git a/test/conformance/corpus.test.js b/test/conformance/corpus.test.js index a35925e..e7fd723 100644 --- a/test/conformance/corpus.test.js +++ b/test/conformance/corpus.test.js @@ -1,14 +1,16 @@ -// Real-world corpus test. -// -// Inputs are every unique `calc(...)` expression extracted from cssnano's -// integration CSS fixtures (Bootstrap, Bulma, Foundation, Milligram, -// Picnic, Semantic UI, Turret, UIkit). The corpus is committed under -// `corpus/` so the test is self-contained — no sibling-repo dependency. -// -// For each expression we run both our pipeline and `@csstools/css-calc`, -// canonicalize the outputs through our parser at a shared precision, and -// assert they agree. Any divergence is either a real bug or a known -// design choice documented in `KNOWN_DIVERGENCES`. +/* Real-world corpus test. + * + * Inputs are every unique `calc(...)` expression extracted from cssnano's + * integration CSS fixtures (Bootstrap, Bulma, Foundation, Milligram, + * Picnic, Semantic UI, Turret, UIkit). The corpus is committed under + * `corpus/` so the test is self-contained — no sibling-repo dependency. + * + * For each expression we run both our pipeline and `@csstools/css-calc`, + * canonicalize the outputs through our parser at a shared precision, and + * assert they agree. Any divergence is either a real bug or a known + * design choice documented in `KNOWN_DIVERGENCES`. + */ + import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/test/conformance/invalid-corpus.test.js b/test/conformance/invalid-corpus.test.js index 435a825..43a0fe8 100644 --- a/test/conformance/invalid-corpus.test.js +++ b/test/conformance/invalid-corpus.test.js @@ -17,7 +17,6 @@ import { runCorpus, assertResilience } from '../helpers/resilience.js'; const r = runCorpus( fileURLToPath(new URL('../corpus/github/invalid.txt', import.meta.url)) ); -// eslint-disable-next-line sonarjs/assertions-in-tests test(`invalid corpus: ${r.total} expressions parse-or-throw cleanly`, () => { assertResilience(r, 'invalid', assert); }); diff --git a/test/conformance/preprocessor-corpus.test.js b/test/conformance/preprocessor-corpus.test.js index ab2c639..c6a7b7c 100644 --- a/test/conformance/preprocessor-corpus.test.js +++ b/test/conformance/preprocessor-corpus.test.js @@ -8,7 +8,6 @@ import { runCorpus, assertResilience } from '../helpers/resilience.js'; const r = runCorpus( fileURLToPath(new URL('../corpus/github/preprocessor.txt', import.meta.url)) ); -// eslint-disable-next-line sonarjs/assertions-in-tests test(`preprocessor corpus: ${r.total} expressions parse-or-throw cleanly`, () => { assertResilience(r, 'preprocessor', assert); }); diff --git a/test/index-edge-cases.test.cjs b/test/index-edge-cases.test.cjs index 782fa1e..21be2ab 100644 --- a/test/index-edge-cases.test.cjs +++ b/test/index-edge-cases.test.cjs @@ -53,8 +53,7 @@ describe('Reduce', () => { ); test( - 'should reduce calc (uppercase) (#2)', - // zero bucket kept → calc() wrapper survives (name case preserved). + 'should preserve the zero with units (uppercase)', testValue('CALC( (1EM - CALC( 10PX + 1EM)) / 2)', 'CALC(0em - 5px)') ); }); diff --git a/test/index-options.test.cjs b/test/index-options.test.cjs index 5cdec72..a2571e5 100644 --- a/test/index-options.test.cjs +++ b/test/index-options.test.cjs @@ -22,11 +22,10 @@ test( describe('Ignore', () => { test( - 'should ignore reducing custom property', - // A division after a substitution stays where it was written. + 'should keep the division after a custom property', testCss( ':root { --foo: calc(var(--bar) / 8); }', - /* ':root { --foo: calc(var(--bar)/8); }' */ ':root { --foo: calc(var(--bar) / 8); }' + ':root { --foo: calc(var(--bar) / 8); }' ) ); @@ -44,7 +43,7 @@ test( // `10px+10px` violates §10.1 whitespace → preserved with a warning. testCss( '@media (min-width:calc(10px+10px)){}', - /* '@media (min-width:20px){}' */ '@media (min-width:calc(10px+10px)){}', + '@media (min-width:calc(10px+10px)){}', { mediaQueries: true, } @@ -53,12 +52,12 @@ test( describe('Ignore', () => { test( - 'should ignore selectors (1)', + 'should ignore attribute selectors', testCss('div[data-size="calc(3*3)"]{}', 'div[data-size="calc(3*3)"]{}') ); test( - 'should ignore selectors (2)', + 'should ignore nth-child pseudo-class', testCss( 'div:nth-child(2n + calc(3*3)){}', 'div:nth-child(2n + calc(3*3)){}' @@ -99,8 +98,7 @@ test( ); test( - 'nested var (reduce-css-calc#50)', - // division by a number is kept as written. + 'should keep division a s written in a nested var (reduce-css-calc#50)', testValue( 'calc(var(--xxx, var(--yyy)) / 2)', 'calc(var(--xxx, var(--yyy)) / 2)' diff --git a/test/integration/css-units.test.js b/test/integration/css-units.test.js index b4ae730..2ca27da 100644 --- a/test/integration/css-units.test.js +++ b/test/integration/css-units.test.js @@ -155,12 +155,8 @@ describe('Unknown units', () => { describe('Mixed units', () => { test( - 'should correctly reduce calc with mixed units (cssnano#211)', - // zero bucket kept for type info. - testValue( - 'calc(99.99% * 1/1 - 0rem)', - /* '99.99%' */ 'calc(99.99% + calc(-1 * 0rem))' - ) + 'should keep zero with for mixed units for the type info (cssnano#211)', + testValue('calc(99.99% * 1/1 - 0rem)', 'calc(99.99% + calc(-1 * 0rem))') ); test( @@ -176,6 +172,5 @@ describe('Mixed units', () => { test( 'should not perform addition on unitless values (reduce-css-calc#3)', - // canonical order: number before dim. testValue('calc(1px + 1)', 'calc(1px + 1)') ); diff --git a/test/integration/math-operations.test.js b/test/integration/math-operations.test.js index 5f161f8..ffb8025 100644 --- a/test/integration/math-operations.test.js +++ b/test/integration/math-operations.test.js @@ -236,14 +236,12 @@ describe('Math constants', () => { ); test( - 'should perform multiplication with pi', - // fold `pi` (§10.7.1). + 'should perform multiplication with pi (§10.7.1)', testValue('calc(1px * pi)', 'calc(3.14159px)') ); test( - 'should perform addition with pi', - // fold `pi` (§10.7.1). + 'should perform addition with pi (§10.7.1)', testValue('calc(43 + pi)', 'calc(46.14159)') ); }); diff --git a/test/integration/nested-calculations.test.js b/test/integration/nested-calculations.test.js index 71f6dfe..b25ff9e 100644 --- a/test/integration/nested-calculations.test.js +++ b/test/integration/nested-calculations.test.js @@ -84,7 +84,7 @@ describe('Nested calc functions', () => { // spec-style spaces around `*`. testValue( 'calc(calc(var(--foo) + var(--bar)) * var(--baz))', - /* 'calc((var(--foo) + var(--bar))*var(--baz))' */ 'calc((var(--foo) + var(--bar)) * var(--baz))' + 'calc((var(--foo) + var(--bar)) * var(--baz))' ) ); @@ -93,7 +93,7 @@ describe('Nested calc functions', () => { // spec-style spaces around `*`. testValue( 'calc(var(--foo) * calc(var(--bar) + var(--baz)))', - /* 'calc(var(--foo)*(var(--bar) + var(--baz)))' */ 'calc(var(--foo) * (var(--bar) + var(--baz)))' + 'calc(var(--foo) * (var(--bar) + var(--baz)))' ) ); @@ -102,7 +102,7 @@ describe('Nested calc functions', () => { // spec-style spaces around `/`. testValue( 'calc(calc(var(--foo) + var(--bar)) / var(--baz))', - /* 'calc((var(--foo) + var(--bar))/var(--baz))' */ 'calc((var(--foo) + var(--bar)) / var(--baz))' + 'calc((var(--foo) + var(--bar)) / var(--baz))' ) ); @@ -111,7 +111,7 @@ describe('Nested calc functions', () => { // spec-style spaces around `/`. testValue( 'calc(var(--foo) / calc(var(--bar) + var(--baz)))', - /* 'calc(var(--foo)/(var(--bar) + var(--baz)))' */ 'calc(var(--foo) / (var(--bar) + var(--baz)))' + 'calc(var(--foo) / (var(--bar) + var(--baz)))' ) ); diff --git a/test/unit/analyze.test.js b/test/unit/analyze.test.js index 11dd191..f9312a4 100644 --- a/test/unit/analyze.test.js +++ b/test/unit/analyze.test.js @@ -259,22 +259,17 @@ test('reduceCalc: reduces percentage ratios through percentage-preserving builti }); test('reduceCalc: serializes non-finite percentage ratios', () => { - // Both operands resolve in the same percentage context, so the quotient is - // a definite number even though its value cannot be known statically. assert.equal(reduceCalc('calc(0% / 0%)'), 'calc(NaN)'); assert.equal(reduceCalc('calc(10% / 0%)'), 'calc(infinity)'); }); test('reduceCalc: adds a percentage to a reduced percentage ratio', () => { - // The ratio is known to be a number, so the sum keeps only the remaining - // contextual percentage instead of preserving both operands. assert.equal(reduceCalc('calc(10% / 5% + 1%)'), 'calc(2 + 1%)'); assert.deepEqual(analyzeSource('10% / 5% + 1%'), { type: 'number', valid: true, unresolved: true, }); - // The coarse typing still rejects a concrete dimension beside the ratio. assert.deepEqual(analyzeSource('10% / 5% + 1% + 1px'), { type: 'unknown', valid: false, diff --git a/test/unit/node.test.js b/test/unit/node.test.js index 490aa9e..93fed97 100644 --- a/test/unit/node.test.js +++ b/test/unit/node.test.js @@ -40,8 +40,6 @@ describe('mkSum:', () => { }); test('mkSum: single negative-sign Num collapses sign into value', () => { - // Canonical form: sign=-1 around Num becomes sign=+1 around Num(-X). - // And a single positive-sign term unwraps. assert.deepEqual(mkSum([{ sign: -1, node: num(5) }]), { type: 'Num', value: -5, @@ -249,8 +247,7 @@ describe('mkProduct:', () => { }); }); -// --- negate --------------------------------------------------------------- -describe('negate:', () => { +describe('negate', () => { test('negate: Num flips value sign', () => { assert.deepEqual(negate(num(5)), { type: 'Num', value: -5 }); }); diff --git a/test/unit/parser-opaque.test.js b/test/unit/parser-opaque.test.js index 2a55f83..1f4521d 100644 --- a/test/unit/parser-opaque.test.js +++ b/test/unit/parser-opaque.test.js @@ -1,6 +1,3 @@ -// Unit tests for parser.ts — parselet behavior and raw AST shape. -// Distinct from test-simplify: we assert what the PARSER produces, -// before simplify runs. Uses sexpr for compact structural assertions. import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { tokenize } from '@csstools/css-tokenizer'; @@ -148,92 +145,83 @@ describe('parser: opaque expressions and invalid syntax', () => { test('parser: unclosed anchor() throws', () => { assert.throws(() => parseSource('anchor(--foo top'), /Unclosed anchor\(/); }); - // --- Calc-keyword folding ------------------------------------------------- - test('parser: `pi` folds to Math.PI', () => { - assert.equal(ast('pi'), String(Math.PI)); - }); - test('parser: `e` folds to Math.E', () => { - assert.equal(ast('e'), String(Math.E)); - }); - test('parser: `infinity` folds to Infinity', () => { - assert.equal(ast('infinity'), 'Infinity'); - }); - test('parser: calc-keywords are case-insensitive except NaN', () => { - assert.equal(ast('PI'), String(Math.PI)); - assert.equal(ast('Infinity'), 'Infinity'); - }); - test('parser: lowercase `nan` is NOT the keyword (case-sensitive)', () => { - // Spec: `NaN` is the only case-sensitive calc keyword. `nan` as - // lowercase is just an opaque identifier. - assert.equal(ast('nan'), 'nan'); - }); - // --- Strict whitespace around +/- ---------------------------------------- - test('parser: `1px + 2px` is valid', () => { - assert.doesNotThrow(() => parseSource('1px + 2px')); - }); - test('parser: a signed token after whitespace still fails at its position', () => { - assert.throws(() => parseSource('1 +2'), { - message: '"+" must be surrounded by whitespace at position 2', - }); +}); +// --- Calc-keyword folding ------------------------------------------------- +test('parser: `pi` folds to Math.PI', () => { + assert.equal(ast('pi'), String(Math.PI)); +}); +test('parser: `e` folds to Math.E', () => { + assert.equal(ast('e'), String(Math.E)); +}); +test('parser: `infinity` folds to Infinity', () => { + assert.equal(ast('infinity'), 'Infinity'); +}); +test('parser: calc-keywords are case-insensitive except NaN', () => { + assert.equal(ast('PI'), String(Math.PI)); + assert.equal(ast('Infinity'), 'Infinity'); +}); +test('parser: lowercase `nan` is NOT the keyword (case-sensitive)', () => { + // Spec: `NaN` is the only case-sensitive calc keyword. `nan` as + // lowercase is just an opaque identifier. + assert.equal(ast('nan'), 'nan'); +}); +// --- Strict whitespace around +/- ---------------------------------------- +test('parser: `1px + 2px` is valid', () => { + assert.doesNotThrow(() => parseSource('1px + 2px')); +}); +test('parser: a signed token after whitespace still fails at its position', () => { + assert.throws(() => parseSource('1 +2'), { + message: '"+" must be surrounded by whitespace at position 2', }); - test('parser: a unary plus after multiplication has the canonical AST', () => { - assert.deepEqual(parseSource('1 * +2'), { - type: 'Num', - value: 2, - }); +}); +test('parser: a unary plus after multiplication has the canonical AST', () => { + assert.deepEqual(parseSource('1 * +2'), { + type: 'Num', + value: 2, }); - // §10.1: `+` and `-` must be surrounded by whitespace. All three asymmetric - // cases (no/before-only/after-only) must throw the same way. - for (const input of ['1px+2px', '1px +2px', '1px+ 2px']) { - test(`parser: \`${input}\` throws (asymmetric whitespace around +)`, () => { - assert.throws( - () => parseSource(input), - /must be surrounded by whitespace/ - ); +}); +// §10.1: `+` and `-` must be surrounded by whitespace. All three asymmetric +// cases (no/before-only/after-only) must throw the same way. +for (const input of ['1px+2px', '1px +2px', '1px+ 2px']) { + test(`parser: \`${input}\` throws (asymmetric whitespace around +)`, () => { + assert.throws(() => parseSource(input), /must be surrounded by whitespace/); + }); +} +test('parser: asymmetric additive whitespace reports the operator position', () => { + for (const [input, position] of [ + ['1px+2px', 3], + ['1px +2px', 4], + ['1px+ 2px', 3], + ]) { + assert.throws(() => parseSource(input), { + message: `"+" must be surrounded by whitespace at position ${position}`, }); } - test('parser: asymmetric additive whitespace reports the operator position', () => { - for (const [input, position] of [ - ['1px+2px', 3], - ['1px +2px', 4], - ['1px+ 2px', 3], - ]) { - assert.throws(() => parseSource(input), { - message: `"+" must be surrounded by whitespace at position ${position}`, - }); - } - }); - test('parser: * / do not require whitespace (spec allows both)', () => { - const productTokens = tokenize({ css: '1px*2' }); - assert.doesNotThrow(() => - parse(productTokens, 0, productTokens.length, indexBlocks(productTokens)) - ); - const quotientTokens = tokenize({ css: '1px/2' }); - assert.doesNotThrow(() => - parse( - quotientTokens, - 0, - quotientTokens.length, - indexBlocks(quotientTokens) - ) - ); - }); - test('parser: tab / newline satisfy the §10.1 whitespace rule', () => { - // The whitespace check inspects the token's `ws` flag, which the - // tokenizer sets for any character matched by /\s/ — tab and newline - // count just like a space. (sexpr shows the raw parse, no folding.) - assert.equal(ast('1\t+\n2'), '(+ 1 2)'); - assert.equal(ast('1\n-\t2'), '(+ 1 -2)'); - }); - test('parser: comments satisfy the §10.1 whitespace rule', () => { - assert.equal(ast('1px/* gap */+/* gap */2px'), '(+ 1px 2px)'); - assert.equal(ast('/* gap */1px + 2px'), '(+ 1px 2px)'); - }); - test('parser: lookahead preserves comment-derived whitespace when consumed', () => { - assert.equal(ast('1 /* before */ + /* after */ 2'), '(+ 1 2)'); - assert.equal(ast('1 /* before */ - /* after */ 2'), '(+ 1 -2)'); - }); - // --- Error positions ------------------------------------------------------ +}); +test('parser: * / do not require whitespace (spec allows both)', () => { + const productTokens = tokenize({ css: '1px*2' }); + assert.doesNotThrow(() => + parse(productTokens, 0, productTokens.length, indexBlocks(productTokens)) + ); + const quotientTokens = tokenize({ css: '1px/2' }); + assert.doesNotThrow(() => + parse(quotientTokens, 0, quotientTokens.length, indexBlocks(quotientTokens)) + ); +}); +test('parser: tab / newline satisfy the §10.1 whitespace rule', () => { + assert.equal(ast('1\t+\n2'), '(+ 1 2)'); + assert.equal(ast('1\n-\t2'), '(+ 1 -2)'); +}); +test('parser: comments satisfy the §10.1 whitespace rule', () => { + assert.equal(ast('1px/* gap */+/* gap */2px'), '(+ 1px 2px)'); + assert.equal(ast('/* gap */1px + 2px'), '(+ 1px 2px)'); +}); +test('parser: lookahead preserves comment-derived whitespace when consumed', () => { + assert.equal(ast('1 /* before */ + /* after */ 2'), '(+ 1 2)'); + assert.equal(ast('1 /* before */ - /* after */ 2'), '(+ 1 -2)'); +}); + +describe('Error positions', () => { test('parser: trailing operator throws (whitespace-before-EOF fails)', () => { // `1 +` has space before `+` but nothing after — EOF has ws=false, so // the strict-whitespace check fires before the unexpected-token path. @@ -248,22 +236,26 @@ describe('parser: opaque expressions and invalid syntax', () => { test('parser: stacked operators throw', () => { assert.throws(() => parseSource('1 * * 2'), /Unexpected token/); }); - // --- expect() failures (unclosed groups) ----------------------------------- - test('parser: unclosed paren throws with expected-token message', () => { - assert.throws(() => parseSource('(1 + 2'), /Expected \)/); - }); +}); - test('parser: unclosed call throws with expected-token message', () => { - assert.throws(() => parseSource('min(1, 2'), /Expected \)/); - }); +// --- expect() failures (unclosed groups) ----------------------------------- +test('parser: unclosed paren throws with expected-token message', () => { + assert.throws(() => parseSource('(1 + 2'), /Expected \)/); +}); - test('parser: unclosed var throws with unclosed message and position', () => { - const tokens = tokenize({ css: 'var(--foo' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Unclosed var\( at position 4/ - ); - }); +test('parser: unclosed call throws with expected-token message', () => { + assert.throws(() => parseSource('min(1, 2'), /Expected \)/); +}); + +test('parser: unclosed var throws with unclosed message and position', () => { + const tokens = tokenize({ css: 'var(--foo' }); + assert.throws( + () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), + /Unclosed var\( at position 4/ + ); +}); + +describe('Trailing tokens', () => { // --- Trailing tokens ------------------------------------------------------ test('parse: rejects input with trailing tokens after a complete expression', () => { assert.throws(() => parseSource('1 2'), /Unexpected token/); diff --git a/test/unit/plugin-traversal.test.js b/test/unit/plugin-traversal.test.js index 22fad3e..1b85f1d 100644 --- a/test/unit/plugin-traversal.test.js +++ b/test/unit/plugin-traversal.test.js @@ -30,8 +30,6 @@ describe('plugin: option combinations', () => { }); test('plugin: selectors:true reduces calc() in selector text', async () => { - // Walking the selector surfaces calc() function nodes that aren't safely - // buried in attribute-value strings. const { css } = await process('a:nth-child(calc(1 + 2)) { b: c }', { selectors: true, }); @@ -182,15 +180,14 @@ describe('plugin: bare math functions', () => { }); // --- Source-range preservation ------------------------------------------ -// The outer traversal only replaces matched source ranges. These tests pin -// down that content having nothing to do with calc() remains byte-for-byte -// unchanged. -test('plugin: IE backslash hack survives the outer walk untouched', async () => { - const { css } = await process('a{width:calc(1px + 2px)\\9}'); - assert.equal(css, 'a{width:calc(3px)\\9}'); -}); - +// These tests pin down that content having nothing to do with calc() remains +// byte-for-byte unchanged. describe('plugin: escaped and opaque content', () => { + test('plugin: IE backslash hack survives the outer walk untouched', async () => { + const { css } = await process('a{width:calc(1px + 2px)\\9}'); + assert.equal(css, 'a{width:calc(3px)\\9}'); + }); + test('plugin: escaped content value survives the outer walk untouched', async () => { const { css } = await process('a{content:"\\e901"}'); assert.equal(css, 'a{content:"\\e901"}'); diff --git a/test/unit/reduceCalc-options.test.js b/test/unit/reduceCalc-options.test.js index 3081d3d..1a204b6 100644 --- a/test/unit/reduceCalc-options.test.js +++ b/test/unit/reduceCalc-options.test.js @@ -8,7 +8,6 @@ import { createReduceCalcTestHarness } from '../helpers/reduceCalc.js'; const { reduceWithWarnings, assertIdempotent } = createReduceCalcTestHarness(reduceCalc); -// --- precision ----------------------------------------------------------- describe('reduceCalc: precision', () => { test('reduceCalc: precision option applies to numeric output', () => { assert.equal( @@ -75,7 +74,6 @@ describe('reduceCalc: precision', () => { }); }); -// --- Option combinations ------------------------------------------------- describe('reduceCalc: option combinations', () => { test('reduceCalc: onParseError catches errors in a media-query param string', () => { const errors = []; @@ -121,8 +119,7 @@ describe('reduceCalc: option combinations', () => { }); }); -// --- Bare math functions (issue #189) ----------------------------------- -describe('reduceCalc: bare math functions', () => { +describe('reduceCalc: bare math functions (issue #189)', () => { test('reduceCalc: simplifies bare min() outside of calc()', () => { assert.equal( reduceCalc('min(360px, 100% - 24px - 24px)'), diff --git a/test/unit/serialize-core.test.js b/test/unit/serialize-core.test.js index d5593b4..8f7e0c6 100644 --- a/test/unit/serialize-core.test.js +++ b/test/unit/serialize-core.test.js @@ -64,8 +64,6 @@ describe('serialize: core syntax and expressions', () => { }); 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)'); }); @@ -142,8 +140,6 @@ describe('serialize: core syntax and expressions', () => { }); 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 } }, @@ -152,7 +148,6 @@ describe('serialize: core syntax and expressions', () => { }); 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' } }, @@ -210,8 +205,6 @@ describe('serialize: core syntax and expressions', () => { }); 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') }],