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