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