Skip to content

fix(spec,service-analytics): carry a percentage measure's SCALE, and report an empty filtered group as zero (objectui#3136) - #4442

Open
baozhoutao wants to merge 2 commits into
mainfrom
claude/percent-scale-3136
Open

fix(spec,service-analytics): carry a percentage measure's SCALE, and report an empty filtered group as zero (objectui#3136)#4442
baozhoutao wants to merge 2 commits into
mainfrom
claude/percent-scale-3136

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes the server half of objectstack-ai/objectui#3136. The Console half is objectstack-ai/objectui#3140 — merge this first; the renderer reads a field this PR adds.

The bug, precisely

A % format string says how to print a number, not what scale it is on. The two readings collide at exactly 1, which is both "100%" (a 0–1 ratio at full compliance) and "1%" (one percentage point). Nothing on the wire told them apart, so renderers guessed from the value's magnitude — and resolved the collision the way almost nobody means. An SLA dashboard reporting sla_rate = 1 displayed "1.0%": "everything met the SLA" read as "1% met the SLA".

The scale was never unknowable. It just never left the server:

  • derived: { op: 'ratio' } is a 0–1 fraction by definition;
  • a measure aggregating a percent field has whatever scale that field stores.

Both facts sit in metadata the measure-column enrichment pass already reads — for the ADR-0053 currency chain, which walks back to the source field, checks type === 'currency', and rides the resolved code onto the result column. Percentages got no such treatment. Same seam, now they do.

What lands

percentScaleOf(field) (spec/data/percent-scale.ts) — the one place the question is answered. A percent field stores a FRACTION unless it declares max > 1 (e.g. min: 0, max: 100), which marks whole-percent storage. That is not a new convention: it is the rule the percent edit widget already writes by, so reading it back this way is what makes a value round-trip. Non-percent fields get no opinion — a plain number an author formatted with a % keeps meaning exactly what their format string says.

AnalyticsResult.fields[].percentScale'fraction' | 'whole', absent when the column is not a percentage. queryDataset sets it from derived.op === 'ratio' first, then the source field. currency — emitted since ADR-0053 but only ever written through a cast — is now declared on the same interface.

measureCurrencysourceFieldMeta, returning max alongside type/defaultCurrency. The old name had already outgrown itself: the date-bucketing path reads type through it to tell a date dimension from a datetime one, and percent scale is its third consumer.

Second fix, same widget family: an empty filtered group is a measured zero. A measure-scoped filter can exclude every row of a group the grid still lists, and the database reports that by omitting the group — after the merge, indistinguishable from "never measured". For a COUNT or a SUM it is measured: the answer is 0. emptyGroupValueFor(aggregate) states which aggregates have an identity over the empty set, and the executor fills it in after all supplementary merges (a later measure's merge can append rows no earlier query saw). So "0 of 12 paid" reports 0 instead of blank and a ratio on it computes to 0 instead of null. avg/min/max keep their null — there is nothing to average over an empty group, and a zero there would invent a measurement.

Showcase: a paid_rate ratio measure plus KPI + table widgets on Revenue Pulse. Grouping by status pins the Paid bucket at exactly 1 — the repro value, now demonstrated instead of described.

Verification

Real backend (objectstack dev --fresh, showcase seed), POST /api/v1/analytics/dataset/query:

{"status": "Paid",  "invoice_count": 5, "paid_count": 5, "paid_rate": 1}
{"status": "Sent",  "invoice_count": 5, "paid_count": 0, "paid_rate": 0}
{"name": "paid_rate", "format": "0.0%", "percentScale": "fraction"}

Browser (Console dev server against that backend, Revenue Pulse dashboard), A/B in one running UI:

Paid Rate — Paid row
with the annotation 100.0%
response intercepted, percentScale stripped (pre-fix server) 1.0% ❌ — the reported bug
annotation restored 100.0%

The Sent row moved from / to 0 / 0.0%.

Tests: service-analytics 470 passed (5 new — ratio→fraction, max:100 source field→whole, bare percent field→fraction, plain number unannotated, empty filtered group→0); spec/data 1234 passed. pnpm build 71/71. spec typecheck clean, service-analytics sources clean. Full pnpm test: 72/73 tasks green — service-datasource failed 5 assertions on 5s timeouts under load and passes 10/10 in isolation, unrelated to this change.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 1, 2026 7:44am

Request Review

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec.

108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

baozhoutao and others added 2 commits August 1, 2026 02:53
…e result column (objectui#3136)

A `%` format string says how to print a number, not what scale it is on, and
the two readings collide at exactly 1 — both "100%" (a 0-1 ratio) and "1%" (one
percentage point). Renderers guessed from the value's magnitude and resolved it
the wrong way, so an SLA rate of full compliance displayed as "1.0%".

The scale was answerable from metadata all along; it just never left the
server. `derived: { op: 'ratio' }` is a 0-1 fraction by definition, and a
measure over a `percent` field has that field's scale. Both are now resolved in
the measure-column enrichment pass, next to the ADR-0053 currency chain that
already walks back to the source field for exactly this kind of display fact.

- `percentScaleOf(field)` (spec/data) — the one rule: a `percent` field stores
  a fraction unless it declares `max > 1`, matching what the edit widget
  writes. Non-percent fields get no opinion.
- `AnalyticsResult.fields[].percentScale` — 'fraction' | 'whole', absent when
  the column is not a percentage. `currency` (emitted since ADR-0053 through a
  cast) is declared on the same interface.
- `measureCurrency` → `sourceFieldMeta`, now returning `max`. The old name had
  outgrown itself: date bucketing already read `type` through it, and the
  percent chain is its third consumer.
- Showcase: a `paid_rate` ratio measure + KPI/table widgets on Revenue Pulse.
  Grouping by status pins the Paid bucket at exactly 1 — the repro value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ro, not missing data (objectui#3136)

A measure-scoped filter can exclude every row of a group the grid still lists,
and the database reports that by omitting the group from the supplementary
result — after the merge, indistinguishable from "never measured". For a COUNT
or a SUM it IS measured: the answer is 0.

So "0 of 12 paid" rendered as a blank cell and every ratio built on it went
null — a compliance dashboard silently dropping the row it exists to show. On
the showcase's Paid-Rate table the Sent bucket read "—/—" where the truth is
"0 / 0.0%".

- `emptyGroupValueFor(aggregate)` (spec/data/aggregation-policy) states which
  aggregates have an identity over the empty set. avg/min/max keep their null:
  there is nothing to average, and a zero there would invent a measurement.
- `queryDataset` fills it in after ALL supplementary merges, not inside the
  loop — a later measure's merge can append rows for dimension keys no earlier
  query saw, and those rows need the same fill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant