fix(insights): keep impossible readings out of the personal band

The baseline-drift card reported a pulse in the hundreds of millions against a
usual range in the tens of thousands. Neither figure is a heart rate, and both
came from the same place.

The application declares a plausibility domain per metric, and every
interactive, import, MCP and Telegram write validates against it. The
derivation layer did not ask. So a single stored value outside that domain — a
provider unit-decode slip, or a row from a writer that never had the gate — was
folded into the day mean, carried by the median into the band, and then held
against itself as "currently". The two numbers in the sentence were wrong by
different amounts, which is why the line read as nonsense rather than as an
exaggeration.

Both readers now refuse a value the application has already declared
impossible: the per-day means behind the band, and the latest-day mean the
deviation reads. A day left with no plausible reading contributes nothing
rather than a fabricated point, and the vital drops out of the comparison — the
coverage floor already knows how to report an absent day. The rollup branch
applies the same rule to a bucket mean, since the rollup writer averages
whatever is stored. Everything downstream of `readDayMeanSeries` inherits it,
which is every consumer of the usual range: the band strip, the drift card, the
changepoint detector, readiness, trajectory, the coach read.

The card also printed raw doubles. Each figure now renders at the metric's own
decimal precision through the shared formatter, so a mean reads as a number
rather than as a float and follows the reader's number convention.

Left open, and recorded for a separate pass: the provider sync writers do not
enforce the domain on the way in, so the impossible rows keep landing.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 14:16:17 +02:00
1 parent 4e9e8649ec
commit 4563bce5b3
6 files changed
+399 -25

No files matched your search

+16 -6
View File
@@ -3,8 +3,9 @@
import { useQuery } from "@tanstack/react-query";
import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { metricFractionDigits } from "@/lib/measurements/value-domain";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -44,6 +45,7 @@ export function HealthStatusCard({
className,
}: HealthStatusCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const { data } = useQuery({
@@ -60,6 +62,14 @@ export function HealthStatusCard({
return key ? t(key) : type;
};
// The engines hand over raw doubles. Interpolating one straight into the
// sentence prints whatever floating-point produced — a pulse mean reads
// "68.33333333333333" — and reads in the wrong number convention for every
// locale that does not use a dot. Render each figure at the metric's own
// decimal precision, through the same formatter the rest of the app uses.
const metricValue = (type: string, value: number): string =>
fmt.number(value, metricFractionDigits(type));
return (
<section
data-slot="health-status-section"
@@ -100,9 +110,9 @@ export function HealthStatusCard({
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.healthStatus.rangeContext", {
value: d.value,
low: d.low,
high: d.high,
value: metricValue(d.type, d.value),
low: metricValue(d.type, d.low),
high: metricValue(d.type, d.high),
})}
</p>
</div>
@@ -137,8 +147,8 @@ export function HealthStatusCard({
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.healthStatus.shiftContext", {
before: s.beforeMean,
after: s.afterMean,
before: metricValue(s.metric, s.beforeMean),
after: metricValue(s.metric, s.afterMean),
})}
</p>
</div>
@@ -0,0 +1,115 @@
/**
* A stored value the application declares impossible is not a measurement.
*
* The personal band ("your usual range") and the deviation line that reads
* against it are both means over stored `Measurement.value`. Neither end
* checked the value against the metric's own plausibility domain, so a single
* impossible pulse reading — the shape a provider unit-decode slip or a writer
* without the range gate produces — moved the day mean, the median carried it
* into the band, and the card said "your pulse is above your usual range,
* currently 111,287,531.01 instead of the usual 36,016.75".
*
* The numbers below are shaped like the real thing: a resting-pulse series in
* the 60s and 70s, one impossible sample dropped into it.
*/
import { describe, expect, it } from "vitest";
import { buildBaselineBand, dayMeansFromRows } from "../baseline";
import { latestDayMeanFromRows } from "../coincident-deviation";
import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
const TZ = "Europe/Berlin";
/** A real-shaped resting-pulse day: four readings in the 60s/70s. */
function pulseDay(day: string, values: number[]) {
return values.map((value, i) => ({
value,
measuredAt: new Date(`${day}T0${6 + i}:30:00.000Z`),
}));
}
describe("metric plausibility domain", () => {
it("admits a real pulse and refuses one no heart produces", () => {
expect(isPlausibleMetricValue("PULSE", 68)).toBe(true);
expect(isPlausibleMetricValue("PULSE", 20)).toBe(true);
expect(isPlausibleMetricValue("PULSE", 300)).toBe(true);
expect(isPlausibleMetricValue("PULSE", 111287531.01)).toBe(false);
expect(isPlausibleMetricValue("PULSE", 36016.75)).toBe(false);
expect(isPlausibleMetricValue("PULSE", 19)).toBe(false);
});
it("passes a metric with no declared range and refuses a non-finite value", () => {
expect(isPlausibleMetricValue("SOMETHING_UNDECLARED", 12345)).toBe(true);
expect(isPlausibleMetricValue("PULSE", Number.NaN)).toBe(false);
expect(isPlausibleMetricValue("PULSE", Number.POSITIVE_INFINITY)).toBe(
false,
);
});
});
describe("per-day means behind the personal band", () => {
it("keeps the day mean on the real readings when one sample is impossible", () => {
const rows = pulseDay("2026-08-01", [66, 70, 111287531.01, 68]);
const [point] = dayMeansFromRows(rows, "PULSE");
expect(point.day).toBe("2026-08-01");
expect(point.mean).toBeCloseTo(68, 6);
});
it("drops a day whose every reading is impossible rather than inventing one", () => {
const rows = [
...pulseDay("2026-08-01", [66, 70, 68]),
...pulseDay("2026-08-02", [36016.75, 111287531.01]),
];
const points = dayMeansFromRows(rows, "PULSE");
expect(points.map((p) => p.day)).toEqual(["2026-08-01"]);
});
it("holds the band inside the metric's own domain across a poisoned month", () => {
const rows: { value: number; measuredAt: Date }[] = [];
for (let d = 1; d <= 28; d += 1) {
const day = `2026-08-${String(d).padStart(2, "0")}`;
// A steady resting pulse with a normal day-to-day wobble.
rows.push(...pulseDay(day, [64 + (d % 5), 70 + (d % 3), 67]));
}
// Three days the provider decoded wrong.
rows.push(...pulseDay("2026-08-05", [36016.75]));
rows.push(...pulseDay("2026-08-17", [111287531.01]));
rows.push(...pulseDay("2026-08-26", [98211.5]));
const band = buildBaselineBand(
dayMeansFromRows(rows, "PULSE").map((p) => p.mean),
"PULSE",
);
expect(band).not.toBeNull();
expect(band!.center).toBeGreaterThan(40);
expect(band!.center).toBeLessThan(120);
expect(band!.low).toBeGreaterThan(20);
expect(band!.high).toBeLessThan(300);
});
});
describe("the latest-day mean the deviation line reads", () => {
it("means only the real readings of the most recent day", () => {
const rows = [
...pulseDay("2026-08-01", [66, 70, 68]),
...pulseDay("2026-08-02", [72, 111287531.01, 74]),
];
const latest = latestDayMeanFromRows(rows, "PULSE", TZ);
expect(latest).not.toBeNull();
expect(latest!.day).toBe("2026-08-02");
expect(latest!.value).toBeCloseTo(73, 6);
});
it("reports nothing when the latest day holds no plausible reading", () => {
const rows = pulseDay("2026-08-02", [36016.75, 111287531.01]);
expect(latestDayMeanFromRows(rows, "PULSE", TZ)).toBeNull();
});
});
+53 -16
View File
@@ -32,7 +32,10 @@ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
import { clampDerivedLowerBound } from "@/lib/measurements/value-domain";
import {
clampDerivedLowerBound,
isPlausibleMetricValue,
} from "@/lib/measurements/value-domain";
import type { ProfileSex } from "@/lib/profile/sex";
import {
probeRollupCoverage,
@@ -209,6 +212,39 @@ export function buildBaselineBand(
};
}
/**
* Group raw readings into per-day means, keeping only the values that sit
* inside the metric's own plausibility domain.
*
* Pure, and exported because this is the arithmetic the band rests on: a
* single stored value the application itself declares impossible drags the
* day's mean with it, the median then carries it into the band, and the
* personal "usual range" reads in the tens of thousands. Filtering here rather
* than at the edge of the card keeps the number wrong in exactly one place —
* nowhere — instead of right on one surface and wrong on the next.
*
* A day whose every reading is implausible produces no point at all. That is
* the honest outcome: the day contributed no measurement, and an absent day is
* something the coverage floor already knows how to report.
*/
export function dayMeansFromRows(
rows: readonly { value: number; measuredAt: Date }[],
type: MeasurementType,
): DayMeanPoint[] {
const byDay = new Map<string, { sum: number; count: number }>();
for (const row of rows) {
if (!isPlausibleMetricValue(type, row.value)) continue;
const day = row.measuredAt.toISOString().slice(0, 10);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += row.value;
acc.count += 1;
byDay.set(day, acc);
}
return [...byDay.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
}
// ── reads ────────────────────────────────────────────────────────────
/**
@@ -237,11 +273,19 @@ export async function readDayMeanSeries(
resolved.granularity === "DAY" &&
resolved.rows.length > 0
) {
const points = resolved.rows.map((row) => ({
day: row.bucketStart.toISOString().slice(0, 10),
mean: row.mean,
}));
return { points, source: "DAY" };
// A bucket mean is only as good as the rows it averaged. The rollup
// writer aggregates whatever is stored, so a reading outside the
// metric's plausibility domain rides straight through into the bucket
// and from there into the band. Drop those buckets on the same rule the
// live read applies to its rows, so the two paths agree about what
// counts as a measurement.
const points = resolved.rows
.filter((row) => isPlausibleMetricValue(type, row.mean))
.map((row) => ({
day: row.bucketStart.toISOString().slice(0, 10),
mean: row.mean,
}));
if (points.length > 0) return { points, source: "DAY" };
}
// Coverage probe said "has buckets" but the window resolved to a
// coarser tier or zero DAY rows → fall through to the live read so
@@ -264,17 +308,10 @@ export async function readDayMeanSeries(
if (rows.length === 0) {
return { points: [], source: "none" };
}
const byDay = new Map<string, { sum: number; count: number }>();
for (const row of rows) {
const day = row.measuredAt.toISOString().slice(0, 10);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += row.value;
acc.count += 1;
byDay.set(day, acc);
const points = dayMeansFromRows(rows, type);
if (points.length === 0) {
return { points: [], source: "none" };
}
const points: DayMeanPoint[] = [...byDay.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
return { points, source: "live" };
}
@@ -40,6 +40,7 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -154,15 +155,36 @@ async function readLatestDayMean(
take: MAX_LATEST_DAY_ROWS,
select: { value: true, measuredAt: true },
});
if (rows.length === 0) return null;
return latestDayMeanFromRows(rows, type, tz);
}
/**
* The pure half of the read above: pick the most recent day present in the
* rows and mean that day's readings.
*
* Readings outside the metric's declared plausibility domain are not means
* material. This side of the comparison had no gate at all, so one impossible
* stored value became "your pulse today" and was then held against a band the
* same value had already inflated — the two numbers in the sentence were both
* wrong, and wrong by different amounts, which is why the line read as
* nonsense rather than as an exaggeration. A day left with no plausible
* reading contributes nothing and the vital drops out of the comparison.
*/
export function latestDayMeanFromRows(
rows: readonly { value: number; measuredAt: Date }[],
type: MeasurementType,
tz: string,
): { value: number; day: string } | null {
const usable = rows.filter((r) => isPlausibleMetricValue(type, r.value));
if (usable.length === 0) return null;
// Derive the most-recent day defensively (do not assume the DB ordering)
// so the "today" reading is always the genuine latest, then mean its rows.
let day = "";
for (const r of rows) {
for (const r of usable) {
const d = userDayKey(r.measuredAt, tz);
if (d > day) day = d;
}
const sameDay = rows.filter((r) => userDayKey(r.measuredAt, tz) === day);
const sameDay = usable.filter((r) => userDayKey(r.measuredAt, tz) === day);
return {
value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length,
day,
+30
View File
@@ -81,6 +81,36 @@ export function isNonNegativeMetric(type: string | null | undefined): boolean {
return range !== undefined && range.min >= 0;
}
/**
* True when the value sits inside the metric's declared plausibility domain —
* the same `VALUE_RANGES` band every interactive, import, MCP and Telegram
* write path validates against before storing a reading.
*
* The derivation layer needs the question answered too, and for the opposite
* direction. A stored value outside the domain is one the application has
* already declared impossible, so it is not a reading: it is a provider glitch,
* a unit-decode slip, or a row from a writer that never had the gate. Folding
* it into a mean or a median makes the statistic say something the data never
* did — a personal pulse band in the tens of thousands, and a "your pulse is
* above your usual range" line built on a number no heart has ever produced.
* A statistic that quietly digests an impossible input is worse than one that
* declines to, because it looks like an answer.
*
* Unknown types and non-finite values answer `false`-safe in opposite ways on
* purpose: an absent range is an absent fact, so the value passes; a NaN is
* never an input to anything.
*/
export function isPlausibleMetricValue(
type: string | null | undefined,
value: number,
): boolean {
if (!Number.isFinite(value)) return false;
if (!type) return true;
const range = VALUE_RANGES[type];
if (range === undefined) return true;
return value >= range.min && value <= range.max;
}
/**
* True when EVERY named metric forbids negative values (and at least one was
* named). Used by the chart, which can paint several series in one axis: a
@@ -0,0 +1,160 @@
/**
* The Insights baseline-drift read, driven as a route against real Postgres.
*
* The unit suite pins the arithmetic; this pins the pipe. A stored pulse value
* the application declares impossible used to reach the card twice over — once
* as "currently", once through the band it inflated — and the line read
* "your pulse is above your usual range, currently 111,287,531.01 instead of
* the usual 36,016.75". The shipped `GET` export answers here, so the readers
* really have to apply the domain rather than a test's copy of it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const DAY_MS = 24 * 60 * 60 * 1000;
/** The two numbers off the report, kept verbatim so the case stays legible. */
const IMPOSSIBLE_PULSE = 111287531.01;
/** A raised but real pulse, so the card has a genuine deviation to report. */
const REAL_RAISED_PULSE = 112;
let counter = 0;
async function makeUser() {
const suffix = `hs-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `hs-${suffix}`,
email: `hs-${suffix}@example.test`,
role: "USER",
timezone: "Europe/Berlin",
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
}
/** A fortnight of ordinary readings for one metric, one per day. */
async function seedDaily(
userId: string,
type: "PULSE" | "WEIGHT",
valueFor: (dayIndex: number) => number,
) {
const rows = [];
for (let d = 14; d >= 1; d -= 1) {
rows.push({
userId,
type,
value: valueFor(d),
unit: type === "PULSE" ? "bpm" : "kg",
measuredAt: new Date(Date.now() - d * DAY_MS),
source: "MANUAL" as const,
});
}
await getPrismaClient().measurement.createMany({ data: rows });
}
async function readHealthStatus() {
const { GET } = await import("@/app/api/insights/health-status/route");
const response = await (GET as (request: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/insights/health-status", {
method: "GET",
}),
);
expect(response.status).toBe(200);
const body = (await response.json()) as {
data: {
present: boolean;
deviations: {
type: string;
value: number;
low: number;
high: number;
}[];
};
};
return body.data;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("GET /api/insights/health-status — implausible stored values", () => {
it("reports today's real pulse, not the impossible row beside it", async () => {
const user = await makeUser();
await signIn(user.id);
// Two banded vitals — the coincident engine needs a pair before it reports.
await seedDaily(user.id, "PULSE", (d) => 64 + (d % 5));
await seedDaily(user.id, "WEIGHT", (d) => 80 + (d % 3) * 0.2);
// Today: a genuinely raised pulse — so the card has something real to say —
// and beside it one impossible reading, the shape a provider unit-decode
// slip leaves behind.
await getPrismaClient().measurement.createMany({
data: [
{
userId: user.id,
type: "PULSE",
value: REAL_RAISED_PULSE,
unit: "bpm",
measuredAt: new Date(Date.now() - 120_000),
source: "MANUAL",
},
{
userId: user.id,
type: "PULSE",
value: IMPOSSIBLE_PULSE,
unit: "bpm",
measuredAt: new Date(Date.now() - 60_000),
source: "MANUAL",
},
],
});
const data = await readHealthStatus();
const pulse = data.deviations.find((d) => d.type === "PULSE");
// The deviation has to be there — otherwise this asserts nothing.
expect(pulse).toBeDefined();
expect(pulse!.value).toBeCloseTo(REAL_RAISED_PULSE, 6);
expect(pulse!.high).toBeLessThan(300);
expect(pulse!.low).toBeGreaterThanOrEqual(20);
// And the impossible number itself never reaches the wire.
expect(JSON.stringify(data)).not.toContain(String(IMPOSSIBLE_PULSE));
});
});