Uh oh!
There was an error while loading. Please reload this page.
fix(driver-memory): count a boolean aggregand as 1/0 in avg and sum - #11153
Conversation
`avg` over a boolean column returned `null` on driver-memory while SQLite answered the arithmetically correct rate over the same rows, and objectql's in-memory fallback answered that same rate — driver-memory was the lone outlier of the three. Both of this package's faces carried the divergence independently: the data face filtered aggregands with `typeof v === 'number'`, and the analytics face emitted a bare mingo `$avg`, which ignores non-numeric values as MongoDB's does. `sum` shares the data face's arm and had the same defect one function over (`SUM(bool)` is "how many true" on every SQL face), so it is aligned with `avg` rather than left behind. The coercion is boolean-only: null, missing and non-numeric strings reach the accumulators unchanged and stay excluded, rather than being folded to 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
…ngeset The ablation was run after the fix was committed so it had a restore point: both coercions reverted to origin/main's expressions, the mutation confirmed on disk by marker counts in both directions, then restored by a trap. Measured 11 failed / 4 passed against a predicted 10 — the direction held exactly (every failure on a value, none on a throw) and the predicted count was off by one. Both numbers are recorded rather than the prediction being quietly rewritten to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 71481b0838259ab3c54ad214e6ef5aba40591b81 && git checkout 71481b0838259ab3c54ad214e6ef5aba40591b81
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 17bad125cca89674cfd2dca59025b0d31da68968 09c4fc44ebe6b0d3e1b7160a96a3f2f242044137 && git checkout -B drift-repro 17bad125cca89674cfd2dca59025b0d31da68968 && git merge --no-ff 09c4fc44ebe6b0d3e1b7160a96a3f2f242044137
node scripts/docs-audit/affected-docs.mjs --json 17bad125cca89674cfd2dca59025b0d31da68968 |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32602076972 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11065
avgover a boolean column returnednullondriver-memorywhile SQLite answered the arithmetically correct rate over the same rows.sumover the same column returned0. Both are aligned here, on both of this package's faces.The three implementations of "average a column", and which one was wrong
avgover a boolean columnSqliteWasmDriver)AVG(col)0.4/0.25— the reported, arithmetically correct numberspackages/objectql/src/in-memory-aggregation.tsvalues.filter(v => v != null).map(toNumber)toNumberisNumber(v)gated onNumber.isFinite, andNumber(true) === 1packages/drivers/driver-memory/src/memory-driver.tsvalues.filter(v => typeof v === 'number')null— every boolean is dropped,nums.length === 0, the arm returnsnullSo this is not a new convention: it is an alignment to the two implementations that already agreed. A refusal instead of a value was considered and is foreclosed — it would have to break
in-memory-aggregation.tstoo, which is a contract change needing its own ruling.Which face the repro reaches — traced, not grepped
driver-memoryhas two aggregate faces, and only a trace says which one the report's repro lands on. Instrumentingdriver.aggregateanddriver.findon a liveObjectQL.create+InMemoryDriverinstance over the reported dataset:Both aggregate calls arrive as a non-array
DriverQuery, which is the AST arm —performAggregation→computeAggregate. That isengine.aggregatepushing the aggregate down to the driver: it prefersdriver.aggregatewhenever the driver has the method and the query needs no in-memory bucketing (no structured date granularity, no non-UTC timezone, no per-aggregation filter), all true here. The singledriver.findcall is the insert path, not an aggregate. The reproduction is exact, includingsum_sla_violated: 0.The second face carried the same divergence, independently
memory-analytics.ts'sbuildAggregatoremitted a bare mingo$avg, and mingo ignores a non-numeric value exactly as MongoDB does. Measured directly against mingo 7.2.4 over five booleans:So it is in scope and fixed too. Aligning the data face alone would have left this one free to keep its own answer — the shape #6814 recorded on
count_distinct.The analytics face carries the rule as a
$groupexpression rather than as post-processing, deliberately: thecount_distinctneighbour's post-processing step runs after the pipeline's own$sortand$limitstages, so a sum or average left as a collected array until then would be sorted as an array, andorderover asum/avgmeasure is an ordinary analytics query.The
sumdecisionsumis coerced alongsideavg. It shares the data face's arm, andSUM(bool)is "how many true" on every SQL face and in the objectql fallback —2over these rows, where driver-memory answered0. Fixing only the function the report named would have left the identical defect alive one function over, in a form that is harder to notice:0is a plausible number wherenullis a visible hole.The coercion is boolean-only
null, a missing key and a non-numeric string reach the accumulators unchanged and stay excluded. Adopting the wider half oftoNumber— which maps a non-numeric string to0— would average garbage as zero, a separate question this PR does not open. Measured on the analytics face: a numeric column carrying a null, a string and a missing key answers identically with and without the wrapper ({avg: 15, sum: 30}both ways). A regression row pins the exclusion on both faces so the wider behaviour cannot arrive by accident.Reverse verification
Both coercions reverted to
origin/main's expressions, with the mutation confirmed on disk by marker counts in both directions (fixed markers 1/3 → 0/0; reverted markers 0 → 1/1) and restored by anEXIT INT TERMtrap.Predicted before running: 10 of the 15 rows fail, every one on a value rather than a throw, because both faces drop aggregands silently. Measured: 11 failed / 4 passed — the direction held exactly (
expected null to be 0.4,expected [ +0, +0 ] to deeply equal [ 2, 1 ],expected 'object' to be 'number'; not one throw), the predicted count was off by one. Both numbers are recorded in the test's docblock rather than the prediction being rewritten to match.No build stands between the pin and the mutation: the test imports the driver by relative path, so vitest runs
src. That is measured, not assumed — the package'sdist/was built fromorigin/mainbefore the fix and contains neither coercion, yet the unmutated run is green, which it could not be if the assertions were readingdist.Verification
Union re-run at final commit
09c4fc44e(working tree clean, so the tree measured is that commit's tree):pnpm --filter @objectstack/driver-memory test—Test Files 27 passed (27)/Tests 779 passed (779). The new file alone, verbose:Test Files 1 passed (1)/Tests 15 passed (15).pnpm --filter @objectstack/driver-memory typecheck— clean.pnpm lint(eslint . --no-inline-config, whole repo) — exit 0, no findings. Not narrowed.node scripts/pm/dispatch-gates.mjsfrom the real change set, all green:check:changeset-gate-self-tests,check:driver-conformance(OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt),check:objectui-changeset,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major(✓ This diff introduces no major bump),check-ci-filter-parity,check-empty-changeset,check-plugin-teardown-shape,check-affected-docs,check:nul-bytes.check:query-options-erasure,check:type-check-coverage,check:engine-double-contract(OK — 381 pinned),check:where-matcher, andcheck:type-check-debton a fully built workspace closure (--re-measure: OK — 33 ledger entr(ies) re-measured, none above its recorded number).Existing assertions
Nothing in the repo pinned the old
null. No existing assertion changed answer — the full package suite was green before and after. The reason the false-green could persist is structural and is reported separately below: the shared cross-driver fixtureAGGREGATION_ROWScarries no boolean column at all, so the aggregation-conformance family cannot see this cell on any driver.Scope
driver-memoryis under the 2026-08-05 investment freeze (#5499); this rides that ruling's explicit restore-invariant exception and stays inside it. Only theavg/sumaggregand rule on the two faces changed.packages/objectqlwas read as the reference and not edited, not imported from, and not refactored;packages/specuntouched.Out-of-scope findings — filed as their own cards, not addressed in this PR
avg/sumover a boolean column tonull/0— the same cell as #11065, one driver over #11151 —driver-mongodblowersavg/sumas bare$avg/$sum, the same shape this PR replaced, so it is the same cell one driver over.AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152 —AGGREGATION_ROWShas no boolean column, which is why every face could disagree here unobserved; it also namesmin/maxover booleans as a second, genuinely unsettled cell.Generated by Claude Code