|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20544] `compensatedSum` — the one compensated fold every platform face that |
| 5 | + * adds a group's values in JavaScript calls (objectql's rows path, |
| 6 | + * driver-memory's data and analytics faces, service-analytics' draft preview). |
| 7 | + * |
| 8 | + * The expected values are SQLite's own `sum` answers, measured by #20489 with |
| 9 | + * better-sqlite3 (SQLite 3.53.4), sql.js (3.49.1) and @libsql/client (3.45.1); |
| 10 | + * the naive fold each face used before is asserted beside them, so a fixture |
| 11 | + * that cannot tell the two folds apart fails here instead of passing vacuously. |
| 12 | + * Each face pins the same fixtures through its own door, in its own package. |
| 13 | + */ |
| 14 | + |
| 15 | +import { describe, it, expect } from 'vitest'; |
| 16 | +import { compensatedSum } from './compensated-sum'; |
| 17 | + |
| 18 | +/** The fold the faces used before: in order, one addition at a time. */ |
| 19 | +const naiveSum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0); |
| 20 | + |
| 21 | +describe('[#20544] compensatedSum — the one compensated fold', () => { |
| 22 | + it("the card's fixture: 0.1 + 0.2 + 0.3 is SQLite's 0.6, not the naive 0.6000000000000001", () => { |
| 23 | + expect(naiveSum([0.1, 0.2, 0.3])).toBe(0.6000000000000001); |
| 24 | + expect(compensatedSum([0.1, 0.2, 0.3])).toBe(0.6); |
| 25 | + // The mean every avg arm divides out of it: SQLite's 0.19999999999999998. |
| 26 | + expect(compensatedSum([0.1, 0.2, 0.3]) / 3).toBe(0.19999999999999998); |
| 27 | + }); |
| 28 | + |
| 29 | + it('a large cancellation keeps the small addend: 1e16 + 1 - 1e16 is 1, not 0', () => { |
| 30 | + expect(naiveSum([1e16, 1, -1e16])).toBe(0); |
| 31 | + expect(compensatedSum([1e16, 1, -1e16])).toBe(1); |
| 32 | + expect(compensatedSum([1e16, 0.5, -1e16])).toBe(0.5); |
| 33 | + }); |
| 34 | + |
| 35 | + it('two addends are unchanged: the compensated a + b is the naive one', () => { |
| 36 | + for (const pair of [[0.1, 0.2], [0.7, 0.1], [1e16, 1], [-0.3, 0.1]]) { |
| 37 | + expect(compensatedSum(pair), `${pair}`).toBe(naiveSum(pair)); |
| 38 | + } |
| 39 | + expect(compensatedSum([0.1, 0.2])).toBe(0.30000000000000004); |
| 40 | + }); |
| 41 | + |
| 42 | + it('integers whose partial sums stay within 2^53 are unchanged', () => { |
| 43 | + for (const ints of [[1, 2, 3, 40, 500], [-7, 3, 12, 0, 9_000_000_000]]) { |
| 44 | + const s = compensatedSum(ints); |
| 45 | + expect(s, `${ints}`).toBe(naiveSum(ints)); |
| 46 | + expect(Number.isInteger(s)).toBe(true); |
| 47 | + } |
| 48 | + expect(compensatedSum([1, 2, 3, 40, 500])).toBe(546); |
| 49 | + }); |
| 50 | + |
| 51 | + it('an empty list is 0, and one addend is itself', () => { |
| 52 | + expect(compensatedSum([])).toBe(0); |
| 53 | + expect(compensatedSum([0.1])).toBe(0.1); |
| 54 | + expect(compensatedSum([-2.5])).toBe(-2.5); |
| 55 | + }); |
| 56 | + |
| 57 | + it('a non-finite total is the naive one, as SQLite returns its running sum when the error term overflows', () => { |
| 58 | + for (const values of [ |
| 59 | + [Infinity, 1, 2], |
| 60 | + [1, -Infinity, 0.3], |
| 61 | + [1e308, 1e308, -1e308], |
| 62 | + [Infinity, -Infinity, 1], |
| 63 | + [NaN, 0.1, 0.2], |
| 64 | + ]) { |
| 65 | + expect(Object.is(compensatedSum(values), naiveSum(values)), `${values}`).toBe(true); |
| 66 | + } |
| 67 | + }); |
| 68 | +}); |
0 commit comments