diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts index 44dfd345f..0dece59aa 100644 --- a/src/lib/insights/derived/__tests__/readiness.test.ts +++ b/src/lib/insights/derived/__tests__/readiness.test.ts @@ -114,4 +114,88 @@ describe("computeReadiness gating", () => { expect(result.value.score).toBeLessThanOrEqual(100); } }); + + it("reports confidence reflecting REAL history depth, not the constant window", async () => { + // 10 distinct backing days on a 30-day window. Pre-fix `historyDays` was + // pinned to `windowDays` so `historyFraction` was always 1 and confidence + // hit the ceiling; post-fix it reflects the 10/30 depth and lands below a + // full-history blend. + const DAY_MS = 24 * 60 * 60 * 1000; + const days = (type: string, base: number, count: number) => + Array.from({ length: count }, (_, i) => ({ + value: base, + // i+1 days before NOW so each row lands on a distinct prior UTC day + // inside the 30-day window (valid calendar dates across the month). + measuredAt: new Date(NOW.getTime() - (i + 1) * DAY_MS), + type, + })); + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "RESTING_HEART_RATE") return days(type, 58, 10); + if (type === "HEART_RATE_VARIABILITY") return days(type, 65, 10); + return []; + }, + ); + const shallow = await computeReadiness("u1", PROFILE, { + now: NOW, + windowDays: 30, + }); + + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "RESTING_HEART_RATE") return days(type, 58, 30); + if (type === "HEART_RATE_VARIABILITY") return days(type, 65, 30); + return []; + }, + ); + const deep = await computeReadiness("u1", PROFILE, { + now: NOW, + windowDays: 30, + }); + + expect(shallow.status).toBe("ok"); + expect(deep.status).toBe("ok"); + if (shallow.status === "ok" && deep.status === "ok") { + expect(shallow.coverage.historyDays).toBe(10); + expect(deep.coverage.historyDays).toBe(30); + // A 10-day blend must report lower confidence than a 30-day one. + expect(shallow.confidence.score).toBeLessThan(deep.confidence.score); + } + }); + + it("provenance source is 'live' for a blend backed only by sleep + mood", async () => { + // No vital readings (RHR/HRV/resp all gate). Mood entries present + a + // scorable sleep night → the two present components are both live reads, + // so the source chip must read 'live', never DAY. + moodFindMany.mockResolvedValue( + Array.from({ length: 8 }, (_, i) => ({ + score: 4, + moodLoggedAt: new Date(`2026-05-${String(20 + i).padStart(2, "0")}T20:00:00Z`), + })), + ); + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "SLEEP_DURATION") { + // Three scorable nights so the sleep score lights up. + return [ + { value: 420, measuredAt: new Date("2026-05-31T06:00:00Z"), sleepStage: "ASLEEP" }, + { value: 430, measuredAt: new Date("2026-06-01T06:10:00Z"), sleepStage: "ASLEEP" }, + { value: 410, measuredAt: new Date("2026-06-02T06:05:00Z"), sleepStage: "ASLEEP" }, + ]; + } + return []; + }, + ); + const result = await computeReadiness("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const present = result.value.components.filter((c) => c.value !== null); + const keys = present.map((c) => c.key).sort(); + expect(keys).toEqual(["mood", "sleep"]); + expect(result.provenance.source).toBe("live"); + } + }); }); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 74359d7f5..518857dda 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -152,6 +152,7 @@ export async function computeCoincidentDeviation( const vitals: VitalDeviation[] = []; let latestDay = ""; let anyDaySource = false; + let maxHistoryDays = 0; // Fan the baseline engine across the supported vitals. Each baseline read // shares the one coverage probe (no per-vital re-probe). @@ -167,6 +168,9 @@ export async function computeCoincidentDeviation( const latest = await readLatestDayMean(userId, type, windowDays, now); if (!latest) continue; if (latest.day > latestDay) latestDay = latest.day; + if (baseline.coverage.historyDays > maxHistoryDays) { + maxHistoryDays = baseline.coverage.historyDays; + } vitals.push( classifyDeviation( type, @@ -209,7 +213,12 @@ export async function computeCoincidentDeviation( // The coverage axis is "how many vitals could have coincided". requiredInputs: vitals.length, presentInputs: vitals.length, - historyDays: windowDays, + // v1.10.0 QA: the REAL distinct-history-days backing the deepest + // contributing vital, not the constant `windowDays` (which pinned + // `historyFraction` to 1 so a 7-day and a 30-day blend reported the same + // confidence). The composite is at least as well-backed as its + // best-supported vital. + historyDays: maxHistoryDays, missing: [], fullHistoryDays: windowDays, }); diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts index 1a2d4ccb2..e1908a9c7 100644 --- a/src/lib/insights/derived/readiness.ts +++ b/src/lib/insights/derived/readiness.ts @@ -196,7 +196,12 @@ async function scoreVitalDeviation( windowDays: number, now: Date, coverage: RollupCoverageMap, -): Promise<{ value: number | null; source: DerivedProvenanceSource }> { +): Promise<{ + value: number | null; + source: DerivedProvenanceSource; + /** Distinct history days backing this component's baseline (0 when gated). */ + historyDays: number; +}> { const baseline = await computeVitalsBaseline(userId, profile, { type, windowDays, @@ -204,11 +209,19 @@ async function scoreVitalDeviation( coverage, }); if (baseline.status !== "ok") { - return { value: null, source: baseline.provenance.source }; + return { + value: null, + source: baseline.provenance.source, + historyDays: baseline.coverage.historyDays, + }; } const today = await readLatestDayMean(userId, type, windowDays, now); if (today == null) { - return { value: null, source: baseline.provenance.source }; + return { + value: null, + source: baseline.provenance.source, + historyDays: baseline.coverage.historyDays, + }; } const value = scoreDeviation( today, @@ -216,7 +229,11 @@ async function scoreVitalDeviation( baseline.value.spread, direction, ); - return { value, source: baseline.provenance.source }; + return { + value, + source: baseline.provenance.source, + historyDays: baseline.coverage.historyDays, + }; } // ── compute ───────────────────────────────────────────────────────────── @@ -285,10 +302,13 @@ export async function computeReadiness( // null when there is no scorable night, dropping the component). const sleep = await computeSleepScore(userId, profile, { windowDays, now }); const sleepValue = sleep.status === "ok" ? sleep.value.score : null; + const sleepHistoryDays = + sleep.status === "ok" ? sleep.coverage.historyDays : 0; // Mood stability — reuse the shipped mood-aggregate stability score over // the window's daily means. - const moodValue = await readMoodStability(userId, windowDays, now); + const mood = await readMoodStability(userId, windowDays, now); + const moodValue = mood.value; const raw: Record = { rhr: rhr.value, @@ -304,10 +324,41 @@ export async function computeReadiness( .filter((c) => c.value === null) .map((c) => c.key); - // Provenance source: DAY when any vital read the rollup tier, else live. - const anyDay = [rhr.source, hrv.source, resp.source].includes("DAY"); + // The REAL history depth backing the composite. v1.10.0 QA: this used to be + // pinned to `windowDays` (the constant), so `historyFraction` was always 1 + // and a 7-day blend reported the same confidence as a 30-day one. Take the + // history of the deepest PRESENT component — the blend is at least as + // well-backed as its best-supported contributor — so confidence reflects + // actual depth. + const presentHistories: number[] = []; + if (rhr.value !== null) presentHistories.push(rhr.historyDays); + if (hrv.value !== null) presentHistories.push(hrv.historyDays); + if (resp.value !== null) presentHistories.push(resp.historyDays); + if (sleepValue !== null) presentHistories.push(sleepHistoryDays); + if (moodValue !== null) presentHistories.push(mood.historyDays); + const compositeHistoryDays = + presentHistories.length > 0 ? Math.max(...presentHistories) : 0; + + // Provenance source (#6): DAY when any PRESENT input read the rollup tier, + // else live. The previous form looked only at RHR/HRV/resp; a blend backed + // solely by sleep + mood (both live reads) mislabelled as DAY whenever one + // of the three gated vitals happened to resolve to DAY despite contributing + // nothing. Fold sleep + mood provenance in so the chip matches what + // actually backed the headline. + const presentSources: DerivedProvenanceSource[] = []; + if (rhr.value !== null) presentSources.push(rhr.source); + if (hrv.value !== null) presentSources.push(hrv.source); + if (resp.value !== null) presentSources.push(resp.source); + // Sleep + mood are live-SQL reads (no rollup tier for per-stage sleep rows + // or mood entries), so a present sleep/mood component contributes "live". + if (sleepValue !== null) presentSources.push(sleep.provenance.source); + if (moodValue !== null) presentSources.push("live"); const source: DerivedProvenanceSource = - presentCount === 0 ? "none" : anyDay ? "DAY" : "live"; + presentCount === 0 + ? "none" + : presentSources.includes("DAY") + ? "DAY" + : "live"; if (presentCount < READINESS_MIN_COMPONENTS) { const { coverage: cov } = deriveCoverage({ @@ -327,7 +378,7 @@ export async function computeReadiness( const { coverage: cov, confidence } = deriveCoverage({ requiredInputs: components.length, presentInputs: presentCount, - historyDays: windowDays, + historyDays: compositeHistoryDays, missing, fullHistoryDays: windowDays, }); @@ -361,7 +412,7 @@ async function readMoodStability( userId: string, windowDays: number, now: Date, -): Promise { +): Promise<{ value: number | null; historyDays: number }> { const since = new Date(now.getTime() - windowDays * MS_PER_DAY); const rows = await prisma.moodEntry.findMany({ where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, @@ -369,7 +420,7 @@ async function readMoodStability( take: 2000, select: { score: true, moodLoggedAt: true }, }); - if (rows.length === 0) return null; + if (rows.length === 0) return { value: null, historyDays: 0 }; // Collapse to daily means keyed by dayOffset (newest = 0), matching the // mood-aggregate DailyPoint shape. const byDay = new Map(); @@ -387,5 +438,8 @@ async function readMoodStability( value: acc.sum / acc.count, })); const stability = computeMoodStability(daily); - return stability ? stability.score : null; + return { + value: stability ? stability.score : null, + historyDays: daily.length, + }; }