Commit f3b28eb
* test(service-analytics): differential harness for the preview `avg` empty-group divergence
The failing half of #16219, written before the fix so its direction is a
measurement rather than a claim. Two `AnalyticsService` instances differing in
exactly one config key (`draftRowsResolver`); the live half is
`NativeSQLStrategy`'s SQL on a real SQLite seeded from the same rows.
Carries the four controls that must NOT move: `sum` over the all-null group
stays the ruled identity `0`, `count(field)` over it stays `0` (#16218),
`min`/`max` stay `null` (#16203), and `avg` over a group that has values still
answers the mean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* fix(service-analytics): draft preview averages non-null operands and answers null over none (#16219)
`aggregate()` in `preview-evaluator.ts` built its operand list with
const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
and `Number(null)` is `0`, which `Number.isFinite` accepts. So every NULL entered
an `avg` as a zero OPERAND and was counted in the divisor, while `AVG(col)` is
defined over non-null values in every SQL dialect. A group whose column was NULL
in every row therefore averaged to `0` — a plausible-looking average nobody
measured — and a group that did carry values averaged low: `(10 + 20 + 0) / 3`
against SQLite's 15.
⭐ The card attributed the defect to the arm's `: 0` fallback. Measured, that
branch never ran on the card's own cell: `nums` was `[0, 0]`, so the ternary took
its TRUE branch. The fallback is reachable only where no row carries a parseable
operand at all, and both limbs had to move for the divergence to close.
One arm. The operand list is rebuilt over the rows that carry a value — exactly
as `extremumOf` (`v == null` → skip) and #16218's `count` arm
(`r[field] != null`) already do — and the answer over none is READ from
`emptyGroupValueFor` (`@objectstack/spec/data`) rather than restated: it rules
`0` where counting or summing nothing is a measured fact and `undefined` (spelled
`null` on this wire) where there is nothing to answer. #16203 cited the same
function when it moved `min`/`max` off the same idiom here.
`sum` and the numeric `default` arm keep the existing `nums`: `0` is the additive
identity so the coercion never moved `sum`'s answer, and `default` serves the
custom-SQL metric types, which have no live standard to be moved towards.
Reproduced and closed through the differential harness #16203 built and #16218
reused — one dataset, one row set, two `AnalyticsService` instances differing
only in `draftRowsResolver`, the live half being `NativeSQLStrategy`'s generated
SQL executed on a real SQLite (sql.js) seeded from the same rows.
Controls held by the same fixture: `sum` over the all-null group stays the ruled
identity `0`, `count(field)` over it stays `0` (#16218), `min`/`max` stay `null`
(#16203), and `travel`'s six answers stay six different numbers.
No behaviour change on the live path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* fix(service-analytics): the preview `avg` null fires on an EMPTY group, never an incoherent one (#16219)
The full `service-analytics` suite caught a control the card's list did not
name: `preview-aggregate-operand-type.test.ts` (#16203) pins `avg` over a
TEMPORAL operand at the numeric identity `0`, and reading the whole
"no numeric operand" condition as "empty group" moved it to `null`.
"No numeric operand" is two situations, and only one of them is averaging
NOTHING:
• no row carried a value at all — the empty group, which `emptyGroupValueFor`
rules and now answers;
• rows carried values that do not read as numbers — a `date` column under
`avg`. That is an incoherent aggregate/field-type pair, #16099 owns the
refusal, no layer refuses it yet, and the live face answers a different
number again (SQLite's numeric affinity over TEXT). A `null` there would
invent a THIRD answer to a question nobody has ruled on.
So the arm splits the two: `present.length ? 0 : (emptyGroupValueFor(...) ?? null)`.
The boundary is now pinned from both sides — the #16203 file keeps its `0`, and
the new differential carries a control asserting the same `0` for values that are
present and unreadable as numbers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent d61139f commit f3b28eb
3 files changed
Lines changed: 486 additions & 4 deletions
File tree
- .changeset
- packages/services/service-analytics/src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
0 commit comments