Skip to content

Commit 24706c9

Browse files
committed
fix(objectql): rows-path sum / avg add with Kahan-Babuska-Neumaier compensation, as SQLite does
The engine's rows path (in-memory-aggregation.ts) folded sum and avg naively, while SQLite 3.43+ compensates, so one query answered two doubles on SQLite depending on the path engine.aggregate took (0.1 + 0.2 + 0.3: native 0.6, rows 0.6000000000000001). Both arms now add through one compensated fold transcribed from SQLite's kahanBabuskaNeumaierStep and its finalizers' overflow guard. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2b6a06 commit 24706c9

3 files changed

Lines changed: 372 additions & 2 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20489] The rows path's `sum` / `avg` add with Kahan-Babuska-Neumaier
5+
* compensation (`in-memory-aggregation.ts`, `compensatedSum`), the summation
6+
* SQLite 3.43+ uses for its own `sum` / `avg`.
7+
*
8+
* The expected values below are SQLite's own answers, measured with
9+
* better-sqlite3 (SQLite 3.53.4), sql.js (3.49.1) and @libsql/client (3.45.1)
10+
* over a `REAL` and a `NUMERIC` column holding the same values. All three
11+
* engines agreed on every fixture. The naive fold the rows path used before is
12+
* shown beside each one:
13+
*
14+
* | fixture | naive `sum` / `avg` (before) | SQLite native = compensated (after) |
15+
* |:--|:--|:--|
16+
* | `0.1, 0.2, 0.3` | `0.6000000000000001` / `0.20000000000000004` | `0.6` / `0.19999999999999998` |
17+
* | `1e16, 1, -1e16` | `0` / `0` | `1` / `0.3333333333333333` |
18+
* | `1e16, 0.5, -1e16` | `0` / `0` | `0.5` / `0.16666666666666666` |
19+
* | `0.1, 0.2` (two addends) | `0.30000000000000004` / `0.15000000000000002` | the same |
20+
* | `1, 2, 3, 40, 500` (integers) | `546` / `109.2` | the same |
21+
*
22+
* `engine.aggregate` and REST on SQLite are pinned in `packages/rest`
23+
* (`rest-aggregate-compensated-sum.test.ts`): the rows path and the native
24+
* path answer the same double there.
25+
*/
26+
27+
import { describe, it, expect } from 'vitest';
28+
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
29+
30+
/** The fold the rows path used before #20489: in order, one addition at a time. */
31+
const naiveSum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0);
32+
33+
/** `sum` and `avg` of `w` over `values`, one group, through the rows path. */
34+
function sumAvg(values: readonly unknown[]): { s: unknown; a: unknown } {
35+
const rows = values.map((w, i) => ({ id: `r${i}`, w }));
36+
const [out] = applyInMemoryAggregation(rows, {
37+
aggregations: [
38+
{ function: 'sum', field: 'w', alias: 's' },
39+
{ function: 'avg', field: 'w', alias: 'a' },
40+
],
41+
});
42+
return out as { s: unknown; a: unknown };
43+
}
44+
45+
describe('[#20489] rows path — sum / avg add with compensation, as SQLite does', () => {
46+
it("the card's fixture: 0.1 + 0.2 + 0.3 answers SQLite's 0.6, and avg its 0.19999999999999998", () => {
47+
const values = [0.1, 0.2, 0.3];
48+
// The fixture discriminates: the naive fold answers another double.
49+
expect(naiveSum(values)).toBe(0.6000000000000001);
50+
expect(sumAvg(values)).toStrictEqual({ s: 0.6, a: 0.19999999999999998 });
51+
// So `having { s: { $eq: 0.6 } }` now keeps the group on this path too.
52+
expect(sumAvg(values).s === 0.6).toBe(true);
53+
});
54+
55+
it('a mixed-sign set with large cancellation keeps the small addend', () => {
56+
expect(naiveSum([1e16, 1, -1e16])).toBe(0);
57+
expect(sumAvg([1e16, 1, -1e16])).toStrictEqual({ s: 1, a: 1 / 3 });
58+
expect(naiveSum([1e16, 0.5, -1e16])).toBe(0);
59+
expect(sumAvg([1e16, 0.5, -1e16])).toStrictEqual({ s: 0.5, a: 0.5 / 3 });
60+
});
61+
62+
it('two addends are unchanged: the compensated a + b is the naive one', () => {
63+
for (const pair of [[0.1, 0.2], [0.7, 0.1], [1e16, 1], [-0.3, 0.1]]) {
64+
const n = naiveSum(pair);
65+
expect(sumAvg(pair), `${pair}`).toStrictEqual({ s: n, a: n / 2 });
66+
}
67+
expect(sumAvg([0.1, 0.2])).toStrictEqual({ s: 0.30000000000000004, a: 0.15000000000000002 });
68+
});
69+
70+
it('integers whose partial sums stay within 2^53 are unchanged, and stay integers', () => {
71+
const values = [1, 2, 3, 40, 500];
72+
const { s, a } = sumAvg(values);
73+
expect(s).toBe(naiveSum(values));
74+
expect(s).toBe(546);
75+
expect(Number.isInteger(s)).toBe(true);
76+
expect(a).toBe(109.2);
77+
expect(sumAvg([-7, 3, 12, 0, 9_000_000_000])).toStrictEqual({ s: 9_000_000_008, a: 9_000_000_008 / 5 });
78+
});
79+
80+
it('above 2^53 the compensated total is the exact one SQLite answers, where the naive fold lost the 1s', () => {
81+
// SQLite 3.53.4 answers 9007199254740994 over a REAL and over a NUMERIC column.
82+
expect(naiveSum([2 ** 53, 1, 1])).toBe(2 ** 53);
83+
expect(sumAvg([2 ** 53, 1, 1])).toStrictEqual({ s: 9007199254740994, a: 3002399751580331.5 });
84+
});
85+
86+
it('under groupBy and a per-aggregation filter, every bucket takes the same fold', () => {
87+
const rows = [
88+
{ g: 'x', k: 'in', w: 0.1 },
89+
{ g: 'x', k: 'in', w: 0.2 },
90+
{ g: 'x', k: 'out', w: 100 },
91+
{ g: 'x', k: 'in', w: 0.3 },
92+
{ g: 'y', k: 'in', w: 1e16 },
93+
{ g: 'y', k: 'in', w: 1 },
94+
{ g: 'y', k: 'in', w: -1e16 },
95+
];
96+
const out = applyInMemoryAggregation(rows, {
97+
groupBy: ['g'],
98+
aggregations: [
99+
{ function: 'sum', field: 'w', alias: 's', filter: { k: 'in' } },
100+
{ function: 'avg', field: 'w', alias: 'a', filter: { k: 'in' } },
101+
{ function: 'sum', field: 'w', alias: 'all' },
102+
],
103+
} as any).sort((p, q) => String(p.g).localeCompare(String(q.g)));
104+
expect(out).toStrictEqual([
105+
{ g: 'x', s: 0.6, a: 0.19999999999999998, all: 100.6 },
106+
{ g: 'y', s: 1, a: 1 / 3, all: 1 },
107+
]);
108+
});
109+
});
110+
111+
describe('[#20489] rows path — what the compensated fold leaves as it was', () => {
112+
it('null: sum adds nothing for it, avg leaves it out of the count', () => {
113+
expect(sumAvg([0.1, null, 0.2, undefined, 0.3])).toStrictEqual({ s: 0.6, a: 0.19999999999999998 });
114+
expect(sumAvg([10, null, 20])).toStrictEqual({ s: 30, a: 15 });
115+
});
116+
117+
it('a non-numeric cell reads as 0 in both, and counts in avg; a numeric string reads as its number', () => {
118+
expect(sumAvg([10, 'abc', 20])).toStrictEqual({ s: 30, a: 10 });
119+
expect(sumAvg(['0.1', '0.2', '0.3'])).toStrictEqual({ s: 0.6, a: 0.19999999999999998 });
120+
expect(sumAvg([true, false, true])).toStrictEqual({ s: 2, a: 2 / 3 });
121+
});
122+
123+
it('an empty group: sum 0, avg null — with no rows, and with only nulls', () => {
124+
expect(sumAvg([])).toStrictEqual({ s: 0, a: null });
125+
expect(sumAvg([null, undefined])).toStrictEqual({ s: 0, a: null });
126+
});
127+
128+
it('a non-finite total is the naive one, as SQLite returns its running sum when the error term overflows', () => {
129+
for (const values of [
130+
[Infinity, 1, 2],
131+
[1, -Infinity, 0.3],
132+
[1e308, 1e308, -1e308],
133+
[Infinity, -Infinity, 1],
134+
[NaN, 0.1, 0.2],
135+
]) {
136+
const n = naiveSum(values);
137+
const { s, a } = sumAvg(values);
138+
expect(Object.is(s, n), `sum of ${values}: ${s} vs naive ${n}`).toBe(true);
139+
expect(Object.is(a, n / values.length), `avg of ${values}`).toBe(true);
140+
}
141+
});
142+
143+
it('the answer is a JS number on every arm, never a string', () => {
144+
const { s, a } = sumAvg(['0.1', 0.2, '0.3']);
145+
expect(typeof s).toBe('number');
146+
expect(typeof a).toBe('number');
147+
});
148+
});

‎packages/objectql/src/in-memory-aggregation.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,15 @@ function aggregateBucket(
228228
case 'count_distinct':
229229
out[alias] = new Set(values.filter((v) => v != null)).size;
230230
break;
231+
// [#20489] Both arms add through ONE compensated fold
232+
// ({@link compensatedSum}) — the summation SQLite's own `sum` / `avg`
233+
// use — so the rows path and SQLite's native path answer the same double.
231234
case 'sum':
232-
out[alias] = values.reduce((a, b) => a + toNumber(b), 0);
235+
out[alias] = compensatedSum(values.map(toNumber));
233236
break;
234237
case 'avg': {
235238
const nums = values.filter((v) => v != null).map(toNumber);
236-
out[alias] = nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0) / nums.length;
239+
out[alias] = nums.length === 0 ? null : compensatedSum(nums) / nums.length;
237240
break;
238241
}
239242
// [#11152] `min`/`max` read a BOOLEAN as the number it is worth (0/1) —
@@ -284,6 +287,42 @@ function toNumber(v: any): number {
284287
return Number.isFinite(n) ? n : 0;
285288
}
286289

290+
/**
291+
* [#20489] The sum of `nums`, added in order with Kahan-Babuska-Neumaier
292+
* compensation — the summation SQLite (3.43 and later) uses for its own `sum`
293+
* and `avg`, transcribed from its `kahanBabuskaNeumaierStep` and the
294+
* finalizers' overflow guard.
295+
*
296+
* Why: this fold used to add naively (`reduce((a, b) => a + b, 0)`), so one
297+
* query answered two doubles on SQLite depending on the path `engine.aggregate`
298+
* took. A `number` column holding `0.1`, `0.2` and `0.3` summed to `0.6`
299+
* natively and to `0.6000000000000001` here (`avg` `0.19999999999999998`
300+
* against `0.20000000000000004`), and `having { s: { $eq: 0.6 } }` kept the
301+
* group on the native path alone. Compensated, the two paths agree, and the
302+
* answer is the more accurate one (`1e16 + 1 - 1e16` is `1`, not `0`).
303+
*
304+
* What does not move: two addends (the compensated `a + b` IS the naive one),
305+
* integers whose partial sums stay within 2^53 (every addition is exact), and
306+
* a non-finite total. `s` below is exactly the naive running sum; once it
307+
* overflows or meets a NaN, the error term is non-finite and the naive answer
308+
* is returned as it was, which is SQLite's rule too.
309+
*
310+
* ⚠️ Residual, stated: PostgreSQL and MySQL add their doubles natively without
311+
* compensation, so over three or more fractions their native path can still
312+
* differ from this one in the last place. An exact `$eq` on a fractional sum
313+
* compares doubles; compare with a range.
314+
*/
315+
function compensatedSum(nums: readonly number[]): number {
316+
let s = 0;
317+
let c = 0;
318+
for (const r of nums) {
319+
const t = s + r;
320+
c += Math.abs(s) > Math.abs(r) ? (s - t) + r : (r - t) + s;
321+
s = t;
322+
}
323+
return Number.isFinite(c) ? s + c : s;
324+
}
325+
287326
/**
288327
* Bucket a date-like value into an ISO-formatted period label. Weeks start
289328
* Monday and use ISO week numbering.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20489] On SQLite, `sum` / `avg` answer ONE double on both of
5+
* `engine.aggregate`'s paths — the native `SqlDriver.aggregate` and the rows
6+
* path (`objectql`'s `in-memory-aggregation.ts`, which a filtered sibling
7+
* aggregation forces) — through `engine.aggregate` and
8+
* `POST /api/v1/data/:object/query`, over a real `SqlDriver`.
9+
*
10+
* SQLite 3.43+ sums with Kahan-Babuska-Neumaier compensation; the rows path
11+
* used to add naively. Measured on the base (`b2b6a0643`), SQLite 3.53.4, a
12+
* `number` column, same readings through the engine and REST:
13+
*
14+
* | group | native `sum` / `avg` | rows path `sum` / `avg` (base) |
15+
* |:--|:--|:--|
16+
* | `0.1, 0.2, 0.3` | `0.6` / `0.19999999999999998` | `0.6000000000000001` / `0.20000000000000004` |
17+
* | `1e16, 1, -1e16` | `1` / `0.3333333333333333` | `0` / `0` |
18+
* | `1e16, 0.5, -1e16` | `0.5` / `0.16666666666666666` | `0` / `0` |
19+
* | `0.1, 0.2` | `0.30000000000000004` / `0.15000000000000002` | the same |
20+
* | `1, 2, 3, 40, 500` | `546` / `109.2` | the same |
21+
*
22+
* `having { s: { $eq: 0.6 } }` kept the first group on the native path and no
23+
* group on the rows path. The rows path now adds with the same compensation,
24+
* so every row of that table reads as its native column on both paths.
25+
*
26+
* ## The dialect axis of THIS file
27+
*
28+
* SQLite only, and deliberately. PostgreSQL and MySQL add their doubles
29+
* natively without compensation (`sql-driver.ts`, `AGGREGATE_ACCUMULATION`), so
30+
* over three or more fractions their native path can differ from the rows path
31+
* in the last place: that is the residual #20489 states, not a defect a pin
32+
* here should hold red. An exact `$eq` on a fractional sum compares doubles.
33+
*/
34+
35+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
36+
import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data';
37+
import { ObjectQL } from '@objectstack/objectql';
38+
import { SqlDriver } from '@objectstack/driver-sql';
39+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
40+
import { RestServer } from './rest-server';
41+
42+
const OBJECT = 'rest_agg_20489';
43+
44+
const LEDGER = {
45+
name: OBJECT,
46+
label: 'Ledger 20489',
47+
fields: {
48+
g: { name: 'g', type: 'text' as const },
49+
w: { name: 'w', type: 'number' as const },
50+
},
51+
};
52+
53+
/** group → its values, and SQLite's native `sum` / `avg` over them. */
54+
const GROUPS: Record<string, { values: number[]; s: number; a: number }> = {
55+
card: { values: [0.1, 0.2, 0.3], s: 0.6, a: 0.19999999999999998 },
56+
cancel: { values: [1e16, 1, -1e16], s: 1, a: 0.3333333333333333 },
57+
cancel_half: { values: [1e16, 0.5, -1e16], s: 0.5, a: 0.16666666666666666 },
58+
two: { values: [0.1, 0.2], s: 0.30000000000000004, a: 0.15000000000000002 },
59+
ints: { values: [1, 2, 3, 40, 500], s: 546, a: 109.2 },
60+
};
61+
62+
function createMockServer() {
63+
const noop = () => {};
64+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
65+
}
66+
67+
function makeRes() {
68+
const res: any = {
69+
write: () => true, end: () => {},
70+
header: () => res,
71+
status: (code: number) => { res._status = code; return res; },
72+
json: (body: any) => { res._json = body; return res; },
73+
};
74+
return res;
75+
}
76+
77+
type Path = 'native' | 'rows';
78+
79+
/**
80+
* `native`: SqlDriver aggregates and the engine applies `having` to its
81+
* answer. `rows`: a filtered aggregation sends the engine to the rows path,
82+
* where it aggregates `find()` rows itself and then applies `having`.
83+
*/
84+
function grouped(path: Path, having?: Record<string, unknown>): EngineAggregateOptions {
85+
const aggregations: NonNullable<EngineAggregateOptions['aggregations']> = [
86+
{ function: 'sum', field: 'w', alias: 's' },
87+
{ function: 'avg', field: 'w', alias: 'a' },
88+
];
89+
if (path === 'rows') aggregations.push({ function: 'count', alias: 'fb', filter: { g: { $ne: '' } } });
90+
return { groupBy: ['g'], aggregations, ...(having ? { having: having as FilterCondition } : {}) };
91+
}
92+
93+
const byGroup = (rows: any[]) => Object.fromEntries(rows.map((r) => [r.g, { s: r.s, a: r.a }]));
94+
const groupsOf = (rows: any[]) => rows.map((r) => r.g).sort();
95+
96+
describe('[#20489] sum / avg — one double on both SQLite paths, engine and REST', () => {
97+
let engine: ObjectQL;
98+
let driver: SqlDriver;
99+
let post: (body: Record<string, unknown>) => Promise<any>;
100+
101+
beforeAll(async () => {
102+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never);
103+
engine = new ObjectQL();
104+
engine.registerDriver(driver, true);
105+
await engine.init();
106+
engine.registry.registerObject(LEDGER as any);
107+
await engine.syncSchemas();
108+
let i = 0;
109+
for (const [g, { values }] of Object.entries(GROUPS)) {
110+
for (const w of values) await engine.insert(OBJECT, { id: `r${i++}`, g, w } as any);
111+
}
112+
113+
const protocol = new ObjectStackProtocolImplementation(engine as any);
114+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
115+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
116+
rest.registerRoutes();
117+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query');
118+
expect(route).toBeDefined();
119+
post = async (body) => {
120+
const res = makeRes();
121+
// What the wire carries: JSON, both ways.
122+
await route!.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)) } as any, res);
123+
if (res._json !== undefined) res._json = JSON.parse(JSON.stringify(res._json));
124+
return res;
125+
};
126+
});
127+
128+
afterAll(async () => {
129+
try { await engine?.destroy(); } catch { /* noop */ }
130+
});
131+
132+
it('each path is the one it names: native asks driver.aggregate, rows asks driver.find alone', async () => {
133+
const spy = vi.spyOn(driver, 'aggregate');
134+
try {
135+
await engine.aggregate(OBJECT, grouped('native'));
136+
expect(spy, 'native').toHaveBeenCalledTimes(1);
137+
spy.mockClear();
138+
await engine.aggregate(OBJECT, grouped('rows'));
139+
expect(spy, 'rows').not.toHaveBeenCalled();
140+
} finally {
141+
spy.mockRestore();
142+
}
143+
});
144+
145+
it("find() reads back the doubles written, so both paths add the same operands", async () => {
146+
const rows = (await engine.find(OBJECT, {})) as Array<{ g: string; w: number }>;
147+
for (const [g, { values }] of Object.entries(GROUPS)) {
148+
expect(rows.filter((r) => r.g === g).map((r) => r.w).sort((x, y) => x - y), g)
149+
.toStrictEqual([...values].sort((x, y) => x - y));
150+
}
151+
});
152+
153+
it('sum / avg: SQLite native answers, equal on both paths, through the engine and REST', async () => {
154+
const expected = Object.fromEntries(Object.entries(GROUPS).map(([g, { s, a }]) => [g, { s, a }]));
155+
for (const path of ['native', 'rows'] as const) {
156+
expect(byGroup(await engine.aggregate(OBJECT, grouped(path))), `engine, ${path}`).toStrictEqual(expected);
157+
const res = await post(grouped(path) as Record<string, unknown>);
158+
expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200);
159+
expect(byGroup(res._json.records), `REST, ${path}`).toStrictEqual(expected);
160+
}
161+
});
162+
163+
it('having $eq on the fractional sum / avg keeps the same group on both paths', async () => {
164+
const KEPT: ReadonlyArray<readonly [Record<string, unknown>, string[]]> = [
165+
[{ s: { $eq: 0.6 } }, ['card']],
166+
[{ a: { $eq: 0.19999999999999998 } }, ['card']],
167+
[{ s: { $eq: 1 } }, ['cancel']],
168+
[{ s: { $in: [0.5, 546] } }, ['cancel_half', 'ints']],
169+
// The residual, as a double: `0.1 + 0.2` is not `0.3` on any path.
170+
[{ s: { $eq: 0.3 } }, []],
171+
[{ s: { $eq: 0.30000000000000004 } }, ['two']],
172+
];
173+
for (const [having, kept] of KEPT) {
174+
for (const path of ['native', 'rows'] as const) {
175+
expect(groupsOf(await engine.aggregate(OBJECT, grouped(path, having))), `engine, ${path}, ${JSON.stringify(having)}`)
176+
.toEqual(kept);
177+
const res = await post(grouped(path, having) as Record<string, unknown>);
178+
expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200);
179+
expect(groupsOf(res._json.records), `REST, ${path}, ${JSON.stringify(having)}`).toEqual(kept);
180+
}
181+
}
182+
});
183+
});

0 commit comments

Comments
 (0)