Skip to content

Commit b785c3b

Browse files
fix(core,driver-memory,service-analytics): sum / avg add with one compensated sum on every face (#20544) (#20739)
Fixes #20544 Clause-②: yes ## What changed `sum` / `avg` now give the same double on every face the platform owns. One compensated fold does the adding everywhere. - **Hoist.** `compensatedSum` moves from `@objectstack/objectql`'s rows path (`in-memory-aggregation.ts`, private, `:317` at base `d2820876f`) to `packages/core/src/utils/compensated-sum.ts`. It is exported from the `@objectstack/core` root, beside `bucketDateKey`, which is the `bucketDateKey` precedent. `in-memory-aggregation.ts` imports it and no longer keeps a copy. The function body is byte-identical: it is SQLite's `kahanBabuskaNeumaierStep` plus the finalizers' overflow guard. - **The three naive folds now call it:** - `packages/drivers/driver-memory/src/memory-driver.ts`, `computeAggregate`'s `sum` / `avg` arm. This is the door `engine.aggregate`'s native path takes on driver-memory, and `find()` with aggregations uses it too. - `packages/drivers/driver-memory/src/memory-analytics.ts`, `buildAggregator`. A `sum` / `avg` measure is now one `$group` `$accumulator`, whose `finalize` calls `compensatedSum`. It replaces mingo's `$sum` / `$avg`. The aggregand expression (`numericAggregandExpr`, the boolean rule) is unchanged. So are the addend predicate (mingo's `isNumber`) and the empty answers (`sum` `0`, `avg` `null`). - `packages/services/service-analytics/src/preview-evaluator.ts`, the `sum` arm and the `avg` arm. The operand lists are unchanged. The `default:` arm and the region PR #20729 re-anchored are not touched. - **Pipeline dump.** `pipelineDumpReplacer` renders a function by its name. Without that, `JSON.stringify` drops the accumulator's functions, and a `sum` measure and an `avg` measure would dump identically in `result.sql`. - ⛔ There is no wrapping of PostgreSQL or MySQL native accumulation, per #20489's ruling. `count`, `min` and `max` are untouched. Why `Clause-②: yes`: the hoist adds one root export (`compensatedSum`) to `@objectstack/core`, which widens its published surface. The changeset `.changeset/20544-compensated-sum-every-face.md` is `@objectstack/core` `minor`, with `patch` for `@objectstack/objectql`, `@objectstack/driver-memory` and `@objectstack/service-analytics`. ## Reproduction: before and after Scratch script (not committed), run with `tsx` over the packages' sources and a rebuilt `@objectstack/core`. Setup: a driver-memory `ObjectQL` engine, one `number` column `w`, one group per fixture. - **Native path:** `engine.aggregate` with `sum` / `avg`. A spy counted 1 `driver.aggregate` call. - **Rows path (the control):** the same query plus a filtered sibling `count`. The spy counted 0 `driver.aggregate` calls. - **Analytics face:** `MemoryAnalyticsService.query` over a cube on the same table. - **Draft preview:** `evaluateAnalyticsQueryOverRows` over the same rows. **Before**, at base `d2820876f` (`sum` / `avg`): | face | `0.1, 0.2, 0.3` | `1e16, 1, -1e16` | `0.1, 0.2` (control) | `1, 2, 3, 40, 500` (control) | |:--|:--|:--|:--|:--| | rows path (control) | `0.6` / `0.19999999999999998` | `1` / `0.3333333333333333` | `0.30000000000000004` / `0.15000000000000002` | `546` / `109.2` | | driver-memory native (`engine.aggregate`) | `0.6000000000000001` / `0.20000000000000004` | `0` / `0` | the same as rows | the same as rows | | driver-memory analytics face | `0.6000000000000001` / `0.20000000000000004` | `0` / `0` | the same | the same | | draft preview | `0.6000000000000001` / `0.20000000000000004` | `0` / `0` | the same | the same | `having { s: { $eq: 0.6 } }` through `engine.aggregate` kept no group on the native path and kept `card` on the rows path. **After**, at `37a825e0c` with core rebuilt: all four faces answer the rows-path row in every column. `having { s: { $eq: 0.6 } }` keeps `card` on both paths. The analytics face's measured `order` over the `sum` measure changed from `cancel=0, two, card=0.6000000000000001, ints` to `two, card=0.6, cancel=1, ints`. ## Mechanism hypotheses: which held - **H1 held.** `compensatedSum` was private at `in-memory-aggregation.ts:317`, and `bucketDateKey` reaches the core root through `export * from './utils/datetime.js'` (`index.ts:50`). Core's `exports` map has only `.` and `./logger`. So the helper cannot be shared without widening the published surface: a new subpath would widen it too, and a copy per package is what triage ruled out. The line `Clause-②: yes` / core `minor` stands. - **H2 held, and the analytics-face route was measured before it was chosen** (mingo 7.2.4, scratch probe): - A caller's `Context` cannot replace `$sum`. `Context.from` merges the built-ins first and `addOps` keeps an operator that is already there. A context whose own `$sum` returns `42` still answered `0.6000000000000001`. A new operator name works (`$mySum` answered `42`), but only in an `Aggregator` built with that context. This face builds two: the driver's public `aggregate()` and its own time-bucket half. - `$accumulator` is in the default operator set. `ComputeOptions.init` defaults `scriptEnabled` to `true`: the probe ran with default options, and with `scriptEnabled: false` it refused (`$accumulator requires 'scriptEnabled' option to be true`). - A post-group recompute is ruled out by the constraint `numericAggregandExpr`'s header already records: it runs after `$sort` / `$limit`. - ⇒ `$accumulator`. The time-bucketed pipeline and `order` by the measure are pinned. - The other two folds were where H2 put them: `memory-driver.ts:2022` and `preview-evaluator.ts:514` / `:550` at base. - **H3 held.** Every `avg` is the compensated sum divided by the count: `0.19999999999999998` on all four faces, which is the rows path's answer on the same values. - **H4 held.** `count` / `min` / `max` arms are untouched, and integers are unchanged on every face (pinned). Stated boundary: this holds while the running total stays within 2^53. Beyond it the compensated total is the exact one, as PR #20543 recorded for the rows path (`2^53, 1, 1` → `9007199254740994`). That boundary now applies to driver-memory's faces and the preview too. ## Tests **New pins.** Each face gets the card's `0.1 + 0.2 + 0.3` fixture, the `1e16` cancellation, a two-addend control and an integers control. Each asserts the naive fold's answer beside the expected one, so a fixture that cannot tell the folds apart fails. - `packages/core/src/utils/compensated-sum.test.ts`: 6 cases, including the empty list and non-finite totals (`Object.is` against the naive answer). - `packages/drivers/driver-memory/src/memory-compensated-sum.test.ts`: 12 cases. - Data face: `aggregate(AST)`, `find()`, the having reading, the addend rule, the empty group. - Analytics face: grouped, the time-bucketed split pipeline, `order` by the measure, the addend rule, the empty group, and the dump naming each measure's fold. - `packages/services/service-analytics/src/__tests__/preview-compensated-sum.test.ts`: 3 cases. It is a differential between the preview and the live face (`NativeSQLStrategy`'s SQL on sql.js SQLite): two `AnalyticsService` instances that differ only in `draftRowsResolver`. - objectql's existing `in-memory-aggregation-compensated-sum.test.ts` is unchanged and now runs through the core export. **Suites and typecheck, at head `b7e98273`** (after merging `origin/main` `f927864ea`): - `pnpm --filter` typecheck over `@objectstack/core`, `@objectstack/objectql`, `@objectstack/driver-memory` and `@objectstack/service-analytics`: all four `Done`. core `check:test-typecheck` OK (4 files / 4 errors held). objectql `check:test-typecheck` OK (40 / 234 / 65 held). - `tsc --listFiles` counts the three new test files once each in their packages' programs. - Tests: - core: 58 files / 1542 tests passed; - driver-memory: 65 / 1470; - service-analytics: 138 / 3219; - objectql (`--project local`): 337 / 6687. - Before the merge, at `e07690e1d`, the counts were the same except objectql at 336 / 6679. Main added one objectql test file. - Declared to CI: objectql's and core's `test:repo` projects. Their files do not read this surface. ## Reverse verification (one-time, from committed state `e07690e1d`) - **Tool.** `scripts/ablation-replace.mjs` in wrap mode on `packages/core/src/utils/compensated-sum.ts`. The anchor `return Number.isFinite(c) ? s + c : s;` became `const ablation20544 = s; return ablation20544;`, which is the naive running sum. - Anchor count went 1 → 0, and the blob went `30e811c70045` → `c2886a45b2d8`. - **Build.** `pnpm --filter @objectstack/core build`, then `ablation-dist-preflight.mjs @objectstack/core ablation20544` found the marker in 2 built files. objectql's and service-analytics' suites resolve core through `dist/`; driver-memory's aliases core to `src/`. - **Predicted before the run:** core 2 red / 4 green, driver-memory 6 / 6, preview 2 / 1, objectql 6 / 5. **Observed:** the same in every package. - core: 2 failed / 4 passed of 6; - driver-memory: 6 failed / 6 passed of 12; - preview: 2 failed / 1 passed of 3; - objectql: 6 failed / 5 passed of 11. - The objectql red is also the proof that the rows path now runs the core export. - **Restore leg.** - The restore was proven: blob equal to HEAD, and `git diff HEAD` empty. - Then core was rebuilt. `--absent` found the marker absent from all 14 built files, and the tree was clean. - All four files were green again: 6 / 12 / 3 / 11. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) at `b7e98273` derived 67 commands. All 67 were run. - 65 exited 0 on the first run. - `check:dual-build-cjs-loads` and `check:type-check-debt` first answered `PREREQUISITE NOT MET` (exit 3, no `dist/` for other packages). After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (71 / 71 tasks), both exited 0. - `--ran` with exit codes: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero). - `check:driver-conformance`, before (base `d2820876f`) and after (`b7e98273`): `50 covered cell(s), 0 in the DEBT ledger, 0 exempt` both times. The dialect axis is unchanged. - `pnpm --filter @objectstack/spec check:api-surface`: `public API surface + factory signatures unchanged`. - That gate snapshots `@objectstack/spec` only, and spec is untouched. - No gate snapshots `@objectstack/core`'s exports. Its one-export widening is declared by `Clause-②: yes` and the `minor` changeset. - Also green: `check:adr-0087-registration` (1 non-breaking changeset), `check-changeset-no-major`, `check:empty-changeset`, `check:issue-citations` (10 citations, all resolve), `check:doc-authoring`, `check:nul-bytes`, `check:engine-double-contract`, `check:cross-package-test-inputs`, `check:test-source-alias` and `check:undeclared-dep-imports`. **Lint: a declared narrowing, not a full run.** `pnpm exec eslint --no-inline-config --format json` over the 9 touched source files, at `b7e98273`, gave 9 files linted, 0 errors and 0 warnings. - **Population.** It is read from `eslint.config.mjs`: `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` and the `packages/**` blocks, minus `NEVER_LINTED`. All 9 `.ts` files are in it. The changeset `.md` matches no `files` glob. - **Invariance.** The config never enables type-aware linting: no `parserOptions.project` and no typed `@typescript-eslint` rules, as its own comment near `QUERY_OPTIONS_TEST_GLOBS` states. So this diff cannot move the verdict on any untouched file. - The full `pnpm lint` is CI's. ## Acceptance notes - `preview-evaluator.ts`'s `default:` arm (custom-SQL metric types) still adds with `reduce`. The file records it as the historical answer with no live standard to move towards, and no card names it. Not changed. Carrier: none. - `driver-sql.ts`'s `AGGREGATE_ACCUMULATION` residual note still points at the rows path (`in-memory-aggregation.ts`, `compensatedSum`). That is still true, because the rows path calls it by that name. The note does not mention that the helper now lives in core, or that driver-memory's faces use it too. It is outside this card's file surface, so it was not edited. Carrier: none. - A third-party pipeline passed straight to `InMemoryDriver.aggregate(object, pipeline)` with its own `$sum` / `$avg` still gets mingo's plain loop. The platform's own producer of that arm (the analytics face) no longer emits them for `sum` / `avg` measures, and `count` keeps `$sum: 1`, whose integers are exact. - The PR #20729 region of `preview-evaluator.ts` (about `:625`) is untouched. That PR has landed on `main`, and this branch merged it. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03cdb9a commit b785c3b

10 files changed

Lines changed: 624 additions & 46 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/driver-memory': patch
5+
'@objectstack/service-analytics': patch
6+
---
7+
8+
fix: `sum` / `avg` answer the same double on every face the platform owns, added with one compensated fold that `@objectstack/core` now exports as `compensatedSum` (#20544)
9+
10+
Clause-②: yes
11+
12+
**New export.** `@objectstack/core` exports `compensatedSum(nums)`: the sum of
13+
`nums`, added in order with Kahan-Babuska-Neumaier compensation, which is the
14+
summation SQLite (3.43 and later) uses for its own `sum` and `avg`. It moved
15+
here from `@objectstack/objectql`'s rows path (`in-memory-aggregation.ts`),
16+
which now imports it instead of keeping a private copy.
17+
18+
**What changed.** Three folds still added a group's values naively, and now call
19+
the same function:
20+
21+
- `@objectstack/driver-memory`'s `aggregate()` and `find()` with aggregations,
22+
the path `engine.aggregate` takes on an in-memory datasource;
23+
- `@objectstack/driver-memory`'s analytics face (`MemoryAnalyticsService`),
24+
whose `sum` / `avg` measures are now a `$group` `$accumulator` in place of
25+
mingo's `$sum` / `$avg`;
26+
- `@objectstack/service-analytics`' draft preview.
27+
28+
Over a `number` column holding `0.1`, `0.2` and `0.3`, each of them answered
29+
`0.6000000000000001` / `0.20000000000000004`. They now answer `0.6` /
30+
`0.19999999999999998`, as SQLite and the engine's rows path do. Over
31+
`1e16, 1, -1e16` they answered `0` and now answer `1`. On driver-memory,
32+
`engine.aggregate` gave two answers depending on its path: `having { s: { $eq:
33+
0.6 } }` kept the group on the rows path and dropped it on the native path. It
34+
now keeps it on both.
35+
36+
**What did not move.** Two addends, integers whose running total stays within
37+
2^53, and a non-finite total give the same answer as before. Which values count
38+
as addends did not change either: booleans as 1 / 0, and nulls and non-numeric
39+
strings left out, as each face already had it. `count`, `min` and `max` are
40+
untouched. The analytics face's pipeline dump (`result.sql`) now renders the
41+
accumulator's functions by name, so a `sum` measure and an `avg` measure still
42+
dump differently.
43+
44+
**Residual.** PostgreSQL and MySQL add their doubles natively without
45+
compensation, and the platform does not wrap that arithmetic. So over three or
46+
more fractions their native path can still differ from these faces in the last
47+
place. An exact `$eq` on a fractional sum compares doubles; compare with a range.

‎packages/core/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export * from './utils/env.js';
4949
// Export timezone-aware calendar utilities (ADR-0053 Phase 2)
5050
export * from './utils/datetime.js';
5151

52+
// [#20544] The ONE compensated fold for `sum` / `avg`, hoisted from
53+
// `@objectstack/objectql`'s rows path for the reason `bucketDateKey` above
54+
// was: `driver-memory`'s two faces and `service-analytics`' draft preview add
55+
// a group's values in JavaScript too, neither package has objectql among its
56+
// runtime dependencies, and a copy per face is how one `sum` came to answer
57+
// two doubles.
58+
export * from './utils/compensated-sum.js';
59+
5260
// Export the shared batched-write helper (framework#2678)
5361
export * from './utils/bulk-write.js';
5462

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20489, #20544] The sum of `nums`, added in order with
5+
* Kahan-Babuska-Neumaier compensation — the summation SQLite (3.43 and later)
6+
* uses for its own `sum` and `avg`, transcribed from its
7+
* `kahanBabuskaNeumaierStep` and the finalizers' overflow guard.
8+
*
9+
* Why: a naive fold (`reduce((a, b) => a + b, 0)`) and SQLite answer two
10+
* different doubles for the same values. A `number` column holding `0.1`,
11+
* `0.2` and `0.3` sums to `0.6` on SQLite and to `0.6000000000000001` naively
12+
* (`avg` `0.19999999999999998` against `0.20000000000000004`), so
13+
* `having { s: { $eq: 0.6 } }` kept a group on one face and dropped it on
14+
* another. Compensated, the faces agree, and the answer is the more accurate
15+
* one (`1e16 + 1 - 1e16` is `1`, not `0`).
16+
*
17+
* ## Why it lives here
18+
*
19+
* Every platform fold that adds a group's values in JavaScript calls this one
20+
* function, so a `sum` is the same double on every face the platform owns:
21+
*
22+
* - `@objectstack/objectql`'s rows path (`in-memory-aggregation.ts`, the fold
23+
* `engine.aggregate` runs itself), where it was written;
24+
* - `@objectstack/driver-memory`'s native `aggregate` (`memory-driver.ts`,
25+
* `computeAggregate`) and its analytics face (`memory-analytics.ts`, the
26+
* `sum` / `avg` measure accumulator);
27+
* - `@objectstack/service-analytics`' draft preview (`preview-evaluator.ts`,
28+
* the `sum` / `avg` arms).
29+
*
30+
* Neither `driver-memory` nor `service-analytics` has objectql among its
31+
* runtime dependencies, and this is the package all three already stand on —
32+
* the same reason `bucketDateKey` lives here. A second transcription is how a
33+
* face comes to answer its own double again.
34+
*
35+
* ## What does not move
36+
*
37+
* Two addends (the compensated `a + b` IS the naive one), integers whose
38+
* partial sums stay within 2^53 (every addition is exact), and a non-finite
39+
* total. `s` below is exactly the naive running sum; once it overflows or
40+
* meets a NaN, the error term is non-finite and the naive answer is returned
41+
* as it was, which is SQLite's rule too. An empty list sums to `0`. Which
42+
* values count as addends, and what an empty group answers, stay each
43+
* caller's own rule: this function only adds.
44+
*
45+
* ⚠️ Residual, stated: PostgreSQL and MySQL add their doubles natively without
46+
* compensation, and the platform does not wrap that arithmetic, so over three
47+
* or more fractions their native path can still differ from this one in the
48+
* last place. An exact `$eq` on a fractional sum compares doubles; compare
49+
* with a range.
50+
*/
51+
export function compensatedSum(nums: readonly number[]): number {
52+
let s = 0;
53+
let c = 0;
54+
for (const r of nums) {
55+
const t = s + r;
56+
c += Math.abs(s) > Math.abs(r) ? (s - t) + r : (r - t) + s;
57+
s = t;
58+
}
59+
return Number.isFinite(c) ? s + c : s;
60+
}

‎packages/drivers/driver-memory/src/memory-analytics.ts‎

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import {
2828
bucketDateKey,
2929
isBucketGranularity,
3030
type BucketGranularity,
31+
// [#20544] The ONE compensated fold for `sum` / `avg`, the one objectql's
32+
// rows path, this package's data face and SQLite add with — see
33+
// {@link compensatedAddendAccumulator}.
34+
compensatedSum,
3135
} from '@objectstack/core';
3236
// [#16178] The pipeline below is split at its `$group` when a time dimension
3337
// buckets, so the bucket key can be folded in JS between the two halves — mingo
@@ -647,6 +651,80 @@ function numericAggregandExpr(path: string): Record<string, unknown> {
647651
return { $cond: [{ $eq: [{ $type: path }, 'bool'] }, { $cond: [path, 1, 0] }, path] };
648652
}
649653

654+
/**
655+
* [#20544] A `sum` or `avg` measure as ONE `$group` accumulator that adds with
656+
* `@objectstack/core`'s {@link compensatedSum} — the fold objectql's rows path,
657+
* this package's data face (`memory-driver.ts`, `computeAggregate`) and SQLite
658+
* add with.
659+
*
660+
* ## What it replaced
661+
*
662+
* mingo's `$sum` and `$avg` add in a plain loop, so this face answered
663+
* `0.6000000000000001` / `0.20000000000000004` over `0.1`, `0.2` and `0.3`
664+
* where the rows path and SQLite answer `0.6` / `0.19999999999999998`, and
665+
* `1e16, 1, -1e16` summed to `0` rather than `1`.
666+
*
667+
* ## Why an `$accumulator`, measured against the other two routes (mingo 7.2.4)
668+
*
669+
* - **A post-group recompute** is ruled out by the reason in
670+
* {@link numericAggregandExpr}'s header: it runs after the pipeline's own
671+
* `$sort` and `$limit`, so `order` over a `sum` measure would rank the value
672+
* the measure does not answer.
673+
* - **A custom accumulator operator** cannot replace `$sum` / `$avg` through
674+
* the `mingo` entry point this package imports: its `Aggregator` merges the
675+
* default operators first (`Context.from`), and `addOps` keeps an operator
676+
* already present, so a caller's context can only ADD a name. A new name
677+
* would have to be registered where each `Aggregator` is built — the
678+
* driver's public `aggregate()` and {@link MemoryAnalyticsService}'s own
679+
* time-bucket half — widening the pipeline dialect the driver accepts.
680+
* - **`$accumulator`** is in mingo's default operator set and needs
681+
* `scriptEnabled`, which `ComputeOptions.init` defaults to `true`; both
682+
* `Aggregator`s this face runs take the default options. It stays inside the
683+
* `$group` stage, so every later stage sees the finished number.
684+
*
685+
* ## What does not move
686+
*
687+
* Only the addition. The aggregand is {@link numericAggregandExpr}, as before,
688+
* and the addends are the values mingo's own `$sum` / `$avg` add: numbers,
689+
* NaN excluded (mingo's `isNumber`), so null, a missing key and a non-numeric
690+
* string stay ignored. `avg` over no addend is `null`, `sum` over none is `0`,
691+
* exactly as mingo answered. The values are added in the group's row order,
692+
* the order mingo's `$push` collects them in, so the naive running sum inside
693+
* {@link compensatedSum} is the one `$sum` computed.
694+
*
695+
* The functions are named, and {@link pipelineDumpReplacer} renders a function
696+
* by its name, so the pipeline dump still says which fold a measure runs.
697+
*/
698+
function compensatedAddendAccumulator(path: string, fn: 'sum' | 'avg'): Record<string, unknown> {
699+
return {
700+
$accumulator: {
701+
init: startAddends,
702+
accumulateArgs: [numericAggregandExpr(path)],
703+
accumulate: collectAddend,
704+
finalize: fn === 'sum' ? compensatedSumOfAddends : compensatedMeanOfAddends,
705+
lang: 'js',
706+
},
707+
};
708+
}
709+
710+
function startAddends(): number[] {
711+
return [];
712+
}
713+
714+
/** mingo's `isNumber`: the values its `$sum` and `$avg` add. */
715+
function collectAddend(addends: number[], value: unknown): number[] {
716+
if (typeof value === 'number' && !Number.isNaN(value)) addends.push(value);
717+
return addends;
718+
}
719+
720+
function compensatedSumOfAddends(addends: readonly number[]): number {
721+
return compensatedSum(addends);
722+
}
723+
724+
function compensatedMeanOfAddends(addends: readonly number[]): number | null {
725+
return addends.length === 0 ? null : compensatedSum(addends) / addends.length;
726+
}
727+
650728
/**
651729
* [#7853] A `JSON.stringify` replacer that renders a `RegExp` operand instead of
652730
* dropping it — the one value type the pipeline dump carries that
@@ -700,7 +778,13 @@ function numericAggregandExpr(path: string): Record<string, unknown> {
700778
* EXECUTION, before this dump is ever built, so no replacer here reaches it.
701779
*/
702780
function pipelineDumpReplacer(_key: string, value: unknown): unknown {
703-
return value instanceof RegExp ? `/${value.source}/${value.flags}` : value;
781+
if (value instanceof RegExp) return `/${value.source}/${value.flags}`;
782+
// [#20544] A function is the other value `JSON.stringify` erases, and the
783+
// `sum` / `avg` `$accumulator` carries three ({@link
784+
// compensatedAddendAccumulator}). Dropped, the two measures dump identically;
785+
// by name, the dump still says which fold each one runs.
786+
if (typeof value === 'function') return `[function ${value.name}]`;
787+
return value;
704788
}
705789

706790
/**
@@ -1728,10 +1812,12 @@ export class MemoryAnalyticsService implements IAnalyticsService {
17281812
switch (measure.type) {
17291813
case 'count':
17301814
return { $sum: 1 };
1815+
// [#20544] Compensated, as every other face the platform owns adds —
1816+
// see {@link compensatedAddendAccumulator}.
17311817
case 'sum':
1732-
return { $sum: numericAggregandExpr(`$${fieldPath}`) };
1818+
return compensatedAddendAccumulator(`$${fieldPath}`, 'sum');
17331819
case 'avg':
1734-
return { $avg: numericAggregandExpr(`$${fieldPath}`) };
1820+
return compensatedAddendAccumulator(`$${fieldPath}`, 'avg');
17351821
// [#11152] `min`/`max` take the SAME boolean coercion as `sum`/`avg` —
17361822
// maintainer ruling 2026-08-28 (superseding #11249's `false`/`true`):
17371823
// booleans aggregate as NUMBERS on every face, no per-aggregate

0 commit comments

Comments
 (0)