mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user