fix(service-analytics): a draft-preview min/max answers the operand's own type instead of 0 - #16235
fix(service-analytics): a draft-preview min/max answers the operand's own type instead of 0#16235os-warren wants to merge 2 commits into
min/max answers the operand's own type instead of 0#16235Conversation
… own type `evaluateAnalyticsQueryOverRows` coerced every aggregate operand with `Number()` and dropped the non-finite ones, so `min`/`max` over a non-numeric field answered `0` on the draft-preview path while the live path answered the value itself — a different, wrong answer to the same query, with no refusal and no warning. Every dimension column it minted was typed `'string'` for the same reason (the producer assumed both). `min`/`max` now return the winning operand in its own type, ordered by this file's shared `compare` with a numeric arm for numeric operands, and a group with only nulls answers `null` (`emptyGroupValueFor`) instead of `0`. `count_distinct`'s arm was unreachable — the switch spelled it `countDistinct`, which no producer mints — so it fell to the numeric default and answered a row count; it is now spelled as the spec spells it. A dimension column takes the cube dimension's own type, the same expression the live producers use. `sum`/`avg` over a temporal operand is deliberately unchanged: there is no defined answer and #16099 owns refusing the pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Graded `minor` on the precedent of the descriptor half of the same question (the `measureResultType` changeset, also `minor`): this changes what `POST /api/v1/analytics/dataset/query` RETURNS on the draft-preview path — a `min`/`max` over a non-numeric field, a `count_distinct`, and a dimension column's declared type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 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 cb886b5dd986b6c74621078a79c9b27f453486f5 && git checkout cb886b5dd986b6c74621078a79c9b27f453486f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ebde49536b431dc891b4bbe1a144b638c6205ae 00e99d9d7561f8c744d0c098f3bc3de0b26aa340 && git checkout -B drift-repro 1ebde49536b431dc891b4bbe1a144b638c6205ae && git merge --no-ff 00e99d9d7561f8c744d0c098f3bc3de0b26aa340
node scripts/docs-audit/affected-docs.mjs --json 1ebde49536b431dc891b4bbe1a144b638c6205ae
|
PM verification — PASSMeasured at head Fence — held, by a LOCAL merge-base diff
The unreachable arm — confirmed, and confirmed unreachable from any validated cubeThe pre-fix blob (
So the arm was unreachable from the dataset path and from a hand-authored cube (zod rejects the spelling in both enums). It was dead, and the measure fell to the numeric Also checked the arm that moved out from under The
|
Contract review (clause ②) — PASS · landing held on the pair declaration — PR #16235 at head
|
Fixes #16203
The draft-preview evaluator assumed every measure is numeric and every dimension is a string. Both halves are produced by
evaluateAnalyticsQueryOverRows(packages/services/service-analytics/src/preview-evaluator.ts) before any descriptor pass runs, so no enrichment could reach either — and the severe half is a wrong value, not a descriptor gap.Driven, not read — the two-instance harness
One dataset, one row set, two
AnalyticsServiceinstances differing in exactly one config key (draftRowsResolver), so a difference between the two responses is a difference the preview evaluator caused. The LIVE half is not a model of an engine: it isNativeSQLStrategy's generated SQL executed on a real SQLite (sql.js) whose table is seeded from the same rows the resolver hands the preview, withdatestored as TEXT (this platform's canonical storage form, ADR-0053 D-B).Measured on
main@159dbad23, before the fix (12 pins, 7 red / 5 green):latest_spend—maxover adate'2026-05-12'0'2026-05-12'earliest_spend—minover adate'2026-04-21'0'2026-04-21'first_payer—minover text'ann'0'ann'last_payer—maxover text'bob'0'bob'min/maxover a group whose operand is null throughoutnull0nullmaxover a BSONDate(mongo storage form)1785110400000(epoch leak)Datedistinct_payers—count_distinctover text23(a row count)2latest_per_expense— derived ratio overlatest_spendnull0nullfields[].typeof adatedimension column'time''string''time'min_amount/max_amountover a number500/1200500/1200500/1200expense_count,total_amount,avg_amountsum_spent_on/avg_spent_on— the temporal pair00(untouched)Per aggregate — the whole closed vocabulary, every member answered
AggregationFunction(packages/spec/src/data/query.zod.ts) is CLOSED, so this has a finite answer; the sibling ruling for the DESCRIPTOR half of the same question (measure-result-type.ts, #15768 / #16101) answers it identically:countcount_distinctcountDistinct, a word no producer mints (dataset-compilercopies the spec'scount_distinctthrough), so it fell to the numericdefaultand answered a row count under the author's measure name. Now spelled as the spec spells it. ⛔ Still numeric — typing it otherwise would be a new bug.sumavgminmaxmin/maxnow return the winning operand itself, ordered by this file's sharedcompare— so an ISO date orders as a date, a BSONDateorders as its instant against wire text, and text orders the wayMIN(text_col)orders on a SQL face — with a numeric arm (numericOperand) so a numeric column authored as text ('800') still orders numerically instead of lexicographically.cross-object-rebucket.tssettled the identical question for the recombination path (#3797) the same way: the value these two pick is a value OF the column, so it has to come back in the shape the row carried; coercing it to a number is what produced the epoch leak in the table above.A group whose operand is null throughout now answers
nullrather than0, which isemptyGroupValueFor's ruling in@objectstack/spec/data— "averaging, minimising or maximising no rows is undefined … and must stay null rather than be flattened to a zero that reads as a real measurement".The ordering arm is deliberately NOT folded into
compareitself: that primitive also decideswherefiltering andorder, whose comparand comes from the query rather than from a sibling row, so widening it would move populations this card never measured.⛔ What is deliberately left unchanged
sum/avgover a TEMPORAL operand. There is no defined answer, and the two faces do not agree on one: the preview drops every non-finite operand and answers the identity0, while SQLite applies numeric affinity to the TEXT column and sums the leading years. Neither number is a date and no layer refuses the pair — which is No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099's subject, an openneeds-user-decisioncard, not this one's to pre-empt. The pin below asserts today's preview value and only that the two faces disagree, so it is a statement about the missing refusal rather than a pin on a dialect. It passed before this change and passes after: the pair is measurably untouched.computeDerivedstill coerces withNumber()and answersnullfor a non-finite operand — this change does not reach that function, it changes what the function READS. A derived ratio over a temporalmin/maxnow sees a date instead of the spurious0, so it answersnullon the preview path exactly as it already did on the live one. Measured both ways in the table above.fields[].type. It stays the'number'every producer in the platform mints, live faces included. Correcting it ismeasureResultType's one rule applied at the ADR-0021 descriptor pass (A dataset measure over a datetime is typednumberin the analytics response, and a metric tile ignores theformatstyle it is handed — measured on 17.3.0 #15768 / fix(analytics): a dataset measure's result type stops contradicting its own value — min/max over a temporal field istime, notnumber#16101); a second copy here would be two implementations free to drift, over a question this producer cannot answer anyway (it holds the cube, not the source object's declared field types). ⛔ The descriptor is not made to lie and is not withheld: after the enrichment reaches this path,latest_spendis described astype: 'time'and the value beside it is now the date.The pins, and the population each covers
New file:
packages/services/service-analytics/src/__tests__/preview-aggregate-operand-type.test.ts(12 cases).min/maxover temporal — onedatecolumn, two groups, both bounds (minandmax), asserted against the literal AND against the live path.min/maxover text — thetext/select/lookuppopulation, which is Amin/maxover a text / select / lookup field is still described astype: "number"in the analytics response #16098's population on the live path and arrives here through the same defect.min/maxover numbers — the control that must NOT move: same numbers, stilltypeof 'number'. Green before and after.min/maxover an all-null group — theemptyGroupValueForboundary, driven through the evaluator directly.maxover a BSONDate—Field.datetime's storage form ondriver-mongodb(ADR-0053 D-E2), the populationcompare'sDatearm exists for.count/count_distinct/sum/avg— the rest of the vocabulary, each against the live path over the same rows.sum/avgover temporal — the deliberately-unchanged pair.max.fields[].type— adatedimension, astringdimension, and a measure column, each compared with the live producer's answer.Reverse verification — direction predicted before running
Restoring
preview-evaluator.tsto159dbad23and keeping the pins must go RED on exactly the seven cases above and leave the five controls green. Ordinary direction, no inversion: the change narrows no rule and removes no limb that another gate reads, so nothing downstream can gain a finding from it.Measured, committed first (
4df5628e0), mutation proven on disk by blob delta (2edec665…HEAD vscbe2c62d…mutated) plus anchored counts (extremumOf0,Math.min1), restored undertrap … EXIT INT TERMagainst an absolute path, restore proven by blob equality withHEADand an emptygit diff HEAD:No rebuild leg is owed and here is why: the pins import
../preview-evaluator.jsand../analytics-service.js— relative, same-package specifiers vitest compiles fromsrc/, so the mutated bytes are in the module graph directly and nothing in this ablation resolves through a packagedist/. A green ablation would have been the danger; this one is red on the predicted seven.Verification
All at head
00e99d9d7, each exit code captured immediately after a single redirected command, never through a pipe.pnpm --filter @objectstack/service-analytics test— 95 files / 2050 tests passed, exit 0.pnpm --filter @objectstack/service-analytics typecheck— exit 0.tsc --noEmit --listFilesconfirms both edited files are in the program (1 hit each), so the green covers them rather than skipping them.scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), re-derived on the final head — identical 55-family list; 55 runnable families plus the 4 artifact-roster gates whose roster sits under a path this change touches = 59 commands, all exit 0. Two needed their prerequisite built first (check:dual-build-cjs-loads,check:type-check-debt); the full closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 71/71) and both then answered 0 —check:type-check-debtre-measured 12 ledger entries, none above its recorded number.eslint --no-inline-config— the whole repo, not a narrowing: 6195 files, 0 errors, 0 warnings, exit 0.Out-of-scope findings, filed rather than absorbed
Both were measured on the same two-instance harness, both live in this file, and neither is this card's defect class (this one is about the operand's TYPE; both of those are about NULLs):
countover a FIELD counts rows, nulls included — live counts the non-null values, so the same measure answers 2 there and 1 on the engine #16218 —countover a FIELD counts rows including nulls on the preview path (2), while every SQL face lowersCOUNT("payer")and answers1. The identical defect was measured and cleared ondriver-mongodbby theAGGREGATION_CASESenrolment.0for anavgover a group with no numeric values —emptyGroupValueForrules that null, and the live path answers null #16219 —avgover a group with no numeric values answers0on the preview path while the live path answersnull, contradictingemptyGroupValueFor's explicit ruling. Themin/maxhalf of that same: 0idiom moved with this card becausemin/maxare its subject;avgis not, and moving it is a value change that deserves its own review.Collision fence
analytics-service.ts(#16192),text-match-sql.ts/like-pattern.ts(#16028) andmeasure-result-type.ts(#16098) are untouched — the diff ispreview-evaluator.ts, one new test file and one changeset.#16192 is not addressed hereand#16098 remains open.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code