From feae5b5215a74c68f9ba911682c501dce5adb26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 17:11:01 +0200 Subject: [PATCH] fix(correlations): fall back to live SQL when user is > 3h from UTC The fast-path keys rollup rows on UTC-midnight bucketStart via userDayKey(b.bucketStart, userTz), then pairs those per-day means with per-event mood + intake rows that the loop below also re-keys in user-tz. For Berlin (+1/+2) the two keying schemes line up on the same calendar day; for Honolulu (-10) the rollup-derived per-day mean addresses the previous local day relative to the per-event stream, and the Pearson / ANOVA inputs miscorrelate silently. Runtime guard: when isNearUtc(userTz, now) is false, force the live SQL path (which re-keys SYS / PULSE / WEIGHT via the same userDayKey helper as mood / intake) so day-key parity is guaranteed. The meta annotate carries a new tz_guard breadcrumb ("near-utc" | "non-utc-live-fallback") so prod logs make the decision visible. A v1.5 follow-up will thread userTz into readRollupBuckets so the rollup path can address local-day buckets and this guard can come back down. --- .../__tests__/correlations-fast-path.test.ts | 79 +++++++++++++++++++ src/lib/analytics/correlations-fast-path.ts | 21 ++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/lib/analytics/__tests__/correlations-fast-path.test.ts b/src/lib/analytics/__tests__/correlations-fast-path.test.ts index 267f0b4ff..12087cb10 100644 --- a/src/lib/analytics/__tests__/correlations-fast-path.test.ts +++ b/src/lib/analytics/__tests__/correlations-fast-path.test.ts @@ -194,4 +194,83 @@ describe("computeCorrelationHypothesesFastPath", () => { expect(PROBE).toHaveBeenCalledWith("user-fresh"); }); }); + + describe("cross-tz runtime guard (v1.4.38 W-A)", () => { + it("takes the rollup path for a Berlin user (near-UTC)", async () => { + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", true], + ["PULSE", true], + ["WEIGHT", true], + ]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + ROLLUP_FIND_MANY.mockResolvedValue([]); + + const result = await computeCorrelationHypothesesFastPath({ + userId: "user-berlin", + userTz: "Europe/Berlin", + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + expect(result.path).toBe("rollup"); + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); + expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled(); + + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const corrCall = calls.find((c) => c?.meta?.correlations !== undefined); + expect(corrCall?.meta.correlations.tz_guard).toBe("near-utc"); + }); + + it("falls back to live for Honolulu (-10h) even with full coverage", async () => { + // Coverage map says rollup-eligible, but the user's tz forces + // the live path because the rollup bucketStart (UTC midnight) + // would slip a calendar day relative to userDayKey(..., Honolulu). + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", true], + ["PULSE", true], + ["WEIGHT", true], + ]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValue([]); + + const result = await computeCorrelationHypothesesFastPath({ + userId: "user-honolulu", + userTz: "Pacific/Honolulu", + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + expect(result.path).toBe("live"); + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(3); + expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); + + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const corrCall = calls.find((c) => c?.meta?.correlations !== undefined); + expect(corrCall?.meta.correlations.tz_guard).toBe("non-utc-live-fallback"); + expect(corrCall?.meta.correlations.path).toBe("live"); + }); + + it("falls back to live for Tokyo (+9h) even with full coverage", async () => { + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", true], + ["PULSE", true], + ["WEIGHT", true], + ]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValue([]); + + const result = await computeCorrelationHypothesesFastPath({ + userId: "user-tokyo", + userTz: "Asia/Tokyo", + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + expect(result.path).toBe("live"); + expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index 3bd2a7456..9c4a1fd4d 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -57,7 +57,7 @@ import { correlateWeightWeekday, type CorrelationResult, } from "@/lib/insights/correlations"; -import { userDayKey } from "@/lib/tz/resolver"; +import { isNearUtc, userDayKey } from "@/lib/tz/resolver"; /** * v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days @@ -119,12 +119,27 @@ export async function computeCorrelationHypothesesFastPath( const coverage = input.coverage ?? (await probeRollupCoverage(userId)); + // v1.4.38 W-A — cross-tz runtime guard. The rollup table buckets at + // UTC midnight, while the per-event mood / intake streams below are + // re-keyed via `userDayKey(..., userTz)`. For a user inside the +-3h + // band around UTC (Europe + western Mid-Atlantic), the two keying + // schemes line up on the same calendar day. Outside that band the + // pairing slips by a day and the Pearson / ANOVA inputs miscorrelate. + // The cheap fix: when the user is non-near-UTC, force the live path + // so the SYS / PULSE / WEIGHT per-day means are re-keyed via the same + // `userDayKey(measuredAt, userTz)` helper the mood / intake streams + // use, guaranteeing day-key parity. A v1.5 follow-up will thread + // `userTz` into `readRollupBuckets` so the rollup path also addresses + // local-day buckets and this guard can come back down. + const userNearUtc = isNearUtc(userTz, now); + // The three measurement-derived streams ride the rollup-fast-path // only when EVERY type the user has logged is covered AND each of // SYS / PULSE / WEIGHT is in the coverage map. Partial coverage // falls back to the live path so a brand-new metric type doesn't // make the correlation card vanish. const measurementsOnRollups = + userNearUtc && isFullyCovered(coverage) && coverage.get("BLOOD_PRESSURE_SYS") === true && coverage.get("PULSE") === true && @@ -295,6 +310,10 @@ export async function computeCorrelationHypothesesFastPath( path: measurementsOnRollups ? "rollup" : "live", window_days: CORRELATION_WINDOW_DAYS, degraded: false, + // v1.4.38 W-A — surfaces the cross-tz guard decision so ops + // logs can distinguish "user is Berlin, rollup path eligible" + // from "user is Honolulu, forced to live regardless of coverage". + tz_guard: userNearUtc ? "near-utc" : "non-utc-live-fallback", }, }, });