feat(insights): add the same-time baseline for cumulative metrics

Compare today's running total at the last completed local hour against this
person's own typical total at that same hour, over a trailing four weeks of
stored intraday profiles. Same estimator as the vitals baseline — median for
the centre, median plus or minus k times MAD for the band — so a reader who
understands one understands the other.

It refuses to speak below fourteen usable days and says so through the
existing insufficient arm rather than a band built on three days. It ships no
projected day total: extrapolating a partial day is a guess, and this exists
to replace guesses.

Steps first; active energy, distance and flights ride the same engine.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:04:01 +02:00
1 parent 22d7e8c30c
commit b2955beffd
5 files changed
+407 -1

No files matched your search

@@ -178,4 +178,14 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
},
caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
},
SAME_TIME_BASELINE: {
methodKey: "insights.derived.composite.SAME_TIME_BASELINE.method",
standard: {
// Same robust median ± k·MAD estimator as the vitals baseline, applied
// to the window's totals at one local hour rather than to daily means.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.SAME_TIME_BASELINE.caveat",
},
};
+31
View File
@@ -15,6 +15,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { buildInsufficient, nowProvenanceTimestamp } from "./coverage";
import {
getDerivedMetricMeta,
isSameTimeBaselineType,
isVitalsBaselineType,
isTrajectoryType,
type DerivedMetricId,
@@ -29,6 +30,7 @@ import { computeSleepScore } from "./sleep-score";
import { computeReadiness } from "./readiness";
import { computeCoincidentDeviation } from "./coincident-deviation";
import { computeTrajectory } from "./trajectory";
import { computeSameTimeBaseline } from "./same-time-baseline";
import {
computeWellnessScore,
type WellnessScoreType,
@@ -207,6 +209,35 @@ export async function computeDerivedMetric(
now,
}) as Promise<Derived<unknown>>;
}
case "SAME_TIME_BASELINE": {
// Today's running total against this person's own typical total at the
// same local hour. Steps is the default because it is the case the
// architecture structurally could not answer before, and the one the
// request was written around.
const requested = args.type ?? "ACTIVITY_STEPS";
if (!isSameTimeBaselineType(requested)) {
return buildInsufficient<unknown>({
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [requested],
},
provenance: {
inputs: [requested],
source: "none",
windowDays: 0,
computedAt: nowProvenanceTimestamp(now),
},
reason: "unsupported_baseline_type",
});
}
return computeSameTimeBaseline(args.userId, args.profile, {
type: requested as MeasurementType,
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
}
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
+12
View File
@@ -40,10 +40,14 @@ export {
DERIVED_METRIC_IDS,
VITALS_BASELINE_TYPES,
TRAJECTORY_TYPES,
SAME_TIME_BASELINE_TYPES,
SAME_TIME_BASELINE_WINDOW_DAYS,
SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
isDerivedMetricId,
getDerivedMetricMeta,
isVitalsBaselineType,
isTrajectoryType,
isSameTimeBaselineType,
} from "./registry";
export type {
DerivedMetricId,
@@ -171,3 +175,11 @@ export type {
TrajectoryOpts,
OlsFit,
} from "./trajectory";
// ── v1.34.0 same-time baseline for cumulative metrics (server-only) ──
export { computeSameTimeBaseline } from "./same-time-baseline";
export type {
SameTimeBaselineValue,
SameTimeBaselineOpts,
SameTimeBand,
} from "./same-time-baseline";
+64 -1
View File
@@ -56,7 +56,9 @@ export type DerivedMetricId =
/** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */
| "SIX_MINUTE_WALK_BAND"
/** v1.11.0 (Epic B): short-horizon OLS projection with a widening prediction band. */
| "TRAJECTORY";
| "TRAJECTORY"
/** v1.34.0: today's running total for a cumulative metric vs this person's own typical total at the same local hour. */
| "SAME_TIME_BASELINE";
// Documented-as-omitted (v1.10.3): two additive HealthKit signals stay
// trend-only with NO derived band, on purpose —
@@ -126,6 +128,58 @@ export const VITALS_BASELINE_TYPES: MeasurementType[] = [
"WEIGHT",
];
/**
* The cumulative metrics a same-time baseline is defined for.
*
* These four accumulate through the day, which is exactly why a "latest
* reading" means nothing for them and a same-hour comparison means everything.
*
* `TIME_IN_DAYLIGHT` and `FALL_COUNT` are cumulative too and are deliberately
* out. Daylight exposure is environmental rather than behavioural — a same-time
* verdict on it would read as a judgement on the weather. A fall count is a
* near-always-zero safety event whose target is zero, and "you are behind your
* usual falls for this hour" is not a sentence this application will ever say.
*/
export const SAME_TIME_BASELINE_TYPES: MeasurementType[] = [
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
"WALKING_RUNNING_DISTANCE",
"FLIGHTS_CLIMBED",
];
/** `true` when the type is one the same-time baseline is defined for. */
export function isSameTimeBaselineType(type: string): boolean {
return (SAME_TIME_BASELINE_TYPES as string[]).includes(type);
}
/**
* The trailing window the same-time baseline draws its typical day from.
*
* Four weeks. Long enough that every weekday is represented several times, so
* a single unusual Tuesday cannot move the median; short enough that the
* comparison still describes the life the person is living now rather than the
* one they had last season.
*/
export const SAME_TIME_BASELINE_WINDOW_DAYS = 28;
/**
* How many usable days must be in hand before the highlight says anything.
*
* THIS IS THE THRESHOLD. It is defined here, in the client-safe registry, so
* the compute engine, the rail builder and the copy that explains the wait all
* read the same number rather than each carrying their own.
*
* Two weeks, not the seven days the vitals baseline uses. A resting heart rate
* is a stable physiological quantity and seven readings genuinely bound it. A
* person's day is not: it has a weekday shape and a weekend shape, and a median
* built from three working days would confidently call a Saturday morning
* "behind" for no better reason than that Saturdays were never in the sample.
* Fourteen days covers both twice over. Below it the metric returns the
* `insufficient` arm with `learning_usual_day`, which is the honest answer and
* the one this arm exists for.
*/
export const SAME_TIME_BASELINE_MIN_HISTORY_DAYS = 14;
const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
VITALS_BASELINE: {
id: "VITALS_BASELINE",
@@ -282,6 +336,15 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
minInputs: 1,
implemented: true,
},
SAME_TIME_BASELINE: {
id: "SAME_TIME_BASELINE",
displayName: "Same-time baseline",
archetype: "any-user-baseline",
inputs: SAME_TIME_BASELINE_TYPES,
minHistoryDays: SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
minInputs: 1,
implemented: true,
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */
@@ -0,0 +1,290 @@
/**
* v1.34.0 — the same-time baseline for a cumulative metric.
*
* `signals-of-day` excludes steps and says why in its own comment: intraday
* step rows arrive as many partial-day fragments, so the newest raw row is not
* a reading. Every other derived metric in this directory compares a latest
* value to a band, and for a quantity that accumulates through the day that
* comparison has no meaning — four thousand steps is a good day at 09:00 and a
* poor one at 22:00.
*
* So this engine compares along the other axis. It takes today's running total
* at the last COMPLETED local hour and puts it against the same person's own
* typical total at that same hour, drawn from a trailing four weeks of their
* stored intraday profiles (`intraday-cumulative-profile.ts`). Same estimator
* as the vitals baseline — median for the centre, median ± k·MAD·1.4826 for
* the band — so a reader who understands one understands the other.
*
* Everything the surface renders is decided HERE: the two curves, the delta,
* the percentage, the band edges and the verdict of where today sits. The copy
* is a deterministic template over these numbers. Nothing downstream computes a
* comparison, and the Coach may explain one that already exists but may never
* produce one.
*
* What it deliberately does not produce: a projected day total. Extrapolating
* a partial day is a guess, and a feature that exists to replace guesses with
* the person's own history has no business shipping one.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
PROFILE_HOURS,
readCumulativeDayProfiles,
localDateKey,
type CumulativeDayProfile,
} from "@/lib/measurements/intraday-cumulative-profile";
import { hourOfDayForUserTz } from "@/lib/measurements/consolidation-tz";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { buildBaselineBand, median, type BaselineProfile } from "./baseline";
import { buildInsufficient, buildOk, deriveCoverage } from "./coverage";
import {
SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
SAME_TIME_BASELINE_WINDOW_DAYS,
} from "./registry";
import type { Derived } from "./types";
/**
* Raw per-sample rows a stored day must have been folded from before it counts
* towards the typical curve.
*
* A day the watch contributed two readings to is not the shape of that day —
* it is two moments — and letting it into the median would quietly claim the
* person walked nothing for twenty-two hours. Four is a low bar that a worn
* watch clears by two orders of magnitude and a barely-synced day does not.
*/
const MIN_PROFILE_SAMPLES = 4;
/**
* Canonical fallback units, matching what the Apple Health mapping writes for
* each cumulative type. Only used when the account holds no row to read the
* unit off — which, given the profile exists, essentially never happens.
*/
const FALLBACK_UNIT: Partial<Record<MeasurementType, string>> = {
ACTIVITY_STEPS: "steps",
ACTIVE_ENERGY_BURNED: "kcal",
WALKING_RUNNING_DISTANCE: "m",
FLIGHTS_CLIMBED: "flights",
};
/** Where today's running total sits against the typical band at this hour. */
export type SameTimeBand = "below" | "within" | "above";
/** The successful `value` payload for a same-time baseline. */
export interface SameTimeBaselineValue {
/** The cumulative metric this compares. */
type: MeasurementType;
/** Canonical unit of every figure below. */
unit: string;
/** IANA zone every "hour" here is expressed in. */
timezone: string;
/** Local calendar day the comparison is for. */
dateKey: string;
/**
* The last COMPLETED local hour (0–23). Both curves are `asOfHour + 1`
* points long and index-aligned: element `h` is the total through the end of
* local hour `h`.
*/
asOfHour: number;
/** Today's running total through the end of `asOfHour`. */
todayValue: number;
/** The median of the window's days at that same hour. */
typicalValue: number;
/** Band lower edge — median − k·MAD·1.4826, floored at zero. */
typicalLow: number;
/** Band upper edge — median + k·MAD·1.4826. */
typicalHigh: number;
/** Signed difference, today − typical. */
delta: number;
/**
* Today as a percentage of typical, rounded. `null` when the typical total
* at this hour is zero — which is the normal state at 05:00 and is an
* undefined ratio, not a hundred percent and not a failure.
*/
percentOfTypical: number | null;
/** The server's verdict of where today sits against the band. */
band: SameTimeBand;
/** Today's cumulative curve, hours 0 … `asOfHour`. */
todayCurve: number[];
/** The typical cumulative curve over the same hours. */
typicalCurve: number[];
/** Days that actually backed the typical curve. */
baselineDays: number;
/** Trailing window the days were drawn from. */
windowDays: number;
}
export interface SameTimeBaselineOpts {
/** The cumulative metric to compare. */
type: MeasurementType;
/** Trailing window override (days). */
windowDays?: number;
now?: Date;
}
/**
* Compute the same-time baseline for one cumulative metric.
*
* `profile` is accepted for signature parity with every other engine in this
* directory — the dispatcher hands each one the same once-loaded profile — but
* a same-time comparison is purely against the person's own history and reads
* nothing off it. The timezone it does need is read here, because a comparison
* whose whole subject is "at this hour" cannot borrow anybody else's clock.
*/
export async function computeSameTimeBaseline(
userId: string,
_profile: BaselineProfile,
opts: SameTimeBaselineOpts,
): Promise<Derived<SameTimeBaselineValue>> {
const now = opts.now ?? new Date();
const type = opts.type;
const windowDays = opts.windowDays ?? SAME_TIME_BASELINE_WINDOW_DAYS;
const computedAt = now.toISOString();
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const timezone = user?.timezone || DEFAULT_TIMEZONE;
const dateKey = localDateKey(now, timezone);
const gate = (reason: string, historyDays: number) =>
buildInsufficient<SameTimeBaselineValue>({
coverage: {
requiredInputs: 1,
presentInputs: historyDays > 0 ? 1 : 0,
historyDays,
missing: historyDays > 0 ? [] : [type],
},
provenance: {
inputs: [type],
source: historyDays > 0 ? "live" : "none",
windowDays,
computedAt,
},
reason,
});
// The comparison is anchored to the last hour that has FINISHED. Comparing
// against an hour still in progress would put a partial hour of today
// against a whole one of every other day and manufacture a shortfall out of
// nothing. Before 01:00 there is no completed hour, and the honest answer is
// that the day is too young to say anything about.
const asOfHour = hourOfDayForUserTz(now, timezone) - 1;
if (asOfHour < 0) return gate("day_too_young", 0);
const profiles = await readCumulativeDayProfiles({
userId,
type,
timezone,
endDateKey: dateKey,
windowDays,
});
const today = profiles.find((p) => p.dateKey === dateKey) ?? null;
const history = profiles.filter(
(p) => p.dateKey !== dateKey && isUsableHistoryDay(p),
);
// No sub-daily rows for today at all. This is the permanent state for every
// account whose activity arrives as a daily total — Fitbit, Withings, Polar
// all write one row per day and never had an intraday stream. It is honest
// absence and must never render as a failure.
if (!today) return gate("no_intraday_today", history.length);
if (history.length < SAME_TIME_BASELINE_MIN_HISTORY_DAYS) {
return gate("learning_usual_day", history.length);
}
// Both curves run hour 0 … asOfHour, index-aligned.
const hours = asOfHour + 1;
const todayCurve = today.hourlyCumulative.slice(0, hours);
const typicalCurve: number[] = [];
for (let h = 0; h < hours; h += 1) {
typicalCurve.push(median(history.map((p) => p.hourlyCumulative[h])));
}
const todayValue = todayCurve[asOfHour];
const typicalValue = typicalCurve[asOfHour];
// The band comes off the same estimator the vitals baseline uses, applied to
// the window's totals AT THIS HOUR rather than to daily means. Floored at
// zero: a cumulative total cannot be negative, and a band edge below zero
// would widen the "within" verdict on the low side for free.
const atHour = history.map((p) => p.hourlyCumulative[asOfHour]);
const spread = buildBaselineBand(atHour);
const typicalLow = Math.max(0, spread ? spread.low : typicalValue);
const typicalHigh = spread ? spread.high : typicalValue;
const band: SameTimeBand =
todayValue < typicalLow
? "below"
: todayValue > typicalHigh
? "above"
: "within";
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: history.length,
missing: [],
fullHistoryDays: windowDays,
});
return buildOk<SameTimeBaselineValue>({
value: {
type,
unit: await resolveUnit(userId, type),
timezone,
dateKey,
asOfHour,
todayValue,
typicalValue,
typicalLow,
typicalHigh,
delta: todayValue - typicalValue,
percentOfTypical:
typicalValue > 0 ? Math.round((todayValue / typicalValue) * 100) : null,
band,
todayCurve,
typicalCurve,
baselineDays: history.length,
windowDays,
},
coverage,
confidence,
provenance: {
inputs: [type],
// The profiles are their own tier, neither a rollup bucket nor a live
// measurement read, so the honest label for the dominant read is `live`.
source: "live",
windowDays,
computedAt,
},
});
}
/** A stored day dense enough to describe the shape of that day. */
function isUsableHistoryDay(p: CumulativeDayProfile): boolean {
return (
p.sampleCount >= MIN_PROFILE_SAMPLES &&
p.hourlyCumulative.length === PROFILE_HOURS
);
}
/**
* The unit the account's own rows for this type carry. Read rather than
* assumed, so a source that stores distance in a different unit than Apple
* Health does labels its own numbers correctly.
*/
async function resolveUnit(
userId: string,
type: MeasurementType,
): Promise<string> {
const row = await prisma.measurement.findFirst({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { unit: true },
});
return row?.unit ?? FALLBACK_UNIT[type] ?? "count";
}