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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 17:11:01 +02:00
1 parent 33dc00fe62
commit feae5b5215
2 files changed
+99 -1

No files matched your search

@@ -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<string, boolean>([
["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<string, boolean>([
["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<string, boolean>([
["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();
});
});
});
+20 -1
View File
@@ -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",
},
},
});