Refine the tiered rollup read by the actual data span

The tier for a chart series keyed off the requested window width. The
All range always requests ~3650 days, so every account got MONTH
buckets regardless of how much history exists — a record spanning four
months came back as four monthly means, and the chart captioned them
from the real span as a weekly average. The reader now re-reads at the
tier the returned rows' span calls for; a coverage miss on the finer
tier keeps the coarser rows.

The ladder comment claimed to mirror the client's pickBucket
thresholds and misquoted them. The two ladders are deliberately not
identical; a test now pins the direction that keeps the caption honest
(server never coarser than the client captions) for every span up to
ten years.
This commit is contained in:
Marc-André Bombeck committed 2026-08-26 17:55:43 +02:00
1 parent 3ce5411d19
commit 0823a4e222
2 files changed
+177 -12

No files matched your search

@@ -43,10 +43,12 @@ vi.mock("@/lib/db", () => ({
import {
aggregateWmyBuckets,
pickRollupGranularityForWindow,
readBestGranularityRollups,
readTieredRollupSeries,
type RollupBucketRow,
} from "../measurement-read-wmy";
import { pickBucket } from "@/lib/charts/bucket-time-series";
const { findMany } = mocks;
@@ -70,6 +72,12 @@ function bucket(
beforeEach(() => {
findMany.mockReset();
// Default to "no coverage" for any read a test did not queue explicitly —
// the v1.37.29 span-refinement probe in `readTieredRollupSeries` issues an
// extra read when a fixture's rows span less than the requested window,
// and an unqueued vi.fn() would resolve `undefined` and crash the reader
// instead of modelling an unminted tier.
findMany.mockResolvedValue([]);
mocks.userFindUnique.mockReset();
mocks.userFindUnique.mockResolvedValue({ sourcePriorityJson: null });
});
@@ -454,4 +462,120 @@ describe("readTieredRollupSeries", () => {
expect(result?.rows[0].minValue).toBe(78);
expect(result?.rows[0].maxValue).toBe(85);
});
// v1.37.29 — the tier keys off the ACTUAL data span, not the requested
// window width. The "All" tab always requests ~3650 days, so pre-fix a
// record whose history spans four months came back as four MONTH means
// while the chart captioned them from the real span — "weekly average"
// over monthly buckets.
describe("span refinement", () => {
it("re-reads at the tier the real span calls for (4 months of data on an All request → DAY)", async () => {
const months = [
bucket("2026-02-01T00:00:00.000Z"),
bucket("2026-03-01T00:00:00.000Z"),
bucket("2026-04-01T00:00:00.000Z"),
bucket("2026-05-01T00:00:00.000Z"),
];
const days = [
bucket("2026-02-03T00:00:00.000Z"),
bucket("2026-03-14T00:00:00.000Z"),
bucket("2026-04-09T00:00:00.000Z"),
bucket("2026-05-28T00:00:00.000Z"),
];
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(days);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
...win(3650),
});
// Span of the MONTH rows ≈ 89 days → the display ladder wants DAY,
// and the refinement jumps straight there (no WEEK stop-over).
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
expect(findMany.mock.calls[1][0].where.granularity).toBe("DAY");
expect(findMany).toHaveBeenCalledTimes(2);
expect(result?.granularity).toBe("DAY");
expect(result?.rows).toHaveLength(4);
expect(result?.rows[0].measuredAt).toBe("2026-02-03T00:00:00.000Z");
});
it("refines to WEEK for a span between one and two years", async () => {
const months = [
bucket("2025-01-01T00:00:00.000Z"),
bucket("2026-05-01T00:00:00.000Z"),
];
const weeks = [
bucket("2024-12-30T00:00:00.000Z"),
bucket("2026-04-27T00:00:00.000Z"),
];
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(weeks);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
...win(3650),
});
expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK");
expect(result?.granularity).toBe("WEEK");
});
it("keeps the coarser rows when the finer tier has no coverage", async () => {
const months = [
bucket("2026-01-01T00:00:00.000Z"),
bucket("2026-05-01T00:00:00.000Z"),
];
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce([]);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
...win(3650),
});
expect(result?.granularity).toBe("MONTH");
expect(result?.rows).toHaveLength(2);
});
it("does not probe when the rows already span the requested tier", async () => {
const months = [
bucket("2018-01-01T00:00:00.000Z"),
bucket("2026-05-01T00:00:00.000Z"),
];
findMany.mockResolvedValueOnce(months);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
...win(3650),
});
expect(findMany).toHaveBeenCalledTimes(1);
expect(result?.granularity).toBe("MONTH");
});
});
});
// v1.37.29 — the server tier ladder and the client caption ladder
// (`pickBucket`) are DIFFERENT functions with different thresholds, and
// their divergence is exactly what shipped monthly means under a
// "weekly average" chip. The invariant that keeps the caption honest is
// directional: for any data span, the tier the server serves is never
// COARSER than the bucket the client captions — the client may fold
// finer rows up to its caption, but it cannot split coarser ones.
describe("server tier vs client caption ladder", () => {
const SERVER_COARSENESS = { DAY: 0, WEEK: 1, MONTH: 2, YEAR: 3 } as const;
const CLIENT_COARSENESS = { day: 0, week: 1, month: 2 } as const;
it("never serves coarser than the client captions, for every span up to ten years", () => {
for (let span = 1; span <= 3650; span++) {
const server = pickRollupGranularityForWindow(span);
const client = pickBucket(span);
expect(
SERVER_COARSENESS[server],
`span ${span}d: server ${server} vs client caption ${client}`,
).toBeLessThanOrEqual(CLIENT_COARSENESS[client]);
}
});
});
+53 -12
View File
@@ -303,20 +303,28 @@ export interface TieredSeriesRow {
}
/**
* Pick the rollup granularity that matches the chart's own client-side
* `bucketTimeSeries` downsampler (`src/lib/charts/bucket-time-series.ts`):
* Pick the rollup granularity for a chart window of `windowDays`:
*
* - > 730 days → MONTH (the chart renders month points)
* - 366–730 → WEEK (the chart renders week points)
* - > 730 days → MONTH
* - 366–730 → WEEK
* - ≤ 365 → DAY (daily resolution; the DAY cap covers it)
*
* Mirroring the display tier means the server returns exactly the
* resolution the chart will paint — no finer (which would truncate at the
* DAY cap) and no coarser (which would drop visible detail). The finer
* fallback in `readTieredRollupSeries` still rescues a tenant whose
* coarse buckets the worker has not minted yet.
* This ladder must stay COMPATIBLE with the chart's client-side
* `pickBucket` (`src/lib/charts/bucket-time-series.ts`), which captions
* the rendered series from the visible data span (day ≤ 90, week 91–730,
* month > 730): for any span the tier chosen here is never COARSER than
* the caption the client will print, so a "Wochendurchschnitt" chip
* always sits over points the client actually folded into weeks. The
* ladders are not identical — this one may be finer (DAY rows for a
* 91–365 d span; the client folds them into the weeks it captions) —
* and a test pins the never-coarser direction across every span.
*
* The finer fallback in `readTieredRollupSeries` still rescues a tenant
* whose coarse buckets the worker has not minted yet.
*/
function pickRollupGranularityForWindow(windowDays: number): RollupGranularity {
export function pickRollupGranularityForWindow(
windowDays: number,
): RollupGranularity {
if (windowDays > 730) return "MONTH";
if (windowDays > 365) return "WEEK";
return "DAY";
@@ -393,8 +401,8 @@ export async function readTieredRollupSeries(opts: {
// drop detail the chart can render). Stop at the first tier with coverage.
const targetIdx = TIER_ORDER.indexOf(target);
for (let i = targetIdx; i >= 0; i--) {
const granularity = TIER_ORDER[i];
const rows = await readGranularity(
let granularity = TIER_ORDER[i];
let rows = await readGranularity(
userId,
type,
granularity,
@@ -403,6 +411,39 @@ export async function readTieredRollupSeries(opts: {
priority,
);
if (rows && rows.length > 0) {
// Refine the tier by the ACTUAL data span, not the requested window.
// The "Alle" tab always requests ~3650 days, so keying the tier off
// the request width alone handed EVERY account MONTH buckets — a
// record whose history spans four months came back as four monthly
// means while the client (which captions from the real span) labelled
// them "weekly average". Re-reading at the tier the real span calls
// for returns the resolution the chart will actually caption. A
// coverage miss on the finer tier keeps the coarser rows — finer
// tiers are the base tiers the workers mint first, so in practice
// this probe only fires when the refinement is genuinely available.
// The walk is strictly finer-only, so it terminates.
for (;;) {
const spanDays = Math.ceil(
(rows[rows.length - 1].bucketStart.getTime() -
rows[0].bucketStart.getTime()) /
86_400_000,
);
const refined = pickRollupGranularityForWindow(Math.max(1, spanDays));
if (TIER_ORDER.indexOf(refined) >= TIER_ORDER.indexOf(granularity)) {
break;
}
const finerRows = await readGranularity(
userId,
type,
refined,
from,
to,
priority,
);
if (!finerRows || finerRows.length === 0) break;
granularity = refined;
rows = finerRows;
}
const useSum = CUMULATIVE_HK_TYPES.has(type);
annotate({
action: { name: "measurement.list" },