fix(insights): place HRV balance against the recent trend, not the band center

computeHrvBalance read recentAvg = baseline.value.center, which is the band
midpoint by construction and therefore always inside [low, high] — so the
"low" and "unbalanced" arms were dead and the card could only ever read
"balanced". Compute recentAvg from the last <= 7 DAY means (the recent
trend) and place that against the longer-window personal band, so a
suppressed recent trend flags "low" and an elevated one flags "unbalanced".

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 148e6cd600
commit cd8e34dac7
2 files changed
+120 -11

No files matched your search

@@ -1,8 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const computeVitalsBaseline = vi.fn();
const readDayMeanSeries = vi.fn();
vi.mock("../baseline", () => ({
computeVitalsBaseline: (...a: unknown[]) => computeVitalsBaseline(...a),
readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a),
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: vi.fn().mockResolvedValue(new Map()),
}));
import { computeHrvBalance, placeHrvBalance } from "../hrv-balance";
@@ -10,7 +15,20 @@ import { computeHrvBalance, placeHrvBalance } from "../hrv-balance";
const NOW = new Date("2026-06-02T07:00:00Z");
const PROFILE = { ageYears: 40, sex: "MALE" as const };
beforeEach(() => vi.clearAllMocks());
/** Build a `readDayMeanSeries` return from a list of recent DAY means. */
function recentSeries(means: number[]) {
return {
points: means.map((mean, i) => ({ day: `2026-05-${20 + i}`, mean })),
source: "DAY" as const,
};
}
beforeEach(() => {
vi.clearAllMocks();
// Default: the recent trend sits on the band center so unrelated tests stay
// "balanced" unless they override the series.
readDayMeanSeries.mockResolvedValue(recentSeries([60, 60, 60]));
});
describe("placeHrvBalance", () => {
it("low below the band floor", () => {
@@ -40,7 +58,7 @@ describe("computeHrvBalance", () => {
}
});
it("builds a balanced band off the baseline center", async () => {
it("builds a balanced band when the recent trend sits inside the band", async () => {
computeVitalsBaseline.mockResolvedValueOnce({
status: "ok",
value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 },
@@ -48,14 +66,74 @@ describe("computeHrvBalance", () => {
confidence: { score: 70, band: "medium" },
provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" },
});
// Recent trend right around the center → balanced.
readDayMeanSeries.mockResolvedValueOnce(recentSeries([58, 62, 60]));
const r = await computeHrvBalance("u1", PROFILE, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.band).toBe("balanced");
expect(r.value.recentAvg).toBe(60);
expect(r.value.recentAvg).toBeCloseTo(60, 5);
expect(r.value.baselineLow).toBe(40);
expect(r.value.baselineHigh).toBe(80);
expect(r.value.sampleDays).toBe(21);
}
});
it("flags 'low' when the RECENT trend is suppressed below the band, not the band center", async () => {
// The band center sits inside [low,high] by construction; the previous
// implementation read it directly so the recentAvg could never go low.
// Drive a suppressed recent 7-day trend and assert it places "low".
computeVitalsBaseline.mockResolvedValueOnce({
status: "ok",
value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] },
confidence: { score: 70, band: "medium" },
provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" },
});
readDayMeanSeries.mockResolvedValueOnce(recentSeries([30, 28, 32]));
const r = await computeHrvBalance("u1", PROFILE, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.band).toBe("low");
expect(r.value.recentAvg).toBeCloseTo(30, 5);
}
});
it("flags 'unbalanced' when the recent trend runs above the band high edge", async () => {
computeVitalsBaseline.mockResolvedValueOnce({
status: "ok",
value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] },
confidence: { score: 70, band: "medium" },
provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" },
});
readDayMeanSeries.mockResolvedValueOnce(recentSeries([95, 100, 98]));
const r = await computeHrvBalance("u1", PROFILE, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.band).toBe("unbalanced");
expect(r.value.recentAvg).toBeGreaterThan(80);
}
});
it("uses only the last ≤ 7 DAY means for the recent trend", async () => {
computeVitalsBaseline.mockResolvedValueOnce({
status: "ok",
value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] },
confidence: { score: 70, band: "medium" },
provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" },
});
// 10 days: the first three (high) must NOT pull the recent mean up — only
// the last 7 (all ~30) count, so the trend is suppressed → "low".
readDayMeanSeries.mockResolvedValueOnce(
recentSeries([90, 90, 90, 30, 30, 30, 30, 30, 30, 30]),
);
const r = await computeHrvBalance("u1", PROFILE, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.recentAvg).toBeCloseTo(30, 5);
expect(r.value.band).toBe("low");
}
});
});
+39 -8
View File
@@ -29,19 +29,29 @@
* placement helper is exported pure for the unit tests.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { RollupCoverageMap } from "@/lib/rollups/measurement-coverage";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import {
buildInsufficient,
buildOk,
deriveCoverage,
nowProvenanceTimestamp,
} from "./coverage";
import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import {
computeVitalsBaseline,
readDayMeanSeries,
type BaselineProfile,
} from "./baseline";
import { isDerivedOk } from "./types";
import type { Derived } from "./types";
const HRV_TYPE: MeasurementType = "HEART_RATE_VARIABILITY";
/** The recent trend rides the last ≤ this many DAY means. */
const RECENT_TREND_DAYS = 7;
/** Personal-trend balance placement. */
export type HrvBalanceBand = "balanced" | "unbalanced" | "low";
@@ -89,12 +99,16 @@ export async function computeHrvBalance(
): Promise<Derived<HrvBalanceValue>> {
const now = opts?.now ?? new Date();
const computedAt = nowProvenanceTimestamp(now);
// One coverage probe shared between the baseline read below and the
// recent-trend read (the pool-contention mitigation; the route passes a
// pre-probed map).
const coverageMap = opts?.coverage ?? (await probeRollupCoverage(userId));
const baseline = await computeVitalsBaseline(userId, profile, {
type: HRV_TYPE,
windowDays: opts?.windowDays,
now,
coverage: opts?.coverage,
coverage: coverageMap,
});
// Below the band floor (or no data) — surface the baseline engine's gated
@@ -107,11 +121,28 @@ export async function computeHrvBalance(
});
}
// The baseline already computed the robust center+band over DAY means; the
// recent average rides the same center for a stable, sparse-cadence-safe
// placement (per-day SDNN is noisy; the median center is the honest
// "recent typical").
const recentAvg = baseline.value.center;
// The recent average is the mean of the last ≤ 7 DAY means — the RECENT
// TREND, placed against the longer-window personal band. Reading the band
// center here (the previous behaviour) made `recentAvg` identical to the
// band midpoint by construction, so it could never fall outside the band:
// the "low" / "unbalanced" arms were dead and the card always read
// "balanced". The recent-trend mean can drift below the band's low edge
// (suppressed HRV) or above its high edge, which is the whole point of a
// trend-vs-baseline placement. Falls back to the band center only when the
// recent series is somehow empty (the baseline being `ok` already implies
// ≥ 7 backing days, so this is defensive).
const recent = await readDayMeanSeries(
userId,
HRV_TYPE,
baseline.provenance.windowDays,
now,
coverageMap,
);
const recentPoints = recent.points.slice(-RECENT_TREND_DAYS);
const recentAvg =
recentPoints.length > 0
? recentPoints.reduce((s, p) => s + p.mean, 0) / recentPoints.length
: baseline.value.center;
const band = placeHrvBalance(
recentAvg,
baseline.value.low,