From db08962d4111a351ee347168759d5576a650a77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 05:05:41 +0200 Subject: [PATCH] fix(analytics): merge resting and proxy heart-rate series per day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveRestingPulseSeries returned the resting branch as soon as a single RESTING_HEART_RATE row existed and never consulted the PULSE proxy again. Since the retention fold began minting derived resting rows for the days it folds, a proxy account carries derived rows for old folded days and nothing but raw PULSE for the recent ones — so the visible series stopped dead at the fold horizon. Merge per day instead: a day with a resting row uses it, a day without falls back to the proxy. which stays a three-value labelling signal and reports proxy as soon as one point is estimated, so a partly-estimated series is never labelled resting heart rate. Widen the intraday baseline read to pull PULSE history when the newest resting row is stale, not only when the rows are few — the count gate alone pinned the baseline to the fold horizon. --- src/lib/analytics/intraday-pulse-io.ts | 23 +++++++- src/lib/analytics/resting-pulse.ts | 59 ++++++++++++++----- .../measurements/dense-intraday-retention.ts | 15 +++-- 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index 8a063a21d..9a4df4243 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -68,6 +68,13 @@ export interface IntradayPulseResult { resolution: IntradayResolution; } +/** + * How old the newest RESTING_HEART_RATE row may be before the baseline stops + * trusting the resting rows alone and pulls the PULSE history in as well. Two + * days tolerates a single missed night without widening the read. + */ +const RESTING_STALE_AFTER_MS = 2 * 24 * 60 * 60 * 1000; + /** Median (p50), or null on an empty series. */ function median(values: number[]): number | null { if (values.length === 0) return null; @@ -103,9 +110,21 @@ async function resolveBaseline( select: { value: true, measuredAt: true }, }); - // Only reach for the heavier PULSE-history read when resting rows are thin. + // Only reach for the heavier PULSE-history read when the resting rows + // cannot carry the baseline on their own: too few of them, OR the newest + // one is already stale. Staleness matters because the retention fold mints + // derived resting rows only for the days it has ALREADY folded, so a proxy + // account accumulates plenty of resting rows for OLD days while the RECENT + // days still hold nothing but raw PULSE. Gating on the row count alone + // pinned the baseline to the fold horizon and never advanced it past it. + // A genuinely native account reports a resting row daily, never trips the + // staleness arm, and pays for no extra read. + const newestRestingAt = restingRows[0]?.measuredAt ?? null; + const restingIsStale = + newestRestingAt === null || + Date.now() - newestRestingAt.getTime() > RESTING_STALE_AFTER_MS; const pulseHistory = - restingRows.length >= MIN_BASELINE_DAYS + restingRows.length >= MIN_BASELINE_DAYS && !restingIsStale ? [] : await prisma.measurement.findMany({ where: { userId, type: "PULSE", deletedAt: null }, diff --git a/src/lib/analytics/resting-pulse.ts b/src/lib/analytics/resting-pulse.ts index 5e62139c4..652079dae 100644 --- a/src/lib/analytics/resting-pulse.ts +++ b/src/lib/analytics/resting-pulse.ts @@ -19,10 +19,12 @@ * * The fix: judge a RESTING band against a RESTING series. * - * 1. **Prefer `RESTING_HEART_RATE`** when the user has it (Apple + * 1. **Prefer `RESTING_HEART_RATE`** on every day that has one (Apple * already separated the clean signal for us). - * 2. **Fallback proxy from `PULSE`** when no `RESTING_HEART_RATE` - * exists (manual-only users, older imports): take a robust + * 2. **Fallback proxy from `PULSE`** on the days that have no + * `RESTING_HEART_RATE` (manual-only users, older imports, and the + * recent days of an account whose derived rows only reach as far + * as the retention fold has run): take a robust * low-percentile of each calendar day's PULSE samples — the resting * heart rate is the floor of the day, not its average, so the daily * 20th percentile estimates resting while excluding the workout @@ -112,11 +114,27 @@ export function deriveRestingProxyFromPulse( /** * Resolve the resting-pulse series a resting-target surface should - * score. Prefers the clean `RESTING_HEART_RATE` rows; falls back to the - * low-percentile PULSE proxy when the user has no resting rows. + * score. Merges PER DAY: a day that has a `RESTING_HEART_RATE` row is + * represented by that row; a day that has none falls back to the + * low-percentile PULSE proxy. * - * `which` reports the branch taken so callers can annotate / label - * honestly ("resting heart rate" vs "estimated from heart rate"). + * v1.30.16 made the dense-intraday retention fold mint derived + * `RESTING_HEART_RATE` rows (source `COMPUTED`) for the days it folds. + * A proxy account therefore now carries derived rows for OLD folded + * days and nothing but raw `PULSE` for RECENT not-yet-folded days. The + * previous all-or-nothing shape returned the resting branch the moment + * a single resting row existed and never consulted the proxy again, so + * the visible series stopped dead at the fold horizon. Merging per day + * keeps the clean signal where it exists and carries the series + * forward across the days that only have the proxy. + * + * `which` reports how to LABEL the series, and it is deliberately + * conservative: `"resting"` only when EVERY point came from a real + * resting row, `"proxy"` as soon as one point is proxy-derived. The two + * error directions are not symmetric — calling a partly-estimated + * series "resting heart rate" is a false statement about the estimated + * days, whereas calling a partly-native series "estimated from heart + * rate" merely hedges. A labelling signal should hedge, never lie. */ export function resolveRestingPulseSeries(input: { restingSamples: ReadonlyArray; @@ -124,13 +142,24 @@ export function resolveRestingPulseSeries(input: { /** Optional day-key for the proxy buckets (defaults to Berlin-day). */ dayKeyOf?: (d: Date) => string; }): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { - if (input.restingSamples.length > 0) { - const series = [...input.restingSamples].sort( - (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), - ); - return { series, which: "resting" }; + const dayKeyOf = input.dayKeyOf ?? toBerlinDayKey; + const byTime = (a: PulseSample, b: PulseSample) => + a.measuredAt.getTime() - b.measuredAt.getTime(); + + const resting = [...input.restingSamples].sort(byTime); + // Days already covered by a real resting row — the proxy must not + // second-guess them. + const restingDays = new Set(resting.map((s) => dayKeyOf(s.measuredAt))); + const proxy = deriveRestingProxyFromPulse( + input.pulseSamples, + dayKeyOf, + ).filter((p) => !restingDays.has(dayKeyOf(p.measuredAt))); + + if (resting.length === 0) { + return proxy.length > 0 + ? { series: proxy, which: "proxy" } + : { series: [], which: "none" }; } - const proxy = deriveRestingProxyFromPulse(input.pulseSamples, input.dayKeyOf); - if (proxy.length > 0) return { series: proxy, which: "proxy" }; - return { series: [], which: "none" }; + if (proxy.length === 0) return { series: resting, which: "resting" }; + return { series: [...resting, ...proxy].sort(byTime), which: "proxy" }; } diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index 2bc1a9455..515f8ea19 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -423,12 +423,15 @@ export async function runDenseIntradayRetention( }; // iOS#34 — per-user memo of whether the user has ANY native - // `RESTING_HEART_RATE` row. The read-path resolver - // (`resolveRestingPulseSeries`) is all-or-nothing: a single native - // resting row makes it ignore the PULSE proxy entirely. So the derived - // resting row is minted ONLY for proxy users (zero native rows) — minting - // alongside native rows would double-count. Memoised so the per-day fold - // does not re-query, and cleared per user at the start of the walk. + // `RESTING_HEART_RATE` row. The derived resting row is minted ONLY for + // proxy users (zero native rows): a native account already reports the + // clean signal for those days, so a derived row would add nothing and + // would let a fold artefact outrank a device reading. The read-path + // resolver (`resolveRestingPulseSeries`) merges resting and proxy PER DAY + // and prefers a resting row wherever one exists, so the mint's job is + // narrow — preserve the day's figure before the fold tombstones the raw + // readings it was derived from. Memoised so the per-day fold does not + // re-query, and cleared per user at the start of the walk. const nativeRestingByUser = new Map(); async function userHasNativeResting(userId: string): Promise { const cached = nativeRestingByUser.get(userId);