From 498b873027966eb8ecc817d642c361d9086c7521 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Sat, 19 Sep 2026 22:46:38 +0200 Subject: [PATCH 1/5] fix: improve rounding algorithm --- src/lib/serialize.js | 98 ++++++++++++++++++---------- test/unit/reduceCalc-options.test.js | 40 ++++++++++++ test/unit/serialize.test.js | 53 +++++++++++++++ 3 files changed, 155 insertions(+), 36 deletions(-) diff --git a/src/lib/serialize.js b/src/lib/serialize.js index fc32c98..be53784 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -29,14 +29,67 @@ const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE; const NOISE_FLOOR = 1e-12; /** - * Decimal rounding with "round half away from zero" (e.g. 1.005 at precision 2 -> 1.01). + * Divide a decimal digit string by 10^k, rounding half away from zero, and + * return the resulting integer digit string. `digits` has no leading zeros. + * @param {string} digits + * @param {number} k + * @return {string} + */ +function divideByPowerOfTen(digits, k) { + // 0x30/0x35/0x39 are the char codes of '0'/'5'/'9'. + if (digits.length <= k) { + return digits.length === k && digits.charCodeAt(0) >= 0x35 ? '1' : '0'; + } + const cut = digits.length - k; + if (digits.charCodeAt(cut) < 0x35) return digits.slice(0, cut); + // Round up and propagate the carry through trailing nines. + let index = cut - 1; + while (index >= 0 && digits.charCodeAt(index) === 0x39) index--; + if (index < 0) return `1${'0'.repeat(cut)}`; + return `${digits.slice(0, index)}${String.fromCharCode( + digits.charCodeAt(index) + 1 + )}${'0'.repeat(cut - index - 1)}`; +} + +/** + * Round the shortest decimal representation of a non-negative double to `p` + * fractional digits, half away from zero. * - * Binary floating-point (IEEE-754) cannot represent many decimal fractions exactly - * (e.g. 1.005 is binary 1.004999999999999893...), causing arithmetic formulas like - * `Math.round(v * 100) / 100` to round down to 1.00. Exponential notation string shifting - * (`1.005e2` -> `100.5`) lets the ECMAScript string-to-number parser read the exact - * intended decimal value before rounding. + * `Number(text + 'e' + p)` reads the exact intended decimal (so `1.005` at + * precision 2 becomes `1.01`), but it is only exact while the shifted value + * fits in `Number.MAX_SAFE_INTEGER`; beyond that the intermediate double + * rounds and can move the rounding boundary (e.g. `312834450754803.44` at + * precision 1 or 6 drifted to `312834450754803.5`). Round the decimal digits + * directly instead. * + * @param {number} abs + * @param {number} p + * @return {number} + */ +function roundDecimal(abs, p) { + const text = String(abs); + const eIdx = text.indexOf('e'); + const mantissa = eIdx === -1 ? text : text.slice(0, eIdx); + let exponent = eIdx === -1 ? 0 : Number(text.slice(eIdx + 1)); + const dot = mantissa.indexOf('.'); + let digits = mantissa; + if (dot !== -1) { + digits = mantissa.slice(0, dot) + mantissa.slice(dot + 1); + exponent -= mantissa.length - dot - 1; + } + + // value = digits * 10^exponent, so the shortest decimal has -exponent + // fractional digits when it is smaller than 1. + if (exponent >= -p) return abs; + + let start = 0; + while (start < digits.length - 1 && digits.charCodeAt(start) === 0x30) + start++; + const rounded = divideByPowerOfTen(digits.slice(start), -(exponent + p)); + return Number(`${rounded}e-${p}`); +} + +/** * @param {number} v * @param {number | false} prec * @return {number} @@ -53,36 +106,9 @@ function round(v, prec) { // or exponent overflows into Infinity/NaN (e.g. exponent + prec > 308). const p = Math.min(100, Math.max(0, Math.trunc(prec))); const sign = v < 0 ? -1 : 1; - let rounded; - - if (p === 0) { - // Fast path: rounding to integer with "round half away from zero". - rounded = sign * Math.round(abs); - } else { - // Avoid .split('e') allocations: for numbers between 1e-6 and MAX_SAFE_INTEGER, - // String(abs) never contains exponential notation ('e'). - const absStr = String(abs); - const eIdx = absStr.indexOf('e'); - let shifted; - if (eIdx === -1) { - shifted = Math.round(Number(absStr + 'e' + p)); - } else { - const mantissa = absStr.slice(0, eIdx); - const exponent = Number(absStr.slice(eIdx + 1)); - shifted = Math.round(Number(mantissa + 'e' + (exponent + p))); - } - - // shifted is an integer. It only contains exponential notation ('e') if >= 1e21. - if (shifted >= 1e21) { - const shiftedStr = String(shifted); - const seIdx = shiftedStr.indexOf('e'); - const sMantissa = shiftedStr.slice(0, seIdx); - const sExponent = Number(shiftedStr.slice(seIdx + 1)); - rounded = sign * Number(sMantissa + 'e' + (sExponent - p)); - } else { - rounded = sign * Number(shifted + 'e-' + p); - } - } + // Fast path: rounding to integer with "round half away from zero". + const rounded = + p === 0 ? sign * Math.round(abs) : sign * roundDecimal(abs, p); // Preserve non-zero values smaller than precision (e.g. 1/1000000) from collapsing // to zero, while still snapping true floating-point dust (< 1e-12) to zero. diff --git a/test/unit/reduceCalc-options.test.js b/test/unit/reduceCalc-options.test.js index 3fe9751..a0dd980 100644 --- a/test/unit/reduceCalc-options.test.js +++ b/test/unit/reduceCalc-options.test.js @@ -27,6 +27,46 @@ describe('reduceCalc: precision', () => { test('reduceCalc: precision 0 rounds to whole numbers', () => { assert.equal(reduceCalc('calc(1in + 10px)', { precision: 0 }), 'calc(1in)'); }); + + test('reduceCalc: precision rounds large fractional results without drift', () => { + // The serializer previously perturbed the rounding boundary for shifted + // values beyond Number.MAX_SAFE_INTEGER. + assert.equal( + reduceCalc('calc(312834450754803.44 + 0)', { precision: 1 }), + 'calc(312834450754803.4)' + ); + assert.equal( + reduceCalc('calc(312834450754803.44 + 0)', { precision: 6 }), + 'calc(312834450754803.44)' + ); + }); + + test('reduceCalc: precision beyond the shortest representation leaves the value unchanged', () => { + // Rounding finer than the shortest decimal representation must not drift + // the value; differential testing found drift here before. + assert.equal( + reduceCalc('calc(7341.0297734398655 + 0)', { precision: 14 }), + 'calc(7341.0297734398655)' + ); + }); + + test('reduceCalc: precision rounding carries through all nines', () => { + assert.equal( + reduceCalc('calc(999.995 + 0)', { precision: 2 }), + 'calc(1000)' + ); + assert.equal( + reduceCalc('calc(999999999999.995 + 0)', { precision: 2 }), + 'calc(1000000000000)' + ); + }); + + test('reduceCalc: precision rounds sub-1 midpoints away from zero', () => { + assert.equal(reduceCalc('calc(0.05 + 0)', { precision: 1 }), 'calc(.1)'); + assert.equal(reduceCalc('calc(0.005 + 0)', { precision: 2 }), 'calc(.01)'); + // 0.004 rounds to zero at 1 place but is above the noise floor. + assert.equal(reduceCalc('calc(0.004 + 0)', { precision: 1 }), 'calc(.004)'); + }); }); // --- Option combinations ------------------------------------------------- diff --git a/test/unit/serialize.test.js b/test/unit/serialize.test.js index 16e0609..6f4261f 100644 --- a/test/unit/serialize.test.js +++ b/test/unit/serialize.test.js @@ -150,6 +150,59 @@ describe('serialize: numbers', () => { assert.equal(serialize(num(-1.000005), { precision: 5 }), 'calc(-1.00001)'); }); + test('serialize: rounds large fractional magnitudes without float drift', () => { + // Scaling through Number(text + 'e' + p) loses the rounding boundary once + // the shifted value exceeds Number.MAX_SAFE_INTEGER. + assert.equal( + serialize(num(312834450754803.44), { precision: 1 }), + 'calc(312834450754803.4)' + ); + assert.equal( + serialize(num(312834450754803.44), { precision: 6 }), + 'calc(312834450754803.44)' + ); + assert.equal( + serialize(dim(-312834450754803.44, 'px'), { precision: 1 }), + 'calc(-312834450754803.4px)' + ); + assert.equal( + serialize(num(39969.492943459234), { precision: 11 }), + 'calc(39969.49294345923)' + ); + }); + + test('serialize: carries a rounding carry through trailing nines', () => { + // Rounding up 999.995 must propagate the carry across all nines to 1000. + assert.equal(serialize(num(999.995), { precision: 2 }), 'calc(1000)'); + assert.equal(serialize(num(-999.995), { precision: 2 }), 'calc(-1000)'); + // All-nines carry combined with digit-string rounding beyond the safe + // shift range. + assert.equal( + serialize(num(999999999999.995), { precision: 2 }), + 'calc(1000000000000)' + ); + }); + + test('serialize: rounds sub-1 midpoints away from zero and preserves sub-precision values', () => { + assert.equal(serialize(num(0.05), { precision: 1 }), 'calc(.1)'); + assert.equal(serialize(num(-0.05), { precision: 1 }), 'calc(-.1)'); + assert.equal(serialize(num(0.005), { precision: 2 }), 'calc(.01)'); + // 0.004 rounds to zero at 1 place but exceeds the noise floor, so the + // value is preserved rather than collapsed to 0. + assert.equal(serialize(num(0.004), { precision: 1 }), 'calc(.004)'); + assert.equal(serialize(num(-0.004), { precision: 1 }), 'calc(-.004)'); + }); + + test('serialize: leaves values unchanged when precision exceeds the shortest representation', () => { + // The shortest decimal of 7341.0297734398655 has 14 fractional digits, + // so rounding at precision 14 must return the value untouched instead of + // rescaling through digit strings. + assert.equal( + serialize(num(7341.0297734398655), { precision: 14 }), + 'calc(7341.0297734398655)' + ); + }); + test('serialize: precision 0 rounds to integers away from zero', () => { assert.equal(serialize(num(1.5), { precision: 0 }), 'calc(2)'); assert.equal(serialize(num(-1.5), { precision: 0 }), 'calc(-2)'); From 99d5f2bb5cda33894e2b19b6e0829051e8d27036 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Sat, 19 Sep 2026 22:50:28 +0200 Subject: [PATCH 2/5] refactor: remove old internal compatiblity shim --- src/lib/calculation-type.js | 25 ------------------------- test/property/properties.test.js | 3 +-- test/unit/analyze.test.js | 5 ----- 3 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 src/lib/calculation-type.js diff --git a/src/lib/calculation-type.js b/src/lib/calculation-type.js deleted file mode 100644 index 6d6092e..0000000 --- a/src/lib/calculation-type.js +++ /dev/null @@ -1,25 +0,0 @@ -// Compatibility facade for the former calculation-type module. New code uses -// analyze() for the complete result and limits.js for depth policy. - -import { analyze } from './analyze.js'; -import { MAX_CALCULATION_DEPTH, checkCalculationDepth } from './limits.js'; - -/** @typedef {import('./node.js').Node} Node */ - -/** @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown'} | {kind: 'failure'}} CalculationType */ - -/** @param {Node} node @return {CalculationType} */ -function checkCalculationType(node) { - const result = analyze(node); - if (!result.valid) return { kind: 'failure' }; - if (result.type === 'number') return { kind: 'number' }; - if (result.type === 'unknown') return { kind: 'unknown' }; - return { kind: 'dimension', base: result.type.dimension }; -} - -export { - MAX_CALCULATION_DEPTH, - checkCalculationDepth, - checkCalculationType, - analyze, -}; diff --git a/test/property/properties.test.js b/test/property/properties.test.js index 4762187..810cba1 100644 --- a/test/property/properties.test.js +++ b/test/property/properties.test.js @@ -16,7 +16,6 @@ import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { analyze } from '../../src/lib/analyze.js'; import { serialize } from '../../src/lib/serialize.js'; -import { checkCalculationType } from '../../src/lib/calculation-type.js'; import { astArb, astArbWithDegenerate, @@ -152,7 +151,7 @@ test('property: -(-x) ≡ simplify(x)', () => { // The structural generator intentionally combines arbitrary dimensions, // which can otherwise produce invalid sums such as `-0 + 0px`. fc.property( - astArb(3).filter((ast) => checkCalculationType(ast).kind !== 'failure'), + astArb(3).filter((ast) => analyze(ast).valid), (ast) => { const doubleNeg = negate(negate(ast)); const lhs = simplify(doubleNeg); diff --git a/test/unit/analyze.test.js b/test/unit/analyze.test.js index 85b8501..c4e3717 100644 --- a/test/unit/analyze.test.js +++ b/test/unit/analyze.test.js @@ -1,7 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { analyze } from '../../src/lib/analyze.js'; -import { checkCalculationType } from '../../src/lib/calculation-type.js'; import { call, num } from '../../src/lib/node.js'; import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; @@ -277,10 +276,6 @@ test('analyze: enforces the calculation depth limit', () => { () => analyze(tree), /Calculation nesting exceeds the limit of 1024/ ); - assert.throws( - () => checkCalculationType(tree), - /Calculation nesting exceeds the limit of 1024/ - ); }); test('analyze: treats inherited object names as unknown functions', () => { From 0d73daf70354432c34628faf3bca004d94d6a6c9 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Sat, 19 Sep 2026 23:37:18 +0200 Subject: [PATCH 3/5] refactor: clean up handling of negative 0 --- src/lib/serialize.js | 101 +++++++++++++++----------- test/unit/reduceCalc-core.test.js | 115 ++++++++++++++++++++++++++++++ test/unit/serialize.test.js | 74 +++++++++++++++++++ 3 files changed, 247 insertions(+), 43 deletions(-) diff --git a/src/lib/serialize.js b/src/lib/serialize.js index be53784..e252527 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -250,15 +250,13 @@ function needsParentheses(node, parentPrecedence, groupedRequired) { * @param {ReturnType} session * @param {number} [parentPrecedence] * @param {boolean} [groupedRequired] - * @param {number} [scalarValueOverride] * @return {void} */ function emitNode( node, session, parentPrecedence = 0, - groupedRequired = false, - scalarValueOverride + groupedRequired = false ) { const parenthesized = needsParentheses( node, @@ -266,22 +264,21 @@ function emitNode( groupedRequired ); if (parenthesized) session.buffer.push('('); - emitNodeBody(node, session, scalarValueOverride); + emitNodeBody(node, session); if (parenthesized) session.buffer.push(')'); } /** * @param {Node} node * @param {ReturnType} session - * @param {number} [scalarValueOverride] * @return {void} */ -function emitNodeBody(node, session, scalarValueOverride) { +function emitNodeBody(node, session) { const buffer = session.buffer; switch (node.type) { case 'Num': case 'Dim': - emitScalar(node, session, scalarValueOverride); + emitScalar(node, session); return; case 'Ident': buffer.push(node.rawName ?? node.name); @@ -332,18 +329,31 @@ function emitOpaqueCall(node, session, callNameOverride) { buffer.push(')'); } +/** + * Whether a scalar node is strictly negative after precision rounding + * (excluding signed zero and sub-precision values that round to zero). + * @param {Node} node + * @param {number | false} precision + * @return {node is import('./node.js').Num | import('./node.js').Dim} + */ +function isEffectivelyNegative(node, precision) { + return ( + isScalar(node) && + !Object.is(node.value, -0) && + Number.isFinite(node.value) && + round(node.value, precision) < 0 + ); +} + /** * @param {import('./node.js').SumTerm} term * @param {1 | -1} multiplier + * @param {number | false} precision * @return {1 | -1} - * */ -function termSign(term, multiplier) { + */ +function termSign(term, multiplier, precision) { let sign = /** @type {1 | -1} */ (term.sign * multiplier); - if ( - isScalar(term.node) && - Number.isFinite(term.node.value) && - term.node.value < 0 - ) { + if (isEffectivelyNegative(term.node, precision)) { sign = /** @type {1 | -1} */ (-sign); } return sign; @@ -353,14 +363,13 @@ function termSign(term, multiplier) { * @param {import('./node.js').SumTerm} term * @param {ReturnType} session * @param {1 | -1} sign - * @param {number | undefined} scalarValueOverride * @return {void} */ -function emitSumTerm(term, session, sign, scalarValueOverride) { +function emitSumTerm(term, session, sign) { if (sign === 1) { - emitNode(term.node, session, SUM_PRECEDENCE, true, scalarValueOverride); + emitNode(term.node, session, SUM_PRECEDENCE, true); } else { - emitLeadingNeg(term.node, session, scalarValueOverride); + emitLeadingNeg(term.node, session); } } @@ -375,25 +384,38 @@ function emitSumTerms(terms, session, multiplier = 1) { for (let i = 0; i < terms.length; i++) { const term = terms[i]; const termNode = term.node; - const scalar = isScalar(termNode); - const negativeScalar = - scalar && Number.isFinite(termNode.value) && termNode.value < 0; - let sign = /** @type {1 | -1} */ (term.sign * multiplier); - if (negativeScalar) sign = /** @type {1 | -1} */ (-sign); - const scalarValueOverride = negativeScalar ? -termNode.value : undefined; - if (i === 0) { - if (scalar) { - if (sign === -1) buffer.push('-'); - emitScalar(termNode, session, scalarValueOverride); + if (isScalar(termNode)) { + const effectiveVal = term.sign * multiplier * termNode.value; + if (Object.is(effectiveVal, -0)) { + if (i > 0) buffer.push(' + '); + emitSignedZero(buffer, termNode); + } else if (isDegenerate(effectiveVal)) { + const sign = /** @type {1 | -1} */ (term.sign * multiplier); + if (i === 0) { + if (sign === -1) buffer.push('-'); + emitScalar(termNode, session); + } else { + buffer.push(sign === 1 ? ' + ' : ' - '); + emitScalar(termNode, session); + } } else { - emitSumTerm(term, session, sign, scalarValueOverride); + const rounded = round(effectiveVal, session.precision); + if (rounded < 0) { + if (i === 0) buffer.push('-'); + else buffer.push(' - '); + emitRoundedScalar(termNode, buffer, -rounded); + } else { + if (i > 0) buffer.push(' + '); + emitRoundedScalar(termNode, buffer, rounded); + } } continue; } - buffer.push(sign === 1 ? ' + ' : ' - '); - if (scalar) { - emitScalar(termNode, session, scalarValueOverride); + const sign = /** @type {1 | -1} */ (term.sign * multiplier); + if (i === 0) { + emitSumTerm(term, session, sign); } else { + buffer.push(sign === 1 ? ' + ' : ' - '); emitNode(termNode, session, SUM_PRECEDENCE, true); } } @@ -407,10 +429,9 @@ function emitSum(sum, session) { /** * @param {Node} node * @param {ReturnType} session - * @param {number} [scalarValueOverride] * @return {void} */ -function emitLeadingNeg(node, session, scalarValueOverride) { +function emitLeadingNeg(node, session) { if ( node.type === 'Product' && node.factors.length > 0 && @@ -424,13 +445,7 @@ function emitLeadingNeg(node, session, scalarValueOverride) { return; } session.buffer.push('-'); - emitNode( - node, - session, - UNARY_PRECEDENCE, - false, - isScalar(node) ? scalarValueOverride : undefined - ); + emitNode(node, session, UNARY_PRECEDENCE, false); } /** @@ -485,7 +500,7 @@ function emitRootExpr(node, session) { node.type === 'Sum' && node.grouped && node.terms.length > 1 && - termSign(node.terms[0], 1) === -1 + termSign(node.terms[0], 1, session.precision) === -1 ) { session.buffer.push('-('); emitSumTerms(node.terms, session, -1); @@ -526,7 +541,7 @@ function emitMathResult(node, session, wrapper) { node.type === 'Sum' && node.grouped && node.terms.length > 1 && - termSign(node.terms[0], 1) === -1 + termSign(node.terms[0], 1, session.precision) === -1 ) { session.buffer.push(wrapper, '(-('); emitSumTerms(node.terms, session, -1); diff --git a/test/unit/reduceCalc-core.test.js b/test/unit/reduceCalc-core.test.js index 844c7dc..d5e320c 100644 --- a/test/unit/reduceCalc-core.test.js +++ b/test/unit/reduceCalc-core.test.js @@ -150,6 +150,121 @@ describe('reduceCalc: basic pipeline', () => { ); }); + describe('reduceCalc: avoids negative zero serialization in sums and grouped sums', () => { + test('sub-precision negative number term serializes as 0 in unresolved sum', () => { + assert.equal(reduceCalc('calc(var(--x) - 1e-20)'), 'calc(0 + var(--x))'); + }); + + test('sub-precision negative dimension term serializes as 0 in unresolved sum', () => { + assert.equal( + reduceCalc('calc(var(--x) - 1e-20px)'), + 'calc(0px + var(--x))' + ); + }); + + test('sub-precision negative dimension term preserves unit alongside resolvable term', () => { + assert.equal(reduceCalc('calc(1em - 1e-20px)'), 'calc(1em + 0px)'); + }); + + test('leading sub-precision negative number term serializes as 0', () => { + assert.equal( + reduceCalc('calc((-1e-20 + var(--x)))'), + 'calc(0 + var(--x))' + ); + }); + + test('negated grouped sum with leading sub-precision negative term serializes with positive sign', () => { + assert.equal( + reduceCalc('calc(-(-1e-20 + var(--x)))'), + 'calc(-(0 + var(--x)))' + ); + }); + + test('negated grouped sum with non-leading sub-precision negative term serializes with positive sign', () => { + assert.equal( + reduceCalc('calc((-10px + var(--x) - 1e-20em))'), + 'calc(-(10px + 0em - var(--x)))' + ); + }); + + test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { + assert.equal( + reduceCalc('calc((-10px + var(--x) + 1e-20em))'), + 'calc(-(10px + 0em - var(--x)))' + ); + }); + + test('precision: false retains sub-precision negative number term', () => { + assert.equal( + reduceCalc('calc(var(--x) - 1e-20)', { precision: false }), + 'calc(-1e-20 + var(--x))' + ); + }); + + test('precision: false retains grouped negative sum inversion', () => { + assert.equal( + reduceCalc('calc((-1e-20 + var(--x)))', { precision: false }), + 'calc(-(1e-20 - var(--x)))' + ); + }); + + test('threshold survivor above noise floor retains negative sign', () => { + assert.equal( + reduceCalc('calc(var(--x) - 1e-10)'), + 'calc(-1e-10 + var(--x))' + ); + }); + + test('sufficient precision retains tiny negative term', () => { + assert.equal( + reduceCalc('calc(var(--x) - 1e-20)', { precision: 20 }), + 'calc(-1e-20 + var(--x))' + ); + }); + + test('precision: 0 preserves non-zero value above noise floor', () => { + assert.equal( + reduceCalc('calc(var(--x) - 0.4)', { precision: 0 }), + 'calc(-.4 + var(--x))' + ); + }); + + test('nested math-function call argument avoids negative zero serialization', () => { + assert.equal( + reduceCalc('min(-1e-20 + var(--x), 1)'), + 'min(0 + var(--x), 1)' + ); + }); + + test('arithmetic signed zero is preserved with default precision', () => { + assert.equal( + reduceCalc('calc(0 / -1 * var(--x))'), + 'calc(calc(-1 * 0) * var(--x))' + ); + }); + + test('arithmetic signed zero dimension is preserved under subtraction', () => { + assert.equal( + reduceCalc('calc(1em - 1px * 0)'), + 'calc(1em + calc(-1 * 0px))' + ); + }); + + test('negated grouped sum serializes a negated positive zero term as arithmetic negative zero', () => { + assert.equal( + reduceCalc('calc((-1em + var(--x) + 0px))'), + 'calc(-(1em + calc(-1 * 0px) - var(--x)))' + ); + }); + + test('negated grouped sum serializes a negated negative zero term as positive zero', () => { + assert.equal( + reduceCalc('calc((-1em + var(--x) - 0px))'), + 'calc(-(1em + 0px - var(--x)))' + ); + }); + }); + test('reduceCalc: unwrapSingleNegativeNumber aliases unwrapSingleValue', () => { assert.equal( reduceCalc('a:nth-child(calc(1 - 2))', { diff --git a/test/unit/serialize.test.js b/test/unit/serialize.test.js index 6f4261f..5b2e9d9 100644 --- a/test/unit/serialize.test.js +++ b/test/unit/serialize.test.js @@ -294,6 +294,80 @@ describe('serialize: numbers', () => { ); }); + describe('serialize: sub-precision negative terms in sums and grouped sums', () => { + test('sub-precision negative number term serializes as 0 in sums', () => { + const ast = mkSum([ + { sign: 1, node: num(-1e-20) }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ]); + assert.equal(serialize(ast), 'calc(0 + var(--x))'); + }); + + test('sub-precision negative number term with precision: false retains negative value in sums', () => { + const ast = mkSum([ + { sign: 1, node: num(-1e-20) }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ]); + assert.equal( + serialize(ast, { precision: false }), + 'calc(-1e-20 + var(--x))' + ); + }); + + test('sub-precision negative number term serializes as 0 in grouped sums', () => { + const ast = { + type: /** @type {const} */ ('Sum'), + grouped: true, + terms: [ + { sign: 1, node: num(-1e-20) }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ], + }; + assert.equal(serialize(ast), 'calc(0 + var(--x))'); + }); + + test('sub-precision negative number term with precision: false retains grouped negative sum inversion', () => { + const ast = { + type: /** @type {const} */ ('Sum'), + grouped: true, + terms: [ + { sign: 1, node: num(-1e-20) }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ], + }; + assert.equal( + serialize(ast, { precision: false }), + 'calc(-(1e-20 - var(--x)))' + ); + }); + + test('negated grouped sum with non-leading sub-precision negative term serializes with positive sign', () => { + const ast = { + type: /** @type {const} */ ('Sum'), + grouped: true, + terms: [ + { sign: 1, node: dim(-10, 'px') }, + { sign: 1, node: dim(-1e-20, 'em') }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ], + }; + assert.equal(serialize(ast), 'calc(-(10px + 0em - var(--x)))'); + }); + + test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { + const ast = { + type: /** @type {const} */ ('Sum'), + grouped: true, + terms: [ + { sign: 1, node: dim(-10, 'px') }, + { sign: 1, node: dim(1e-20, 'em') }, + { sign: 1, node: opaqueCall('var', [ident('--x')]) }, + ], + }; + assert.equal(serialize(ast), 'calc(-(10px + 0em - var(--x)))'); + }); + }); + test('serialize: custom calcName', () => { const ast = mkSum([ { sign: 1, node: dim(1, 'px') }, From aff3e7e2ea8e12c3a0190ab29a5fe954ea2fb28c Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Sun, 20 Sep 2026 02:10:19 +0200 Subject: [PATCH 4/5] fix: handle unary prefix - as invalid in more cases --- src/lib/node.js | 2 +- src/lib/parser.js | 18 ++--- src/lib/serialize.js | 43 ++++++------ test/helpers/css-math-arbitraries.js | 4 +- test/property/opaque-grouping.test.js | 8 ++- test/unit/corpus-selection.test.js | 12 +++- test/unit/parser-core.test.js | 35 ++++++---- test/unit/parser-opaque.test.js | 37 +---------- test/unit/plugin-core.test.js | 21 +++++- test/unit/reduceCalc-core.test.js | 42 +++++++++--- test/unit/serialize.test.js | 96 +++++++++++++++++++++++---- test/unit/simplify/sum.test.js | 11 +-- 12 files changed, 213 insertions(+), 116 deletions(-) diff --git a/src/lib/node.js b/src/lib/node.js index b4fe5c7..1e49306 100644 --- a/src/lib/node.js +++ b/src/lib/node.js @@ -215,7 +215,7 @@ function negate(node) { } if (node.type === 'Sum') { // A grouped sum may contain opaque terms whose meaning depends on the - // surrounding context. Keep the group intact so `-(a + b)` cannot turn + // surrounding context. Keep the group intact so `-1 * (a + b)` cannot turn // into `-a - b` while it is still unresolved. if (node.grouped) { return mkSum([{ sign: -1, node }]); diff --git a/src/lib/parser.js b/src/lib/parser.js index 09db2ca..80e3514 100644 --- a/src/lib/parser.js +++ b/src/lib/parser.js @@ -1,16 +1,7 @@ // Pratt parser over native @csstools/css-tokenizer tokens. import { TokenType as CssType } from '@csstools/css-tokenizer'; import { baseOf } from './convertUnits.js'; -import { - call, - dim, - ident, - mkProduct, - mkSum, - negate, - num, - opaqueCall, -} from './node.js'; +import { call, dim, ident, mkProduct, mkSum, num, opaqueCall } from './node.js'; import { isCalculationFunction, isSupportedMathFunction } from './functions.js'; import { assertDepth } from './limits.js'; import { CSS_NUMBER_PREFIX } from './regex.js'; @@ -293,11 +284,10 @@ function parsePrefix(input, cursor, token, depth) { ? { ...expression, grouped: true } : expression; } - case '-': - return negate(parseExpr(input, cursor, 7, depth + 1)); - case '+': - return parseExpr(input, cursor, 7, depth + 1); } + // No unary `+`/`-` production exists in the grammar; a + // sign is only valid inside a number/dimension token or as a binary + // operator. `-(...)` therefore fails to parse and is preserved. } throw new Error(`Unexpected token "${token.raw}" at position ${token.pos}`); } diff --git a/src/lib/serialize.js b/src/lib/serialize.js index e252527..ee9e3c8 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -5,6 +5,7 @@ import { serializeComponents } from './opaque.js'; import { checkCalculationDepth } from './limits.js'; import { isCalculationFunction } from './functions.js'; +import { num } from './node.js'; /** * @typedef {import('./node.js').Node} Node @@ -23,8 +24,8 @@ import { isCalculationFunction } from './functions.js'; const SUM_PRECEDENCE = 1; const PRODUCT_PRECEDENCE = 2; const ATOMIC_PRECEDENCE = 3; -// Unary minus binds more tightly than a sum but has the same atomic boundary -// for deciding whether `-x` needs parentheses. +// Negation (-1 * ...) binds more tightly than a sum but has the same atomic boundary +// for deciding whether the operand needs parentheses. const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE; const NOISE_FLOOR = 1e-12; @@ -189,20 +190,21 @@ function emitFiniteScalar(node, session, value) { */ function emitScalar(node, session, value) { const buffer = session.buffer; - if (Object.is(node.value, -0)) emitSignedZero(buffer, node); - else if (isDegenerate(node.value)) { + const effective = value ?? node.value; + if (Object.is(effective, -0)) emitSignedZero(buffer, node); + else if (isDegenerate(effective)) { if (node.type === 'Dim') { buffer.push( 'calc(', - degenerateKeyword(node.value), + degenerateKeyword(effective), ' * 1', node.rawUnit ?? node.unit, ')' ); } else { - buffer.push(degenerateKeyword(node.value)); + buffer.push(degenerateKeyword(effective)); } - } else emitFiniteScalar(node, session, value); + } else emitFiniteScalar(node, session, effective); } /** @@ -432,19 +434,20 @@ function emitSum(sum, session) { * @return {void} */ function emitLeadingNeg(node, session) { - if ( - node.type === 'Product' && - node.factors.length > 0 && - node.factors[0].exponent === 1 && - node.factors[0].node.type === 'Num' && - Number.isFinite(node.factors[0].node.value) && - node.factors[0].node.value !== 0 - ) { - const head = node.factors[0].node; - emitProductFactors(node.factors, session, 1, -head.value, head); + if (node.type === 'Product') { + if ( + node.factors.length > 0 && + node.factors[0].exponent === 1 && + node.factors[0].node.type === 'Num' + ) { + const head = node.factors[0].node; + emitProductFactors(node.factors, session, 1, -head.value, head); + return; + } + emitProductFactors(node.factors, session, 0, -1, num(-1)); return; } - session.buffer.push('-'); + session.buffer.push('-1 * '); emitNode(node, session, UNARY_PRECEDENCE, false); } @@ -502,7 +505,7 @@ function emitRootExpr(node, session) { node.terms.length > 1 && termSign(node.terms[0], 1, session.precision) === -1 ) { - session.buffer.push('-('); + session.buffer.push('-1 * ('); emitSumTerms(node.terms, session, -1); session.buffer.push(')'); return; @@ -543,7 +546,7 @@ function emitMathResult(node, session, wrapper) { node.terms.length > 1 && termSign(node.terms[0], 1, session.precision) === -1 ) { - session.buffer.push(wrapper, '(-('); + session.buffer.push(wrapper, '(-1 * ('); emitSumTerms(node.terms, session, -1); session.buffer.push('))'); return; diff --git a/test/helpers/css-math-arbitraries.js b/test/helpers/css-math-arbitraries.js index eeea0f1..c1f75ec 100644 --- a/test/helpers/css-math-arbitraries.js +++ b/test/helpers/css-math-arbitraries.js @@ -73,8 +73,8 @@ export const opaqueGroupedCalcArb = fc .chain(([a, b]) => fc.constantFrom( { - input: `calc(-(var(${a}) + var(${b})))`, - expected: `calc(-(var(${a}) + var(${b})))`, + input: `calc((var(${a}) + var(${b})) * -1)`, + expected: `calc(-1 * (var(${a}) + var(${b})))`, }, { input: `calc(var(${a}) - (var(${b}) + 10px))`, diff --git a/test/property/opaque-grouping.test.js b/test/property/opaque-grouping.test.js index 6c19c31..6d22505 100644 --- a/test/property/opaque-grouping.test.js +++ b/test/property/opaque-grouping.test.js @@ -54,7 +54,11 @@ test('opaque grouping: nested groups and var() fallbacks preserve serialization' 'calc(var(--a) - (var(--b) - (var(--c) + var(--d))))' ); assert.equal( - out('calc(-(var(--a, calc(1px + 2px)) + var(--b, 4px)))'), - 'calc(-(var(--a, calc(3px)) + var(--b, 4px)))' + out('calc(-1 * (var(--a, calc(1px + 2px)) + var(--b, 4px)))'), + 'calc(-1 * (var(--a, calc(3px)) + var(--b, 4px)))' + ); + assert.equal( + out('calc((var(--a, calc(1px + 2px)) + var(--b, 4px)) * -1)'), + 'calc(-1 * (var(--a, calc(3px)) + var(--b, 4px)))' ); }); diff --git a/test/unit/corpus-selection.test.js b/test/unit/corpus-selection.test.js index eb3e718..1284402 100644 --- a/test/unit/corpus-selection.test.js +++ b/test/unit/corpus-selection.test.js @@ -23,12 +23,18 @@ describe('corpus selection:', () => { const second = selectCorpusExpressions([...FIXTURE].reverse(), 20); assert.deepEqual(first, second); assert.equal(first.total, FIXTURE.length); - assert.equal(first.eligible, FIXTURE.length - 1); + assert.equal(first.eligible, FIXTURE.length - 2); assert.ok(first.selected.includes(FIXTURE[0])); - assert.ok(first.selected.includes(FIXTURE[1])); - assert.deepEqual(first.parserRejected, ['calc(1px +)']); + assert.ok(first.selected.includes(FIXTURE[2])); + // Rejected by the parser: the malformed operator and the unary-minus + // form that has no production. Sorted by stable hash. + assert.deepEqual(first.parserRejected, [ + 'calc(1px +)', + 'calc(-(var(--a) + var(--b)))', + ]); assert.ok(!first.routineInputs.includes('calc(1px +)')); assert.ok(!first.allInputs.includes('calc(1px +)')); + assert.ok(!first.routineInputs.includes('calc(-(var(--a) + var(--b)))')); }); test('corpus selection: structural and literal buckets distinguish boundaries', () => { diff --git a/test/unit/parser-core.test.js b/test/unit/parser-core.test.js index 98ea951..110612f 100644 --- a/test/unit/parser-core.test.js +++ b/test/unit/parser-core.test.js @@ -162,29 +162,38 @@ describe('parser: long arithmetic chains', () => { // --- Unary + / - prefix --------------------------------------------------- describe('parser: unary operators', () => { - test('parser: unary - on Num absorbs into value', () => { + test('parser: signed Num token absorbs its sign into the value', () => { assert.equal(ast('-5'), '-5'); }); - test('parser: unary - on Dim absorbs into value', () => { + test('parser: signed Dim token absorbs its sign into the value', () => { assert.equal(ast('-10px'), '-10px'); }); - test('parser: double unary - cancels', () => { - // Bare `--5` tokenizes as a single ident per CSS Syntax L3 (leading - // `-` followed by `-` starts an ident), so use a grouped form to - // exercise two unary-minus parses. - assert.equal(ast('-(-5)'), '5'); + test('parser: unary + on a signed Num token is a no-op', () => { + assert.equal(ast('+5'), '5'); }); - test('parser: unary + is a no-op', () => { - assert.equal(ast('+5'), '5'); + test('parser: unary - before a parenthesized sum is rejected', () => { + // The grammar has no unary sign production; a `-` punct + // can only be a binary operator. Browsers reject `calc(-(...))`. + assert.throws(() => ast('-(-5)'), { + message: 'Unexpected token "-" at position 0', + }); + }); + + test('parser: unary - before an opaque value is rejected', () => { + // `-x` tokenizes as one ident per CSS Syntax L3, so parenthesize to + // keep the leading `-` a punctuator. + assert.throws(() => ast('-(x)'), { + message: 'Unexpected token "-" at position 0', + }); }); - test('parser: unary - on opaque wraps in single-term negative Sum', () => { - // `-x` tokenizes as one ident per CSS Syntax L3. Parenthesize so the - // leading `-` lives next to a `(` and stays a punctuator. - assert.equal(ast('-(x)'), '(+ (- x))'); + test('parser: unary + before a parenthesized sum is rejected', () => { + assert.throws(() => ast('+(x)'), { + message: 'Unexpected token "+" at position 0', + }); }); }); diff --git a/test/unit/parser-opaque.test.js b/test/unit/parser-opaque.test.js index c84a651..ae2eb7d 100644 --- a/test/unit/parser-opaque.test.js +++ b/test/unit/parser-opaque.test.js @@ -161,42 +161,11 @@ describe('parser: opaque expressions and invalid syntax', () => { }, ], }, - ', calc(1PX+2PX), ', - { - type: 'Call', - name: 'calc', - args: [ - { - type: 'Sum', - terms: [ - { - sign: -1, - node: { - type: 'Sum', - terms: [ - { - sign: 1, - node: { - type: 'OpaqueCall', - name: 'var', - components: [{ type: 'Ident', name: '--x' }], - }, - }, - { - sign: 1, - node: { type: 'Dim', value: 1, unit: 'px' }, - }, - ], - grouped: true, - }, - }, - ], - }, - ], - }, - ' ', + ', calc(1PX+2PX), calc(-(var(--x) + 1px)) ', ], }); + // `calc(-( ... ))` has no unary-minus production, so the nested calc() + // degrades to raw opaque text and round-trips byte-for-byte. assert.equal(serialize(node), input); }); diff --git a/test/unit/plugin-core.test.js b/test/unit/plugin-core.test.js index d3d49f6..5bc5f2a 100644 --- a/test/unit/plugin-core.test.js +++ b/test/unit/plugin-core.test.js @@ -104,7 +104,8 @@ describe('plugin: basic pipeline', () => { ); }); - test('plugin: preserves grouping through unary negation', async () => { + test('plugin: preserves the unparsable unary minus form byte-for-byte', async () => { + // `-(...)` is invalid CSS math syntax; the plugin must not repair it. const { css } = await process( 'a{a:calc(-(var(--a) + var(--b)));b:calc(-(10px + var(--a)))}' ); @@ -114,6 +115,24 @@ describe('plugin: basic pipeline', () => { ); }); + test('plugin: preserves the unparsable unary plus form byte-for-byte', async () => { + // `+(...)` is invalid CSS math syntax; the plugin must not repair it. + const { css } = await process( + 'a{a:calc(+(10px + 20px));b:calc(+var(--x))}' + ); + assert.equal(css, 'a{a:calc(+(10px + 20px));b:calc(+var(--x))}'); + }); + + test('plugin: preserves grouping through explicit -1 multiplication', async () => { + const { css } = await process( + 'a{a:calc((var(--a) + var(--b)) * -1);b:calc(-1 * (10px + var(--a)))}' + ); + assert.equal( + css, + 'a{a:calc(-1 * (var(--a) + var(--b)));b:calc(-1 * (10px + var(--a)))}' + ); + }); + test('plugin: preserves grouping for opaque subtraction', async () => { const { css } = await process( 'a{a:calc(5px - (var(--var-1) + var(--var-2)));b:calc(var(--a) - (var(--b) + var(--c)));c:calc(var(--a) - (var(--b) - var(--c)));d:calc(5px - (10px + var(--a)))}' diff --git a/test/unit/reduceCalc-core.test.js b/test/unit/reduceCalc-core.test.js index d5e320c..ebc6bb7 100644 --- a/test/unit/reduceCalc-core.test.js +++ b/test/unit/reduceCalc-core.test.js @@ -175,22 +175,22 @@ describe('reduceCalc: basic pipeline', () => { test('negated grouped sum with leading sub-precision negative term serializes with positive sign', () => { assert.equal( - reduceCalc('calc(-(-1e-20 + var(--x)))'), - 'calc(-(0 + var(--x)))' + reduceCalc('calc(-1 * (-1e-20 + var(--x)))'), + 'calc(-1 * (0 + var(--x)))' ); }); test('negated grouped sum with non-leading sub-precision negative term serializes with positive sign', () => { assert.equal( reduceCalc('calc((-10px + var(--x) - 1e-20em))'), - 'calc(-(10px + 0em - var(--x)))' + 'calc(-1 * (10px + 0em - var(--x)))' ); }); test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { assert.equal( reduceCalc('calc((-10px + var(--x) + 1e-20em))'), - 'calc(-(10px + 0em - var(--x)))' + 'calc(-1 * (10px + 0em - var(--x)))' ); }); @@ -204,7 +204,7 @@ describe('reduceCalc: basic pipeline', () => { test('precision: false retains grouped negative sum inversion', () => { assert.equal( reduceCalc('calc((-1e-20 + var(--x)))', { precision: false }), - 'calc(-(1e-20 - var(--x)))' + 'calc(-1 * (1e-20 - var(--x)))' ); }); @@ -253,14 +253,14 @@ describe('reduceCalc: basic pipeline', () => { test('negated grouped sum serializes a negated positive zero term as arithmetic negative zero', () => { assert.equal( reduceCalc('calc((-1em + var(--x) + 0px))'), - 'calc(-(1em + calc(-1 * 0px) - var(--x)))' + 'calc(-1 * (1em + calc(-1 * 0px) - var(--x)))' ); }); test('negated grouped sum serializes a negated negative zero term as positive zero', () => { assert.equal( reduceCalc('calc((-1em + var(--x) - 0px))'), - 'calc(-(1em + 0px - var(--x)))' + 'calc(-1 * (1em + 0px - var(--x)))' ); }); }); @@ -398,7 +398,9 @@ describe('reduceCalc: basic pipeline', () => { assert.equal(reduceCalc('calc(2 / 1)'), 'calc(2)'); }); - test('reduceCalc: preserves grouping through unary negation', () => { + test('reduceCalc: preserves the unparsable unary minus form byte-for-byte', () => { + // `-(...)` has no production in the grammar; browsers drop + // the declaration, so the reducer must not rewrite it into valid CSS. assert.equal( reduceCalc('calc(-(var(--a) + var(--b)))'), 'calc(-(var(--a) + var(--b)))' @@ -409,6 +411,30 @@ describe('reduceCalc: basic pipeline', () => { ); }); + test('reduceCalc: preserves the unparsable unary plus form byte-for-byte', () => { + // `+(...)` has no production in the grammar; browsers drop + // the declaration, so the reducer must not rewrite it into valid CSS. + assert.equal(reduceCalc('calc(+(10px + 20px))'), 'calc(+(10px + 20px))'); + assert.equal(reduceCalc('calc(+var(--x))'), 'calc(+var(--x))'); + assert.equal( + reduceCalc('calc(+(var(--a) + var(--b)))'), + 'calc(+(var(--a) + var(--b)))' + ); + }); + + test('reduceCalc: unary plus on a signed number token simplifies to the bare value', () => { + // A leading `+` before a / token is a valid no-op, so + // this form is parsed and reduced rather than preserved verbatim. + assert.equal(reduceCalc('calc(+10px)'), 'calc(10px)'); + }); + + test('reduceCalc: preserves grouping through explicit -1 multiplication', () => { + assert.equal( + reduceCalc('calc((var(--a) + var(--b)) * -1)'), + 'calc(-1 * (var(--a) + var(--b)))' + ); + }); + test('reduceCalc: preserves grouping for opaque subtraction', () => { assert.equal( reduceCalc('calc(5px - (var(--var-1) + var(--var-2)))'), diff --git a/test/unit/serialize.test.js b/test/unit/serialize.test.js index 5b2e9d9..f693d51 100644 --- a/test/unit/serialize.test.js +++ b/test/unit/serialize.test.js @@ -103,14 +103,14 @@ describe('serialize: numbers', () => { }); test('serialize: single-term Sum with opaque gets calc() function', () => { - // `-var(--x)` needs calc() so the leading minus isn't ambiguous. + // `-1 * var(--x)` needs calc() so the leading minus isn't ambiguous. const ast = mkSum([ { sign: -1, node: opaqueCall('var', [ident('--x')]), }, ]); - assert.equal(serialize(ast), 'calc(-var(--x))'); + assert.equal(serialize(ast), 'calc(-1 * var(--x))'); }); test('serialize: precision option applied to numbers and dimensions', () => { @@ -337,7 +337,7 @@ describe('serialize: numbers', () => { }; assert.equal( serialize(ast, { precision: false }), - 'calc(-(1e-20 - var(--x)))' + 'calc(-1 * (1e-20 - var(--x)))' ); }); @@ -351,7 +351,7 @@ describe('serialize: numbers', () => { { sign: 1, node: opaqueCall('var', [ident('--x')]) }, ], }; - assert.equal(serialize(ast), 'calc(-(10px + 0em - var(--x)))'); + assert.equal(serialize(ast), 'calc(-1 * (10px + 0em - var(--x)))'); }); test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { @@ -364,7 +364,7 @@ describe('serialize: numbers', () => { { sign: 1, node: opaqueCall('var', [ident('--x')]) }, ], }; - assert.equal(serialize(ast), 'calc(-(10px + 0em - var(--x)))'); + assert.equal(serialize(ast), 'calc(-1 * (10px + 0em - var(--x)))'); }); }); @@ -551,10 +551,10 @@ describe('serialize: mutation-targeted tests', () => { }, ]); assert.equal(serialize(withCoefficient), 'calc(-2 * x)'); - assert.equal(serialize(withoutCoefficient), 'calc(-(a * b))'); + assert.equal(serialize(withoutCoefficient), 'calc(-1 * a * b)'); assert.equal( serialize(call('min', [withCoefficient, withoutCoefficient])), - 'min(-2 * x, -(a * b))' + 'min(-2 * x, -1 * a * b)' ); }); @@ -624,8 +624,8 @@ describe('serialize: mutation-targeted tests', () => { assert.equal(serialize(num(-5)), 'calc(-5)'); }); - test('serialize: single-term Sum with sign=-1 and opaque call → calc(-call)', () => { - // `-var(--x)` shape — only reachable as a directly-constructed Sum + test('serialize: single-term Sum with sign=-1 and opaque call → calc(-1 * call)', () => { + // `-1 * var(--x)` shape — only reachable as a directly-constructed Sum // (parser never produces it; mkSum would collapse if leaf). const ast = { type: 'Sum', @@ -636,12 +636,11 @@ describe('serialize: mutation-targeted tests', () => { }, ], }; - assert.equal(serialize(ast), 'calc(-var(--x))'); + assert.equal(serialize(ast), 'calc(-1 * var(--x))'); }); - test('serialize: single-term Sum with sign=-1 and Product needs outer parens', () => { - // `-(a * b)` must wrap the product so unary `-` binds the whole thing - // on re-parse (otherwise `-a * b` = `(-a) * b`). + test('serialize: single-term Sum with sign=-1 and Product serializes as -1 * factors', () => { + // `-1 * a * b` serializes without extra parentheses because multiplication is associative. const ast = { type: 'Sum', terms: [ @@ -657,7 +656,7 @@ describe('serialize: mutation-targeted tests', () => { }, ], }; - assert.equal(serialize(ast), 'calc(-(a * b))'); + assert.equal(serialize(ast), 'calc(-1 * a * b)'); }); test('serialize: multi-term Sum with trailing zero-valued Dim', () => { @@ -679,6 +678,75 @@ describe('serialize: mutation-targeted tests', () => { }; assert.equal(serialize(ast), 'calc(1 / 2px)'); }); + + test('serialize: negated Product with leading denominator emits -1 / value', () => { + // Negation of `1 / 2px` cannot fold into a signed Dim leaf, so it must + // serialize as `-1 / 2px` rather than distributing over the denominator. + const ast = mkSum([ + { + sign: -1, + node: mkProduct([{ exponent: -1, node: dim(2, 'px') }]), + }, + ]); + assert.equal(serialize(ast), 'calc(-1 / 2px)'); + }); + + test('serialize: negating a Product with leading zero coefficient emits calc(-1 * 0) times the rest', () => { + // The negated coefficient is -0, which must take the signed-zero path + // (calc(-1 * 0)) instead of collapsing to plain `0` or `1`. + const ast = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(0) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]), + }, + ]); + assert.equal(serialize(ast), 'calc(calc(-1 * 0) * var(--x))'); + }); + + test('serialize: negating a Product with leading -0 coefficient emits positive zero times the rest', () => { + // Negating -0 yields +0, so the signed-zero path must not trigger. + const ast = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(-0) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]), + }, + ]); + assert.equal(serialize(ast), 'calc(0 * var(--x))'); + }); + + test('serialize: negating a Product with leading Infinity coefficient emits -infinity times the rest', () => { + // The negated coefficient is -Infinity and must take the degenerate + // keyword path rather than the finite rounding path. + const ast = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(Infinity) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]), + }, + ]); + assert.equal(serialize(ast), 'calc(-infinity * var(--x))'); + }); + + test('serialize: negating a Product with leading -Infinity coefficient emits infinity times the rest', () => { + const ast = mkSum([ + { + sign: -1, + node: mkProduct([ + { exponent: 1, node: num(-Infinity) }, + { exponent: 1, node: opaqueCall('var', [ident('--x')]) }, + ]), + }, + ]); + assert.equal(serialize(ast), 'calc(infinity * var(--x))'); + }); }); // --- §10.13 degenerate-numeric serialization ---------------------------- diff --git a/test/unit/simplify/sum.test.js b/test/unit/simplify/sum.test.js index 59e80ed..e604817 100644 --- a/test/unit/simplify/sum.test.js +++ b/test/unit/simplify/sum.test.js @@ -33,12 +33,15 @@ describe('sum simplification and grouping', () => { assert.equal(out('calc(50px - (20px - 30px))'), 'calc(60px)'); }); - test('simplify: preserves grouping through unary negation', () => { + test('simplify: preserves grouping through multiplication by -1', () => { assert.equal( - out('calc(-(var(--a) + var(--b)))'), - 'calc(-(var(--a) + var(--b)))' + out('calc((var(--a) + var(--b)) * -1)'), + 'calc(-1 * (var(--a) + var(--b)))' + ); + assert.equal( + out('calc(-1 * (10px + var(--a)))'), + 'calc(-1 * (10px + var(--a)))' ); - assert.equal(out('calc(-(10px + var(--a)))'), 'calc(-(10px + var(--a)))'); }); test('simplify: preserves opaque sums subtracted as a group', () => { From c295ca216880f577b076b403bf3736fa9027fe95 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Sun, 20 Sep 2026 12:05:15 +0200 Subject: [PATCH 5/5] test: try to get benchmarks to run under 5 minutes --- scripts/README.md | 7 ++++- scripts/lib/parser-benchmark.js | 50 ++++++++++++++++++++---------- scripts/parser-benchmark-worker.js | 9 ++++-- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index bcf8b41..f760635 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -10,7 +10,12 @@ reanalysis. benchmark for arithmetic shapes. `benchmark-nested-fallbacks.js` does the same for nested `var()` fallbacks. Both accept `--baseline`, `--blocks`, `--max-attempts`, `--seed`, and `--output`, and write schema-v2 artifacts under - `reports/benchmarks/`. + `reports/benchmarks/`. The default arithmetic grid uses four logarithmically + spaced sizes with uniform doubling steps (`2,000` to `16,000`) and a tuned + batch schedule so a controlled run completes under 5 minutes while preserving + the paired fresh-process blocks, balanced process order, stratified interval, + doubling-growth gate, and family-adjusted precision gate; request more + `--blocks` to trade time for power. - Parser benchmark exit codes are `0` pass, `1` regression, `2` inconclusive, `3` benchmark/correctness/infrastructure failure, and `64` invalid usage or artifact. Twenty blocks are the minimum operational floor, not a guarantee diff --git a/scripts/lib/parser-benchmark.js b/scripts/lib/parser-benchmark.js index ceca5b1..aa54aab 100644 --- a/scripts/lib/parser-benchmark.js +++ b/scripts/lib/parser-benchmark.js @@ -10,8 +10,6 @@ import { DECISION_INTERVAL_METHOD, DRIFT_THRESHOLD, GROWTH_THRESHOLD, - MAX_WARMUPS, - MEASURED_BATCHES, linearRegression, logRatio, materializeBaseline, @@ -27,8 +25,17 @@ import { validateSchemaV2Artifact, } from './benchmark.js'; -const SIZES = [500, 1_000, 2_000, 4_000, 8_000, 16_000]; +// Four logarithmically spaced sizes with uniform doubling steps (2x) +// keep the scaling claims (slope and doubling growth) sound and well-powered +// while holding the default 20-block run to under five minutes. +const SIZES = [2_000, 4_000, 8_000, 16_000]; const DEPTHS = [16, 32, 64, 128, 256, 512]; +// 16ms batch targets provide ample separation above the timer resolution floor +// while keeping worker durations concise. +const PARSER_TARGET_BATCH_MS = 16; +const PARSER_WARMUP_MINIMUM = 4; +const PARSER_WARMUP_MAXIMUM = 8; +const PARSER_MEASURED_BATCHES = 5; const SCRIPT_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const WORKER = join(SCRIPT_ROOT, 'parser-benchmark-worker.js'); @@ -160,10 +167,8 @@ function analyzeParser( endpoints.push(endpoint); } - const largest = endpoints.filter( - (endpoint) => - endpoint.key.endsWith(':16000') || endpoint.key.endsWith(':512') - ); + const largestKeys = largestSizeKeys(artifact.workloadKeys); + const largest = endpoints.filter((endpoint) => largestKeys.has(endpoint.key)); const slopes = analyzeSlopes(artifact, blocks, config); const growthData = analyzeGrowth(artifact, blocks); const familyRows = blocks.map((_, index) => [ @@ -599,17 +604,18 @@ function analyzeGrowth(artifact, blocks) { for (const [group, items] of groups) { items.sort((a, b) => a.size - b.size); for (let i = 1; i < items.length; i++) { + const doublings = Math.log2(items[i].size / items[i - 1].size); const base = []; const cand = []; for (const block of blocks) { - base.push( + const baseRatio = median(findWorkload(block, 'baseline', items[i].key).measured) / - median(findWorkload(block, 'baseline', items[i - 1].key).measured) - ); - cand.push( + median(findWorkload(block, 'baseline', items[i - 1].key).measured); + const candRatio = median(findWorkload(block, 'candidate', items[i].key).measured) / - median(findWorkload(block, 'candidate', items[i - 1].key).measured) - ); + median(findWorkload(block, 'candidate', items[i - 1].key).measured); + base.push(doublings === 1 ? baseRatio : baseRatio ** (1 / doublings)); + cand.push(doublings === 1 ? candRatio : candRatio ** (1 / doublings)); } logs.push(cand.map(Math.log)); results.push({ @@ -665,6 +671,16 @@ function parseKey(key) { if (parts.length === 3) return { key, shape: parts[0], mode: parts[1], size }; return { key, shape: 'nested-fallbacks', mode: parts[0], size }; } +function largestSizeKeys(workloadKeys) { + const maximum = new Map(); + for (const key of workloadKeys) { + const { shape, mode, size } = parseKey(key); + const group = `${shape}:${mode}`; + maximum.set(group, Math.max(maximum.get(group) ?? 0, size)); + } + return new Set([...maximum].map(([group, size]) => `${group}:${size}`)); +} + function findWorkload(block, revision, key) { return block.revisions .find((item) => item.revision === revision) @@ -700,10 +716,10 @@ export function runParserBenchmark({ requestedBlocks: blocks, minimumBlocks: MIN_VALID_BLOCKS, maxAttempts, - targetBatchMs: 25, - warmupMinimum: 5, - warmupMaximum: MAX_WARMUPS, - measuredBatchCount: MEASURED_BATCHES, + targetBatchMs: PARSER_TARGET_BATCH_MS, + warmupMinimum: PARSER_WARMUP_MINIMUM, + warmupMaximum: PARSER_WARMUP_MAXIMUM, + measuredBatchCount: PARSER_MEASURED_BATCHES, driftThreshold: DRIFT_THRESHOLD, bootstrapResamples: 100_000, confidence: 0.95, diff --git a/scripts/parser-benchmark-worker.js b/scripts/parser-benchmark-worker.js index 783c219..06d97de 100644 --- a/scripts/parser-benchmark-worker.js +++ b/scripts/parser-benchmark-worker.js @@ -160,16 +160,19 @@ function control() { }; const run = makeRun(workload); const repetitions = 50; - for (let i = 0; i < 5; i++) timedBatch(run, repetitions); + // The first batches after process start are slower (JIT and frequency + // ramp). Warm to a steady state before sampling so before/after ratios + // measure drift during the run rather than process startup. + for (let i = 0; i < 10; i++) timedBatch(run, repetitions); const samples = []; const elapsedMs = []; - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 5; i++) { const batch = timedBatch(run, repetitions); samples.push(batch.perRun); elapsedMs.push(batch.elapsed); } samples.sort((a, b) => a - b); - return { medianMs: samples[1], samplesMs: elapsedMs }; + return { medianMs: samples[2], samplesMs: elapsedMs }; } const before = control();