fix(insights): confidence reflects real history depth; readiness source folds sleep + mood

The readiness and coincident-deviation composites passed historyDays =
windowDays (a constant) into deriveCoverage, pinning historyFraction to 1 so
a 7-day and a 30-day blend reported identical confidence. Pass the real
distinct-history-days backing the composite (the depth of the
best-supported present contributor) so confidence tracks actual depth.

The readiness provenance source was derived only from RHR/HRV/respiratory, so
a blend backed solely by sleep + mood could mislabel as DAY. Fold the present
sleep + mood provenance in so the source chip matches what actually backed
the headline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-02 23:40:24 +02:00
1 parent fc6924d52f
commit dabdfdf68e
3 files changed
+160 -13

No files matched your search

@@ -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");
}
});
});
@@ -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,
});
+66 -12
View File
@@ -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<ReadinessComponentKey, number | null> = {
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<number | null> {
): 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<number, { sum: number; count: number }>();
@@ -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,
};
}