Files
HealthLog/src/lib/workouts/insight-evidence.ts
T
Marc-André Bombeck bec7538012 fix(workouts): enforce the numbers-only evidence claim in code
The projection's central claim was carried by a comment. It is now walked
before it is returned: any non-numeric leaf at any depth throws, and the only
two string leaves are allowlisted by shape — the sport type against the schema
enum itself, and the day key against its format.

This matters because sport_type is a TEXT column whose API write path is
enum-constrained but whose backup-restore path accepts any non-empty string, so
a restored row can carry arbitrary text. It is re-asserted at the projection
boundary rather than trusted from the column. The heart-rate provenance string
is dropped rather than allowlisted, and route geometry stays consumed-not-carried
— it yields one number, and location history may not ride a prompt.

Own-history rows now collapse cross-source twins through the canonical picker,
as the detail page's comparison already did; a session recorded by two paired
watches was inflating the sample size and biasing every median.
2026-07-19 14:27:59 +02:00

429 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The deterministic evidence block behind the per-workout Activity Insight.
*
* Everything the model is ever told about a session is built here, and the
* shape of this module is the security boundary of the whole feature: it is a
* NUMBERS-ONLY projection over closed fields.
*
* What is excluded, and why each one matters:
*
* - `metadata` — a JSON blob of device bundle ids, HKWorkoutEvent markers and
* per-vendor extras. Never passed in at all; the only thing taken from it
* anywhere on this path is the numeric WHOOP zone-duration array, and that
* goes through `parseWhoopZoneDurations` before it gets near here.
* - `externalId` — a vendor-supplied opaque string. Never selected.
* - route geometry — consumed to derive ONE number (metres climbed) and then
* dropped. It is location history, not a figure, and it may not ride a
* prompt.
* - `sportType` — a TEXT column. The API write path constrains it to
* `workoutSportTypeEnum`, but the BACKUP RESTORE path accepts
* `z.string().min(1)`, so a restored row can carry arbitrary
* attacker-chosen text. It is therefore re-asserted against the enum HERE,
* at projection time, and anything unrecognised folds to `"other"`. The
* enum is imported from the schema module rather than copied, because a
* hand-copied vocabulary drifts.
*
* And the claim is enforced by `assertNumericLeaves`, not by this comment: the
* projection is walked before it is returned and ANY non-numeric leaf at any
* depth throws. A future field that quietly admits a string fails loudly at the
* seam instead of arriving in a prompt.
*
* Determinism is the second contract. The same session always projects to the
* same numbers, which is what makes the input hash a real no-op gate: a WHOOP
* re-sync or an iOS `stats:` overwrite that changes nothing the paragraph
* narrates hashes identically and never reaches a provider.
*/
import { userDayKey } from "@/lib/tz/format";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
import type { WorkoutHrSeries } from "./hr-series";
import type { WorkoutZones } from "./zones";
/** Lookback for the own-history comparison, in days. */
export const OWN_HISTORY_LOOKBACK_DAYS = 90;
/** Cap on the history rows the median reads — bounded work on a heavy account. */
export const OWN_HISTORY_MAX_ROWS = 400;
/**
* The shape of the session's heart rate, reduced to four figures.
*
* Derived from the SAME series the detail route renders (`buildWorkoutHrSeries`),
* deliberately not from `loadIntradayPulse`: that read is day-scoped, so it
* would answer a question about the day rather than about the session, and the
* workout-window series is the richer of the two anyway.
*/
export interface WorkoutHrShape {
bucketSec: number;
buckets: number;
/** Mean bpm across the first half of the session's buckets. */
firstHalfMeanBpm: number;
/** Mean bpm across the second half. */
secondHalfMeanBpm: number;
/** secondHalf − firstHalf. Positive = drifting up as the session went on. */
driftBpm: number;
/** Buckets that stand out as a local high point (see `countPeaks`). */
peaks: number;
/**
* Median seconds from a peak back down to the session's mean. Null when no
* peak ever came back down inside the session.
*/
medianSettleSec: number | null;
}
/** The user's own recent baseline for this sport. Medians, never means. */
export interface WorkoutOwnHistory {
lookbackDays: number;
/** Comparable sessions found, excluding this one. */
sampleSize: number;
medianDurationSec: number;
medianAvgHr: number | null;
medianDistanceM: number | null;
medianEnergyKcal: number | null;
}
export interface WorkoutInsightEvidence {
/** Narrowed against the closed enum — never the raw column. */
sportType: string;
/** User-profile-timezone day the session started. */
localDate: string;
durationSec: number;
distanceM: number | null;
/** Metres climbed, from route altitudes when present, else the column. */
climbM: number | null;
activeEnergyKcal: number | null;
avgHr: number | null;
maxHr: number | null;
minHr: number | null;
/** Seconds in each %HRmax (or device) zone, ascending by zone. */
zoneSeconds: { zone: number; seconds: number }[] | null;
hr: WorkoutHrShape | null;
history: WorkoutOwnHistory | null;
}
/**
* Narrow the stored sport string to the closed vocabulary.
*
* The DB column is free text by design (a new sport is one Zod refinement
* away), so this is the whitelist that keeps an unreviewed value out of the
* prompt. Anything unrecognised projects as `"other"` — the same bucket the
* ingest validator uses.
*/
export function narrowSportType(raw: string): string {
const parsed = workoutSportTypeEnum.safeParse(raw);
return parsed.success ? parsed.data : "other";
}
function median(values: number[]): number | null {
const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[mid]
: Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 10) / 10;
}
/**
* How far above the session's own mean a bucket must sit to count as an effort.
*
* A percentile alone is not enough, and the reason is worth keeping: on a flat
* ride the 90th percentile sits a beat or two above the mean, so ordinary
* sampling noise clears it and a steady session reports several "peaks" that
* the rider did not do. Five bpm is the smallest rise that is a change in
* effort rather than a change in measurement.
*/
const MIN_PEAK_PROMINENCE_BPM = 5;
/**
* Count the session's peaks.
*
* A peak is a bucket that is a strict local maximum against its neighbours AND
* clears both thresholds: the 90th percentile of the session's bucket means
* (relative — it stands out for THIS session) and the prominence floor above
* the session mean (absolute — it is a real rise, not noise). Requiring all
* three is what keeps a steady ride from reporting efforts it did not contain
* while an interval session still reports each of its own.
*/
function countPeaks(means: number[], threshold: number): number[] {
const indices: number[] = [];
for (let i = 1; i < means.length - 1; i++) {
if (
means[i] >= threshold &&
means[i] > means[i - 1] &&
means[i] >= means[i + 1]
) {
indices.push(i);
}
}
return indices;
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const idx = Math.min(
sorted.length - 1,
Math.max(0, Math.round((sorted.length - 1) * p)),
);
return sorted[idx];
}
/**
* Reduce the HR curve to the four figures the paragraph can honestly use.
*
* Returns null below three buckets: two points cannot carry a front/back-half
* comparison, and inventing one from them is exactly the overclaiming the copy
* contract forbids.
*/
export function summariseHrShape(
series: WorkoutHrSeries | null,
): WorkoutHrShape | null {
if (!series || series.points.length < 3) return null;
const means = series.points.map((p) => p.mean);
const half = Math.floor(means.length / 2);
const avg = (xs: number[]) =>
Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 10) / 10;
const firstHalfMeanBpm = avg(means.slice(0, half));
const secondHalfMeanBpm = avg(means.slice(means.length - half));
const sessionMean = avg(means);
const sorted = [...means].sort((a, b) => a - b);
const peakIndices = countPeaks(
means,
Math.max(percentile(sorted, 0.9), sessionMean + MIN_PEAK_PROMINENCE_BPM),
);
// Settle time: from each peak, how long until the curve first comes back to
// the session's own mean. A peak that never settles inside the session
// contributes nothing rather than a censored figure pretending to be one.
const settles: number[] = [];
for (const idx of peakIndices) {
for (let j = idx + 1; j < means.length; j++) {
if (means[j] <= sessionMean) {
settles.push((j - idx) * series.bucketSec);
break;
}
}
}
return {
// `series.source` ("workout_series" | "pulse_window") is deliberately not
// carried: it is provenance for the chart, it is a string, and the
// paragraph has nothing to say about it. Dropping it keeps the projection
// numeric without an allowlist exception.
bucketSec: series.bucketSec,
buckets: series.points.length,
firstHalfMeanBpm,
secondHalfMeanBpm,
driftBpm: Math.round((secondHalfMeanBpm - firstHalfMeanBpm) * 10) / 10,
peaks: peakIndices.length,
medianSettleSec: settles.length > 0 ? median(settles) : null,
};
}
/**
* Total metres climbed, summed from a GeoJSON LineString's altitude channel.
*
* Only positive deltas count — this is climb, not net elevation change, which
* is the figure a rider recognises. Returns null when the geometry carries no
* altitude channel at all (Withings ships static GPX without one), so the
* caller can fall back to the denormalised column instead of reporting a
* confident zero.
*/
export function routeClimbM(geometry: unknown): number | null {
if (!geometry || typeof geometry !== "object") return null;
const coords = (geometry as { coordinates?: unknown }).coordinates;
if (!Array.isArray(coords)) return null;
let climb = 0;
let previous: number | null = null;
let seen = 0;
for (const point of coords) {
if (!Array.isArray(point) || point.length < 3) continue;
const alt = point[2];
if (typeof alt !== "number" || !Number.isFinite(alt)) continue;
seen++;
if (previous !== null && alt > previous) climb += alt - previous;
previous = alt;
}
return seen >= 2 ? Math.round(climb) : null;
}
/** Slim row shape the own-history median reads. */
export interface OwnHistoryRow {
durationSec: number;
avgHeartRate: number | null;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
}
/**
* The user's own median for this sport over the lookback window.
*
* Medians rather than means because a single six-hour outing would drag a mean
* far enough that the paragraph's comparison would be wrong in the ordinary
* case. Returns null below three comparable sessions: two sessions do not make
* a baseline, and the copy contract says so plainly instead.
*
* CONVERGENCE NOTE — read before editing.
*
* `src/lib/workouts/sport-context.ts` computes an own-history comparison for
* the SAME sport, for the workout-detail card and the coach's evidence block.
* The two are not interchangeable today and the differences are deliberate on
* this side, but they should become one function:
*
* | this | sport-context.ts |
* |-----------------------------|-----------------------------|
* | 90-day lookback | 180-day lookback |
* | medians | means |
* | null below 3 sessions | any non-empty result |
*
* Medians and the three-session floor are the ones worth keeping: a paragraph
* that says "right at your usual" must not be moved by one outlier or built on
* a single prior session. The lookback is arbitrary on both sides.
*
* The caller MUST collapse cross-source twins through `pickCanonicalWorkoutRows`
* before calling this, exactly as `sport-context.ts` does — a session recorded
* by two paired watches is one session, and counting it twice inflates the
* sample size and biases every median.
*/
export function summariseOwnHistory(
rows: readonly OwnHistoryRow[],
): WorkoutOwnHistory | null {
if (rows.length < 3) return null;
const durations = rows.map((r) => r.durationSec);
const medianDurationSec = median(durations);
if (medianDurationSec === null) return null;
const numeric = (pick: (r: OwnHistoryRow) => number | null): number[] =>
rows
.map(pick)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
return {
lookbackDays: OWN_HISTORY_LOOKBACK_DAYS,
sampleSize: rows.length,
medianDurationSec: Math.round(medianDurationSec),
medianAvgHr: median(numeric((r) => r.avgHeartRate)),
medianDistanceM: median(numeric((r) => r.totalDistanceM)),
medianEnergyKcal: median(numeric((r) => r.totalEnergyKcal)),
};
}
/** The already-loaded workout row the projection reads. */
export interface WorkoutEvidenceRow {
sportType: string;
startedAt: Date;
durationSec: number;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
minHeartRate: number | null;
elevationM: number | null;
}
export interface BuildEvidenceInput {
row: WorkoutEvidenceRow;
tz: string;
/** The detail route's HR curve for this workout, or null. */
hrSeries: WorkoutHrSeries | null;
/** Zones as the detail route computes them, or null. */
zones: WorkoutZones | null;
/** `WorkoutRoute.geometry`, or null when the source shipped no route. */
routeGeometry: unknown;
/** Same-sport sessions inside the lookback, excluding this workout. */
history: readonly OwnHistoryRow[];
}
/**
* The only two string-valued leaves the projection is allowed to carry, each
* with the shape that makes it safe.
*
* `sportType` is a member of the closed enum; `localDate` is a derived day key.
* Neither can carry attacker text once these hold. Everything else must be a
* number, a boolean, or null.
*/
const ALLOWED_STRING_LEAVES: Record<string, RegExp> = {
sportType: new RegExp(`^(?:${workoutSportTypeEnum.options.join("|")})$`),
localDate: /^\d{4}-\d{2}-\d{2}$/,
};
/**
* Walk the finished projection and throw on anything that is not a number.
*
* This is the enforcement behind the module's headline claim. A comment saying
* "numbers only" is worth nothing the day someone adds a field: it stays true
* in the docblock and false in the payload. Walking every leaf means the claim
* fails at the seam, in the worker, before a provider is resolved — and the
* worker treats a throw as a transient fault, so the paragraph is simply not
* written rather than written from unvetted input.
*
* `hrSource` is deliberately NOT on the allowlist: the HR shape's `source` is a
* two-value internal literal, and it is dropped from the projection entirely
* rather than argued about.
*/
export function assertNumericLeaves(value: unknown, path = "evidence"): void {
if (
value === null ||
typeof value === "number" ||
typeof value === "boolean"
) {
if (typeof value === "number" && !Number.isFinite(value)) {
throw new Error(`workout evidence: non-finite number at ${path}`);
}
return;
}
if (Array.isArray(value)) {
value.forEach((entry, i) => assertNumericLeaves(entry, `${path}[${i}]`));
return;
}
if (typeof value === "object") {
for (const [key, leaf] of Object.entries(
value as Record<string, unknown>,
)) {
const allowed = ALLOWED_STRING_LEAVES[key];
if (allowed) {
if (typeof leaf !== "string" || !allowed.test(leaf)) {
throw new Error(
`workout evidence: ${path}.${key} is not a permitted ${key} value`,
);
}
continue;
}
assertNumericLeaves(leaf, `${path}.${key}`);
}
return;
}
throw new Error(
`workout evidence: non-numeric leaf at ${path} (${typeof value})`,
);
}
/**
* Compose the evidence block. Pure — every read has already happened.
*/
export function buildWorkoutInsightEvidence(
input: BuildEvidenceInput,
): WorkoutInsightEvidence {
const { row } = input;
const evidence: WorkoutInsightEvidence = {
sportType: narrowSportType(row.sportType),
localDate: userDayKey(row.startedAt, input.tz),
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
climbM: routeClimbM(input.routeGeometry) ?? row.elevationM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
minHr: row.minHeartRate,
zoneSeconds:
input.zones?.zones.map((z) => ({ zone: z.zone, seconds: z.seconds })) ??
null,
hr: summariseHrShape(input.hrSeries),
history: summariseOwnHistory(input.history),
};
assertNumericLeaves(evidence);
return evidence;
}