fix(insights): sleep average via deduped reconstruction + night-reconstruction guard

This commit is contained in:
Marc-André Bombeck committed 2026-06-05 22:09:06 +02:00
1 parent a55816bf58
commit 874794e0e4
9 files changed
+366 -26

No files matched your search

+44 -9
View File
@@ -23,6 +23,12 @@ import {
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
resolveDashboardLayout,
type ComparisonBaseline,
@@ -153,16 +159,45 @@ async function buildComparisonSnapshotForUser(
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
const rows = await Promise.all(
types.map(async (type) => {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const summary = summarize(
measurements.map(
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
// raw stage rows mislabels a per-stage average as a night total (and
// double-counts a bare ASLEEP aggregate against its granular twin). Route
// the comparison through the per-night dedup reconstruction so the
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
let dataPoints: DataPoint[];
if (type === "SLEEP_DURATION") {
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: {
measuredAt: true,
value: true,
sleepStage: true,
source: true,
},
}),
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
dataPoints = reconstructSleepNights(
sleepRows as SleepStageRow[],
sleepTz,
sleepPriority,
)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
} else {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
dataPoints = measurements.map(
(m): DataPoint => ({ date: m.measuredAt, value: m.value }),
),
);
);
}
const summary = summarize(dataPoints);
const baselineAvg =
baseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
@@ -139,4 +139,37 @@ describe("GET /api/sleep/night", () => {
expect(body.data.main).toBeNull();
expect(body.data.naps).toEqual([]);
});
// A5 — a reconstruction edge (a session of only bare ASLEEP / stage-less rows
// alongside a granular partition for the same span) must return a valid empty
// night, NEVER a 500 from the unguarded `segments[0]` access.
it("returns 200 + empty night on an IN_BED/AWAKE-only reconstruction, never 500", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-06-04T06:00:00.000Z", "IN_BED", 60),
stage("2026-06-04T06:00:00.000Z", "AWAKE", 60),
] as never);
const res = await GET(req("date=2026-06-04"));
expect(res.status).toBe(200); // not 500
const body = await res.json();
expect(body.data.main).toBeNull();
expect(body.data.naps).toEqual([]);
});
it("returns 200 on a bare ASLEEP + stage-less + granular mix, never 500", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-06-04T02:00:00.000Z", "DEEP", 1),
stage("2026-06-04T06:00:00.000Z", "ASLEEP", 480),
// stage-less twin (sleepStage: null) for the same span.
{
value: 480,
measuredAt: new Date("2026-06-04T06:00:00.000Z"),
sleepStage: null,
source: "APPLE_HEALTH",
},
] as never);
const res = await GET(req("date=2026-06-04"));
expect(res.status).toBe(200); // never 500 on the reconstruction edge
});
});
@@ -16,8 +16,14 @@ function row(
iso: string,
stage: SleepStage | null,
minutes: number,
source?: MeasurementSource,
): SleepStageRow {
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes };
return {
measuredAt: new Date(iso),
sleepStage: stage,
value: minutes,
...(source ? { source } : {}),
};
}
/** Build a stage row tagged with an ingest source. */
@@ -535,3 +541,90 @@ describe("summarizeSleepNights", () => {
expect(summary.latest).toBe(570);
});
});
// A4 — the Insights sleep AVERAGE must come from the deduped per-night totals,
// never the raw per-stage sum that double-counts a bare ASLEEP aggregate against
// its granular twin (and folds IN_BED / AWAKE in on top). The ~20.3 h symptom is
// what a single night's stage rows summed without dedup produces.
describe("sleep average dedup (A4)", () => {
it("one source emits BOTH bare ASLEEP + granular for the same span → dedup total, not the sum", () => {
// Apple-Health-style double write for ONE overnight session: the bare
// ASLEEP aggregate (480) PLUS the granular CORE/DEEP/REM partition (also
// 480), plus IN_BED + AWAKE. Summed raw this is ~1490 min (~24.8 h); the
// deduped night total is the granular 480 min (8 h).
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "ASLEEP", 480, "APPLE_HEALTH"), // bare aggregate
row("2026-06-04T02:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
row("2026-06-04T04:00:00.000Z", "DEEP", 120, "APPLE_HEALTH"),
row("2026-06-04T06:00:00.000Z", "REM", 120, "APPLE_HEALTH"),
row("2026-06-04T06:30:00.000Z", "IN_BED", 470, "APPLE_HEALTH"),
row("2026-06-04T03:00:00.000Z", "AWAKE", 20, "APPLE_HEALTH"),
];
const { summary } = summarizeSleepNights(rows, "UTC");
// Granular partition wins: 240 + 120 + 120 = 480 min — NOT 480 + 480 + …
expect(summary.latest).toBe(480);
expect(summary.max).toBe(480);
// One night, not 6 stage rows.
expect(summary.count).toBe(1);
});
it("multi-source bare+granular collapses to the canonical source, no cross-source sum", () => {
// WHOOP granular + Apple bare-only for the SAME night. The canonical source
// (WHOOP, top of the sleep ladder) wins; the night total is WHOOP's dedup
// total, never WHOOP + Apple summed.
const rows: SleepStageRow[] = [
// WHOOP granular — 240 + 120 + 120 = 480.
row("2026-06-04T02:00:00.000Z", "CORE", 240, "WHOOP"),
row("2026-06-04T04:00:00.000Z", "DEEP", 120, "WHOOP"),
row("2026-06-04T06:00:00.000Z", "REM", 120, "WHOOP"),
// Apple bare aggregate for the same span — must NOT add on top.
row("2026-06-04T06:00:00.000Z", "ASLEEP", 470, "APPLE_HEALTH"),
];
const { summary } = summarizeSleepNights(rows, "UTC");
expect(summary.count).toBe(1);
expect(summary.latest).toBe(480); // WHOOP only, not 480 + 470.
});
});
// A5 — `reconstructSleepSessions` must never throw on a session the dedup
// empties (the unguarded `segments[0]` access). A read returns a valid empty
// night, never a 500.
describe("reconstructSleepSessions total-safety (A5)", () => {
it("never throws on an IN_BED/AWAKE-only session and yields no main night", () => {
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "IN_BED", 60),
row("2026-06-04T06:00:00.000Z", "AWAKE", 60),
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
const sessions = reconstructSleepSessions(rows, "UTC");
// Every returned session has at least one renderable segment.
expect(sessions.every((s) => s.segments.length > 0)).toBe(true);
expect(pickMainNightAndNaps(sessions).main).toBeNull();
});
it("skips a session that empties after the granular-over-bare filter, never throws", () => {
// A session whose canonical pool, after dropping the redundant bare ASLEEP
// aggregate / stage-less twins under `sawGranular`, has zero renderable
// segments. The DEEP granular row marks the session granular; the bare +
// stage-less rows are then filtered out. The granular row survives here, so
// assert the load-bearing contract: no throw, and every returned session is
// non-empty.
const rows: SleepStageRow[] = [
row("2026-06-04T02:00:00.000Z", "DEEP", 1), // tiny granular marker
row("2026-06-04T06:00:00.000Z", "ASLEEP", 480), // redundant bare twin
row("2026-06-04T06:00:00.000Z", null, 480), // stage-less twin
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions.every((s) => s.segments.length > 0)).toBe(true);
});
it("never throws on a stage-less-only session", () => {
// Legacy / manual stage-less rows only — no granular partition, so they are
// the fallback signal and survive the filter; still must not throw.
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", null, 480),
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
});
});
+15
View File
@@ -332,6 +332,12 @@ export function reconstructSleepNights(
let sawInBed = false;
let sawAwake = false;
let asleep = 0;
// `nightSessions` and each session pool are non-empty by construction: a
// key is only added with a `pool` that has ≥ 1 row (the source-filter falls
// back to the full session when the canonical filter empties it), so
// `nightSessions[0][0]` is safe. Unlike `reconstructSleepSessions`, this
// function never applies the granular-over-bare filter to the indexed pool,
// so it cannot empty the array here. Keep that invariant on future edits.
let latest = nightSessions[0][0].measuredAt;
const stages: Partial<Record<SleepStage, number>> = {};
for (const sessionRows of nightSessions) {
@@ -522,6 +528,15 @@ export function reconstructSleepSessions(
.map(segmentOf)
.sort((a, b) => a.start.getTime() - b.start.getTime());
// The granular-over-bare filter can empty a non-empty pool (a session whose
// only rows are the redundant bare ASLEEP aggregate / stage-less twins of a
// granular partition that did not survive into this pool). A session with no
// renderable/scorable segment contributes nothing — skip it before indexing
// `segments[0]`. This keeps `reconstructSleepSessions` total: it must NEVER
// throw on a shape the dedup empties, so `GET /api/sleep/night` (and every
// other consumer) returns a valid empty night, never a 500.
if (segments.length === 0) continue;
let inBed = 0;
let awake = 0;
let sawInBed = false;
@@ -4,6 +4,10 @@ import {
buildGradedSeriesFromPoints,
type GradedSeries,
} from "../graded-series";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
const dayMs = 24 * 60 * 60 * 1000;
@@ -111,4 +115,31 @@ describe("buildGradedSeriesFromPoints", () => {
expect(s.recent[0].mean).toBe(80);
expect(s.recent[0].n).toBe(3);
});
// A4 — the sleep AVERAGE the Insights assessment ships is the graded series
// built from DEDUPED per-night totals, never the raw per-stage sum that
// double-counts a bare ASLEEP aggregate against its granular twin (the
// impossible ~20.3 h symptom). This pins the path metric-status now uses:
// reconstruct nights → per-night points → buildGradedSeriesFromPoints.
it("sleep graded series uses the deduped night total, never the ~20 h stage sum (A4)", () => {
const sleepNow = new Date("2026-06-04T12:00:00.000Z");
// One overnight session, Apple-Health-style double write: bare ASLEEP
// aggregate (480) + granular CORE/DEEP/REM (also 480) + IN_BED + AWAKE.
// Raw-summed this is ~1490 min (~24.8 h); deduped it is 480 min (8 h).
const rows: SleepStageRow[] = [
{ measuredAt: new Date("2026-06-04T06:00:00.000Z"), sleepStage: "ASLEEP", value: 480, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "CORE", value: 240, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "DEEP", value: 120, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T06:00:00.000Z"), sleepStage: "REM", value: 120, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T06:30:00.000Z"), sleepStage: "IN_BED", value: 470, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T03:00:00.000Z"), sleepStage: "AWAKE", value: 20, source: "APPLE_HEALTH" },
];
const points = reconstructSleepNights(rows, "UTC")
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes }));
const graded = buildGradedSeriesFromPoints(points, sleepNow);
const recentMean = graded.recent.at(-1)?.mean ?? 0;
expect(recentMean).toBe(480); // night total, not ~1218 (stage sum)
expect(recentMean).toBeLessThan(960); // < 16 h — never impossible
});
});
@@ -135,6 +135,68 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
expect(prompt).not.toContain('"value": 75');
expect(prompt).not.toContain('"value": 90');
});
it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum (A4)", async () => {
// The ~20.3 h symptom: a source writes BOTH a bare ASLEEP aggregate AND the
// granular CORE/DEEP/REM partition for the same span. Pre-fix the graded
// `series` came from `buildGradedSeriesWithRollups`, which folds bare + each
// granular stage + IN_BED + AWAKE into one day bucket (~1490 min ≈ 24.8 h).
// Post-fix the graded series is built from the deduped per-night points, so
// every recent bucket mean is the night total (480 min = 8 h).
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
// A recent night (last 24 h) so it lands in `graded.recent`.
const wake = new Date(Date.now() - 6 * 60 * 60 * 1000);
const m = 60 * 1000;
const stageRows = [
{ value: 480, measuredAt: wake, sleepStage: "ASLEEP", source: "APPLE_HEALTH" }, // bare aggregate
{ value: 240, measuredAt: new Date(wake.getTime() - 240 * m), sleepStage: "CORE", source: "APPLE_HEALTH" },
{ value: 120, measuredAt: new Date(wake.getTime() - 120 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" },
{ value: 120, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" },
{ value: 470, measuredAt: wake, sleepStage: "IN_BED", source: "APPLE_HEALTH" },
{ value: 20, measuredAt: new Date(wake.getTime() - 200 * m), sleepStage: "AWAKE", source: "APPLE_HEALTH" },
];
vi.mocked(prisma.measurement.count).mockResolvedValue(
stageRows.length as never,
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
stageRows as never,
);
const capture = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion("Summary: solide Nacht.", capture);
await generateMetricStatus({
metric: "SLEEP_DURATION",
userId: "u1",
locale: "de",
force: true,
});
const prompt = capture.userPrompt as string;
const match = prompt.match(/\{[\s\S]*\}/);
expect(match).toBeTruthy();
const snapshot = JSON.parse(match![0]);
const recent = snapshot.SLEEP_DURATION.series.recent as Array<{
mean: number;
}>;
expect(recent.length).toBeGreaterThan(0);
for (const bucket of recent) {
// Night total (480 min = 8 h), never the impossible ~1490-min stage sum.
expect(bucket.mean).toBeLessThanOrEqual(960); // ≤ 16 h
}
expect(recent.at(-1)?.mean).toBe(480);
});
});
describe("metric-status registry", () => {
+44 -9
View File
@@ -36,6 +36,12 @@ import {
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
resolveDashboardLayout,
type ComparisonBaseline,
@@ -145,16 +151,45 @@ export async function buildComparisonSnapshotForUser(
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
const rows = await Promise.all(
types.map(async (type) => {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const summary = summarize(
measurements.map(
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
// raw stage rows mislabels a per-stage average as a night total (and
// double-counts a bare ASLEEP aggregate against its granular twin). Route
// the comparison through the per-night dedup reconstruction so the
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
let dataPoints: DataPoint[];
if (type === "SLEEP_DURATION") {
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: {
measuredAt: true,
value: true,
sleepStage: true,
source: true,
},
}),
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
dataPoints = reconstructSleepNights(
sleepRows as SleepStageRow[],
sleepTz,
sleepPriority,
)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
} else {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
dataPoints = measurements.map(
(m): DataPoint => ({ date: m.measuredAt, value: m.value }),
),
);
);
}
const summary = summarize(dataPoints);
const baselineAvg =
baseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
+28 -2
View File
@@ -12,6 +12,11 @@ import {
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
@@ -568,14 +573,35 @@ export async function extractFeatures(
}
// Sleep Duration
//
// SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
// raw stage rows would average individual stages (and double-count a bare
// ASLEEP aggregate against its granular CORE/DEEP/REM twin). Route the
// feature block through the per-night dedup reconstruction — the same helper
// the dashboard / series / status path use — so `avg7` / `avg30` / `latest`
// are per-night TIME-ASLEEP totals in minutes, never stage averages.
const sleepData = byType("SLEEP_DURATION");
if (sleepData.length > 0) {
const summary = summarize(toDataPoints(sleepData));
const [sleepTz, sleepPriority] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const sleepNights = reconstructSleepNights(
sleepData as unknown as SleepStageRow[],
sleepTz,
sleepPriority,
).filter((n) => n.asleepMinutes > 0);
const summary = summarize(
sleepNights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })),
);
features.sleep = {
avg7: summary.avg7,
avg30: summary.avg30,
latest: summary.latest,
coverage: computeCoverage(sleepData, now),
coverage: computeCoverage(
sleepNights.map((n) => ({ measuredAt: n.measuredAt })),
now,
),
};
}
+15 -5
View File
@@ -47,6 +47,7 @@ import { lookupNormalRange } from "@/lib/insights/derived/norms";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
@@ -271,11 +272,20 @@ export async function generateMetricStatus(args: {
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
}
const series = applyPayloadBudget(points, { now });
const graded = await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
);
// SLEEP_DURATION must never go through `buildGradedSeriesWithRollups`: that
// path reads RAW per-stage rows (and the stage-summed `measurement_rollups`
// tier) and folds IN_BED + AWAKE + bare ASLEEP + CORE/DEEP/REM into one
// bucket, double-counting the bare aggregate against its granular partition —
// it produces an impossible ~20 h "average sleep". Build the graded series
// straight from the deduped per-night TIME-ASLEEP `points` instead, so every
// recent/weekly/monthly/yearly bucket is a night total, not a stage sum.
const graded = isSleep
? buildGradedSeriesFromPoints(points, now)
: await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
);
const summary = summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
);