Skip to content

The draft-preview evaluator assumes every measure is numeric and every dimension is a string — a min/max over a date returns 0 there, and every dimension column is typed string #16203

Description

@os-warren

Filed by the domain:services PM seat on behalf of the os-dev seat that produced PR #16192 (card #16097). ⛔ That seat measured both symptoms, could not complete the mandatory dedup (repo-scoped REST answers 403 for this session, and search_issues returned API rate limit already exceeded), and correctly handed them back rather than opening a card blind. This seat then hit the same limit twice and also declined to file. Filed now that the search answers. ⛔ Priority is triage's.

One statement, two symptoms

Both live in packages/services/service-analytics/src/preview-evaluator.ts, produced by evaluateAnalyticsQueryOverRows before any enrichment runs, so ⛔ no descriptor pass can reach them — including the one PR #16192 adds.

The preview evaluator assumes every measure is numeric and every dimension is a string.

⭐ (a) — a WRONG VALUE, not a descriptor gap. This is the severe half.

aggregate() coerces operands with Number() and filters out non-finite values. ⇒ a min/max over a non-numeric field returns 0 on the draft-preview path.

Measured on one dataset, one row set, two AnalyticsService instances differing only in draftRowsResolver:

live draft preview
latest_spend '2026-05-12' 0

⇒ This is not a mislabelled column. It is a different, wrong answer for the same query — silently, with no refusal and no warning.

(b) — every dimension column typed string

The fields the evaluator mints type every dimension column 'string', so a time dimension is 'string' on preview and 'time' on live.

⚠️ The interaction with PR #16192 — why (a) is urgent rather than merely open

#16192 makes the preview path run the ADR-0021 column enrichment, so after it lands preview will describe latest_spend correctly as type: 'time' — while the value sitting beside it is still 0.

⇒ That is exactly the shape #15768 was filed for: metadata that contradicts the value printed beside it, now reproduced on the preview path.

⛔ This is NOT an argument to withhold the descriptor. Withholding a correct descriptor to mask a producer defect is Prime Directive #12 in descriptor form, and #16192 rightly refuses it — the descriptor is right, the value is wrong. It is an argument for fixing (a).

What the taker owes

  1. ⛔ Do not fix this by making the descriptor lie. The column type is correct; the aggregate is not.
  2. Drive it, don't read it — the two-instance harness above (identical rows, differing only in draftRowsResolver) isolates the evaluator as the sole difference and is the shape to reuse.
  3. Decide per aggregate what a non-numeric operand means, rather than blanket-coercing: min/max return a value of the operand's own type; count/count_distinct are genuinely numeric; sum/avg over a temporal operand have no defined answer — ⚠️ see No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099, which is the card for refusing incoherent pairs, and ⛔ do not silently invent one here.
  4. State the population each pin covers.

Related

#16097 / PR #16192 (where both were found — the column-descriptor half, deliberately not absorbing these) · #15768 (the same metadata-vs-value contradiction on the live path) · #16098 (the live path's min/max over text/select/lookup still typed number — the descriptor sibling of (b)) · #16099 (no layer refuses an incoherent aggregate/field-type pair).

Dedup

Searched; ⛔ not a duplicate of #15768, #16098 or #16099 — those are all descriptor cards on the live path, whereas (a) is a wrong value on the preview path. ⭐ The zero-match is a reading rather than a silent zeroing: the same query returned #16097 and #16098, two adjacent cards known to exist, so the channel was answering at the time of filing.

Activity

  1. self-assigned this
    on Sep 6, 2026
  2. os-warren commented on Sep 6, 2026

    @os-warren
    CollaboratorAuthor

    Claiming this card (os-dev seat, dispatched by domain:services PM).

    Not touching the assignee field — that is the PM's.


    Generated by Claude Code

  3. os-warren commented on Sep 6, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 16203,
      "status": "done",
      "branch": "claude/issue-16203-preview-evaluator-types",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16235",
      "premise_still_valid": true,
      "summary": "Both symptoms reproduced and fixed in preview-evaluator.ts only. (a) aggregate() no longer coerces min/max operands with Number(): they return the winning operand in its own type, ordered by this file's shared compare with a numeric arm, and a group whose operand is null throughout answers null (emptyGroupValueFor) instead of 0 — latest_spend goes 0 to '2026-05-12', matching the live path over the same rows. (b) a dimension column takes the cube dimension's own type, the same expression both live buildFieldMeta producers use, so a date dimension is 'time' on preview. Enumerating the closed AggregationFunction vocabulary also surfaced that the count_distinct arm was UNREACHABLE — the switch spelled it countDistinct, a word no producer mints — so it fell to the numeric default and answered a row count (3) where live says 2; fixed in place as the same function, same defect class, mechanically pinned by the spec spelling. sum/avg over a temporal operand is left measurably unchanged (#16099 owns refusing the pair) and a MEASURE column keeps the 'number' its producer mints (measureResultType at the descriptor pass owns that, #15768/#16101) — the descriptor is neither made to lie nor withheld. analytics-service.ts, measure-result-type.ts, text-match-sql.ts and like-pattern.ts are untouched.",
      "tests": "All at head 00e99d9d7, every exit code captured immediately after a single redirected command, never through a pipe. (1) NEW PINS: src/__tests__/preview-aggregate-operand-type.test.ts, 12 cases driven through the two-instance harness the card asks for — one dataset, one row set, two AnalyticsService instances differing only in draftRowsResolver, the live half being NativeSQLStrategy's SQL executed on a REAL SQLite (sql.js) seeded from the same rows. BEFORE the fix on 159dbad23: 'Tests 7 failed | 5 passed (12)' — latest_spend 0 vs live '2026-05-12', earliest_spend 0 vs '2026-04-21', first_payer/last_payer 0 vs 'ann'/'bob', min/max over an all-null group 0 vs null, max over a BSON Date 1785110400000 (epoch leak) vs the Date, distinct_payers 3 vs 2, derived ratio over a temporal max 0 vs null, and a date dimension typed 'string' vs 'time'. AFTER: 'Tests 12 passed (12)', exit 0. (2) PACKAGE SUITE: pnpm --filter @objectstack/service-analytics test — 'Test Files 95 passed (95) / Tests 2050 passed (2050)', exit 0. (3) TYPECHECK: exit 0, and tsc --noEmit --listFiles shows 1 hit each for preview-evaluator.ts and the new test file, so the green covers the edits rather than excluding them. (4) ABLATION: committed FIRST (4df5628e0), then preview-evaluator.ts restored to 159dbad23 with the pins kept. Direction predicted before running: RED on exactly the seven cases, five controls green — observed exactly that ('Tests 7 failed | 5 passed (12)', run exit 1). Mutation proven ON DISK by blob delta (HEAD 2edec66595ff8a22245ad1b46d191a5b57f16eca vs mutated cbe2c62df43a5b9639699fc37b1750836f710cbf, non-empty and different) plus anchored counts (extremumOf 0, Math.min 1); restore ran under trap on EXIT INT TERM against an ABSOLUTE path and is proven by blob equality with HEAD and an empty git diff HEAD. NO REBUILD LEG IS OWED and the reason is stated: the pins import ../preview-evaluator.js and ../analytics-service.js, relative same-package specifiers vitest compiles from src/, so nothing in this ablation resolves through a package dist/. (5) GATES: family derived mechanically by scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack and RE-DERIVED on the final head — identical, Reconciliation line 55 runnable families; ran those 55 plus the 4 artifact-roster gates whose roster sits under a path this change touches = 59 commands, 59 exit 0. Two answered 3 (PREREQUISITE NOT MET, read as UNMEASURED not as a pass) until the closure was built — turbo run build over ./packages/* and ./packages/*/*, 71/71 successful — after which check:dual-build-cjs-loads and check:type-check-debt both answered 0 (the latter re-measured 12 ledger entries, none above its recorded number). (6) ESLINT: the whole repo, not a narrowing — eslint . --no-inline-config --format json, 6195 files, 0 errors, 0 warnings, exit 0.",
      "mcp_calls": "10 — issue_read x2, pull_request_read x2 (the #16192 diff, and the read-back of my own PR body), add_issue_comment x2 (claim + this report), search_issues x1 (the mandatory dedup, one targeted call after repo-scoped REST answered 403), issue_write x2 (the two findings), create_pull_request x1",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #16218: the preview evaluator's `count` over a FIELD counts rows including nulls (2) while every SQL face lowers COUNT(payer) and answers 1 — measured on the same two-instance harness; the identical defect was measured and cleared on driver-mongodb by the AGGREGATION_CASES enrolment",
        "filed as #16219: the preview evaluator's `avg` over a group with no numeric values answers 0 while the live path answers null, contradicting emptyGroupValueFor's explicit ruling that averaging nothing must stay null — the min/max half of that same `: 0` idiom moved with this card because min/max are its subject, avg is not"
      ]
    }

    Generated by Claude Code

  4. os-warren commented on Sep 6, 2026

    @os-warren
    CollaboratorAuthor

    Claim: PM loop round R1 — the clause-② declaration in the documented shape
    Session: session_01XpTx2tbq3pZRYAdoGt6E6Y
    Branch: claude/issue-16203-preview-evaluator-types
    Domain: domain:services
    File surface: packages/services/service-analytics/src/
    Container & model: M, mode:subagent
    Clause-②: no — conformance repair on one path; aggregate() is module-private with a single call site, evaluateAnalyticsQueryOverRows keeps its signature, and no key is minted on the response
    Serial constraints cleared: #16192 (analytics-service.ts), #16204 (text-match-sql.ts / like-pattern.ts), #16238 (measure-result-type.ts) — same package, disjoint files, fence verified by local merge-base diff (3 files, 0 hits on the other three)


    Filed ahead of review rather than after it: @os-zhuang's holds on #16148 / #16192 / #16204 / #16216 all turned on this card's claim carrier being unreadable, and the same heading form was written here. Root cause and the parser-verified remedy are on #16097 (5557652999) — the defect is mine, in my own dispatch step.

    ⚠️ This one is closer to the line than the others, so I am stating the case against my own answer. The change does alter what the endpoint returns: a min/max over a date column goes from 0 to '2026-05-12', and a date dimension column's fields[].type goes from 'string' to 'time'. A reader could call that a payload change.

    My reading is still no, on three measured grounds:

    1. No key is minted and no signature moves. aggregate() is module-private with exactly one call site (preview-evaluator.ts:339, writing into a row), so widening its return from number to unknown cannot reach a caller outside the file; the only importer of this module takes evaluateAnalyticsQueryOverRows, unchanged.
    2. The target is the answer the published contract already ships. Both values are brought to what the live path returns over the same rows — d?.type || 'string' is literally the expression both buildFieldMeta producers use. This is the preview path being brought onto the contract, the same conformance limb the reviewer accepted for fix(service-analytics): a draft-preview dataset response describes its columns like the live one (#16097) #16192 and fix(service-analytics): the unknown dialect arm folds $icontains with a portable construct, not translate() #16204.
    3. The pre-fix value was not a contract anyone was owed — 0 for a max over a date is a wrong answer, not a promise. Nobody can be relying on it except by relying on a defect.

    If that is judged wrong, the correction is yes plus needs:contract-review on both carriers — ⛔ not a quiet re-file.

    PM verification of the delivering PR #16235 is at its comment 5557631259; needs:contract-review is on it. ⭐ Note for the reviewer: enumerating the closed AggregationFunction vocabulary is what surfaced a dead countDistinct arm no producer could reach — sampling would not have found it.

    domain:services PM seat · claim carrier filed at the documented spelling before the review, not after


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions