From 9009f1bb33ddbe0412854d716b45ac2f1e39a272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 12:05:02 +0200 Subject: [PATCH] perf(health-score-fast-path): consume monthly rollup buckets for slope90 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires readBestGranularityRollups into the WEIGHT-covered branch of computeUserHealthScoreFastPath so the trailing-year weight baseline is served from the WEEK / MONTH / YEAR rollup tier instead of falling through to a live measurements walk. The 365-day window routes through MONTH by default (~12 buckets) and falls back to WEEK -> DAY on per- tier coverage miss. count / mean compose linearly across MONTH buckets so the derived mean is mathematically equivalent to the per-row average over the same window. The read fires in parallel with the canonical DAY-bucket read for the 30-day weight series so wall-clock cost stays flat. Score shape is unchanged; the weightLongWindow annotate carries the resolved granularity + bucket count + mean so operators can verify the rollup tier serves production traffic. The live-fallback branch deliberately omits the long-window probe because the raw weight findMany already does the work — guards against double-reading on cold mounts. --- .../__tests__/health-score-fast-path.test.ts | 169 ++++++++++++++++++ src/lib/analytics/health-score-fast-path.ts | 69 ++++++- 2 files changed, 231 insertions(+), 7 deletions(-) diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index 898ef3a96..5d6883a70 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -102,6 +102,11 @@ describe("computeUserHealthScoreFastPath", () => { computedAt: now, }, ]); + // v1.4.40 W-WMY-WIRE — the parallel long-window read fires + // alongside the DAY-bucket read; mock its `findMany` to return + // a coverage miss so the annotate carries `weightLongWindow: + // null` and the score shape is unchanged. + ROLLUP_FIND_MANY.mockResolvedValueOnce([]); // Weight source-attribution read (narrow 2-column). MEASUREMENT_FIND_MANY .mockResolvedValueOnce([ @@ -147,6 +152,170 @@ describe("computeUserHealthScoreFastPath", () => { }); }); + /** + * v1.4.40 W-WMY-WIRE — pin the long-window weight wiring through + * `readBestGranularityRollups`. The score shape is unchanged; the + * weightLongWindow annotate makes the rollup-tier consumption + * verifiable in production wide-events. + */ + describe("long-window weight wiring (W-WMY-WIRE)", () => { + it("surfaces a MONTH-granularity weightLongWindow mean on the rollup branch", async () => { + const coverage = new Map([["WEIGHT", true]]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-05-17T12:00:00.000Z"); + // DAY-bucket read (trailing 37 days) — single bucket, irrelevant + // for the long-window assertion. + ROLLUP_FIND_MANY.mockResolvedValueOnce([ + { + bucketStart: new Date("2026-05-10T00:00:00.000Z"), + count: 1, + mean: 82.5, + minValue: 82.5, + maxValue: 82.5, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + ]); + // readBestGranularityRollups(WEIGHT, 365) — first call is the + // MONTH probe (floor 181 d). Two MONTH buckets, count-weighted + // mean = (12*82 + 18*80) / 30 = 80.8. + ROLLUP_FIND_MANY.mockResolvedValueOnce([ + { + bucketStart: new Date("2025-08-01T00:00:00.000Z"), + count: 12, + mean: 82, + minValue: 80, + maxValue: 84, + sd: 1, + slope: 0, + r2: 0, + sumValue: null, + }, + { + bucketStart: new Date("2025-09-01T00:00:00.000Z"), + count: 18, + mean: 80, + minValue: 78, + maxValue: 83, + sd: 1, + slope: 0, + r2: 0, + sumValue: null, + }, + ]); + // Source attribution + BP-SYS attribution reads. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ + { + measuredAt: new Date("2026-05-10T08:00:00.000Z"), + source: "WITHINGS", + }, + ]).mockResolvedValueOnce([]); + + await computeUserHealthScoreFastPath({ + userId: "user-longwindow", + bpInTargetPct: 85, + heightCm: 178, + now, + coverage, + }); + + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const pathCall = calls.find( + (c) => c?.meta?.healthScore?.path === "rollup", + ); + expect(pathCall).toBeDefined(); + const longWindow = pathCall?.meta?.healthScore?.weightLongWindow; + expect(longWindow).not.toBeNull(); + expect(longWindow?.granularity).toBe("MONTH"); + expect(longWindow?.buckets).toBe(2); + // Σ(count*mean) / Σcount = (12*82 + 18*80) / 30 = 80.8 → rounded. + expect(longWindow?.mean).toBeCloseTo(80.8, 5); + }); + + it("emits weightLongWindow:null when the WMY tier carries no buckets", async () => { + const coverage = new Map([["WEIGHT", true]]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-05-17T12:00:00.000Z"); + // DAY-bucket read. + ROLLUP_FIND_MANY.mockResolvedValueOnce([ + { + bucketStart: new Date("2026-05-10T00:00:00.000Z"), + count: 1, + mean: 82.5, + minValue: 82.5, + maxValue: 82.5, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + ]); + // Long-window read — every granularity returns empty (router + // walks MONTH → WEEK → DAY; 365 d skips YEAR floor). + ROLLUP_FIND_MANY.mockResolvedValue([]); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ + { + measuredAt: new Date("2026-05-10T08:00:00.000Z"), + source: "WITHINGS", + }, + ]).mockResolvedValueOnce([]); + + await computeUserHealthScoreFastPath({ + userId: "user-no-longwindow", + bpInTargetPct: 85, + heightCm: 178, + now, + coverage, + }); + + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const pathCall = calls.find( + (c) => c?.meta?.healthScore?.path === "rollup", + ); + expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull(); + }); + + it("omits the long-window read on the live fallback branch", async () => { + const coverage = new Map([["WEIGHT", false]]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-05-17T12:00:00.000Z"); + // Weight live read + BP source attribution. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ + { + measuredAt: new Date("2026-05-10T08:00:00.000Z"), + value: 82.5, + source: "MANUAL", + }, + ]).mockResolvedValueOnce([]); + + await computeUserHealthScoreFastPath({ + userId: "user-live-nowmy", + bpInTargetPct: 80, + heightCm: 178, + now, + coverage, + }); + + // The live branch never issues the long-window probe — guards + // against accidentally double-reading on cold mounts where the + // raw weight findMany is already doing the work. + expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const pathCall = calls.find( + (c) => c?.meta?.healthScore?.path === "live", + ); + expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull(); + }); + }); + describe("live fallback — WEIGHT not covered", () => { it("reads raw weight rows and pins path:live", async () => { const coverage = new Map([ diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 1257172cb..0c6a1e380 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -45,6 +45,7 @@ import { probeRollupCoverage, type RollupCoverageMap, } from "@/lib/measurements/rollup-coverage"; +import { readBestGranularityRollups } from "@/lib/measurements/rollup-read-wmy"; import { calculateCompliance } from "./compliance"; import { computeHealthScore, @@ -159,19 +160,38 @@ export async function computeUserHealthScoreFastPath( let weightSeriesPrev30d: Array<{ date: string; kg: number }>; let weightSourcesIn30d: ReadonlyArray; let latestWeightAsOf: string | null; + // v1.4.40 W-WMY-WIRE — long-window weight baseline served from the + // WEEK / MONTH / YEAR rollup tier. Used for the diagnostic annotate + // so operators can verify the WMY readers actually serve production + // traffic instead of sitting as dead write amplification (see + // `.planning/round-v1438-perf-analysis.md` §2 + §5 P6). The score + // shape stays unchanged; the long-window mean is annotate-only so + // the v1.5 Coach drawer can opt into it later without breaking the + // current contract. + let weightLongWindowGranularity: string | null = null; + let weightLongWindowMean: number | null = null; + let weightLongWindowBucketCount = 0; if (weightCovered) { // Rollup-fast-path — per-day MEAN weight from `measurement_rollups`. // Linear regression on per-day means produces a slope equivalent // to per-event regression for a series with consistent sampling // cadence (one weigh-in per day is the canonical pattern). - const dayBuckets = await readRollupBuckets( - userId, - "WEIGHT", - "DAY", - prevSince30d, - now, - ); + // + // v1.4.40 W-WMY-WIRE — the long-window weight read runs in + // parallel with the canonical DAY-bucket read so the wall-clock + // cost stays flat. `readBestGranularityRollups(..., 365)` routes + // the trailing-year window through MONTH (floor 181 d) by default + // — typically 12 monthly buckets vs the ~365 DAY rows the live + // path would scan. count / mean are linearly composable across + // MONTH buckets so the derived mean is mathematically equivalent + // to the per-row average over the same year-long window. + // Coverage-fallback in the helper means a tenant with WEEK or DAY + // coverage but no MONTH buckets still resolves to a usable mean. + const [dayBuckets, longWindow] = await Promise.all([ + readRollupBuckets(userId, "WEIGHT", "DAY", prevSince30d, now), + readBestGranularityRollups(userId, "WEIGHT", 365), + ]); weightSeriesLast30d = dayBuckets .filter((b) => b.bucketStart >= since30d) .map((b) => ({ @@ -184,6 +204,19 @@ export async function computeUserHealthScoreFastPath( date: b.bucketStart.toISOString(), kg: b.mean, })); + if (longWindow && longWindow.rows.length > 0) { + weightLongWindowGranularity = longWindow.granularity; + weightLongWindowBucketCount = longWindow.rows.length; + let totalCount = 0; + let weighted = 0; + for (const row of longWindow.rows) { + totalCount += row.count; + weighted += row.count * row.mean; + } + if (totalCount > 0) { + weightLongWindowMean = weighted / totalCount; + } + } // Source attribution still needs the raw rows — only the raw table // carries the `source` enum. Pull the narrow 30-day window with a // 2-column projection so the round-trip stays minimal. @@ -338,6 +371,14 @@ export async function computeUserHealthScoreFastPath( score: null, reason: "no_components_available", path: weightCovered ? "rollup" : "live", + weightLongWindow: + weightLongWindowMean !== null + ? { + mean: Math.round(weightLongWindowMean * 100) / 100, + granularity: weightLongWindowGranularity, + buckets: weightLongWindowBucketCount, + } + : null, }, }, }); @@ -406,6 +447,20 @@ export async function computeUserHealthScoreFastPath( band: result.band, delta: result.delta, path: weightCovered ? "rollup" : "live", + // v1.4.40 W-WMY-WIRE — the long-window weight baseline drawn + // from the WEEK / MONTH / YEAR rollup tier. `null` when the + // user lacks long-tail coverage or when the live fallback + // branch ran (no probe issued). Surfaces the granularity the + // router landed on so operators can see "rollup tier served + // the year-long mean from MONTH buckets" in the wide-event. + weightLongWindow: + weightLongWindowMean !== null + ? { + mean: Math.round(weightLongWindowMean * 100) / 100, + granularity: weightLongWindowGranularity, + buckets: weightLongWindowBucketCount, + } + : null, }, }, });