Files
HealthLog/src/lib/insights/features-units.ts
T
Marc-André Bombeck a7e6b89672 fix(insights): hand the briefing its figures in the reader's units
The daily briefing serialised the aggregated feature set as computed:
weight, grip strength and the weight target in kilograms, waist in
centimetres, glucose and the signals of the day in mg/dL (the glucose
signal carried no unit at all), workout distances in kilometres. A
reader on pounds or mmol/L got a briefing in units they do not use.

One step now converts the feature set after all canonical arithmetic,
on both the nightly and the on-demand path: absolute values through the
full transform, differences and slopes through the factor alone, every
converted block labelled, workout distances under unit-neutral keys.
The prompt, the stored fingerprint and the grounding check all read the
converted set, so a unit switch moves the fingerprint and the grounding
check compares the reply against the figures the model was shown. The
weight target sentence, the comparison block and the GLP-1 plateau
context quote weight in the reader's unit; the comparison block also
labelled sleep as hours while carrying minutes.

A unit switch now also re-warms a cached briefing alongside the status
notes and narratives.

Refs #1067
2026-10-01 13:23:17 +02:00

229 lines
7.4 KiB
TypeScript

/**
* The aggregated feature set as a model reads it: every quantity whose unit
* depends on a preference converted into the reader's units, and labelled.
*
* `extractFeatures` computes in canonical units (kilograms, centimetres,
* metres, mg/dL) because the classifications, the correlations and the
* cached feature set are shared by every surface. The briefing and the
* Coach then serialised that canonical set straight into their prompts, so a
* reader on pounds or mmol/L got a narrative in kilograms or mg/dL. This is
* the one step between the two: it runs after all canonical arithmetic and
* before the snapshot is hashed, serialised and checked for grounding, so
* the hash moves with a unit switch and the grounding check compares the
* model's numbers against the same figures the model was shown.
*
* Absolute values take the full transform, differences and slopes the
* factor alone. A metric reader on mg/dL gets every number back unchanged,
* with a unit label added.
*/
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getQuantityTransform,
getReadingTransform,
type DisplayTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import { buildWeightTargetFeature } from "@/lib/targets/weight-trend";
import type {
AggregatedFeatures,
BucketedSeries,
RawFeatures,
} from "./features";
import type { SignalOfDay } from "./signals-of-day";
type Num = number | null;
function abs(value: Num, transform: DisplayTransform): Num {
return typeof value === "number"
? applyDisplayTransform(value, transform)
: value;
}
function delta(value: Num, transform: DisplayTransform): Num {
return typeof value === "number"
? applyDisplayTransformDelta(value, transform)
: value;
}
/** The measurement type each signal of the day is read from. */
const SIGNAL_TYPE: Partial<Record<SignalOfDay["metric"], string>> = {
weight: "WEIGHT",
glucose: "BLOOD_GLUCOSE",
};
/** One signal of the day in the reader's units. */
export function signalInReaderUnits(
signal: SignalOfDay,
units: UnitPreferences,
): SignalOfDay {
const type = SIGNAL_TYPE[signal.metric];
if (!type) return signal;
const t = getReadingTransform(type, units);
return {
...signal,
unit: t.displayUnit,
latest: applyDisplayTransform(signal.latest, t),
avg7: abs(signal.avg7, t),
avg30: abs(signal.avg30, t),
deltaVs7: delta(signal.deltaVs7, t),
deltaVs30: delta(signal.deltaVs30, t),
spread30: delta(signal.spread30, t),
recentAnomaly: signal.recentAnomaly
? {
...signal.recentAnomaly,
value: applyDisplayTransform(signal.recentAnomaly.value, t),
}
: null,
};
}
/** The weight aggregate in the reader's units, its target included. */
export function weightFeatureInReaderUnits(
weight: NonNullable<AggregatedFeatures["weight"]>,
units: UnitPreferences,
): NonNullable<AggregatedFeatures["weight"]> & { unit: string } {
const t = getReadingTransform("WEIGHT", units);
// The target is rebuilt from the canonical band and the canonical trend
// reference, so its position is judged exactly as before and only the
// band it quotes changes unit.
const target = weight.target
? (buildWeightTargetFeature(
{ min: weight.target.min, max: weight.target.max },
{ avg7: weight.avg7, latest: weight.latest },
units.system,
) ?? undefined)
: undefined;
return {
...weight,
unit: t.displayUnit,
latest: applyDisplayTransform(weight.latest, t),
avg7: abs(weight.avg7, t),
avg30: abs(weight.avg30, t),
avg90: abs(weight.avg90, t),
allTimeAvg: abs(weight.allTimeAvg, t),
allTimeMin: abs(weight.allTimeMin, t),
allTimeMax: abs(weight.allTimeMax, t),
slope30: delta(weight.slope30, t),
...(target ? { target } : {}),
};
}
function bucketedInReaderUnits(
series: BucketedSeries,
units: UnitPreferences,
): BucketedSeries & { unit: string } {
const t = getReadingTransform(series.type, units);
return {
...series,
unit: t.displayUnit,
buckets: series.buckets.map((bucket) => ({
...bucket,
mean: applyDisplayTransform(bucket.mean, t),
})),
};
}
/**
* The whole feature set in the reader's units. Pure: returns a new object
* and leaves the (possibly cached and shared) input untouched.
*/
export function featuresInReaderUnits<
T extends AggregatedFeatures | RawFeatures,
>(features: T, units: UnitPreferences): T {
const out: AggregatedFeatures & Partial<RawFeatures> = { ...features };
if (features.weight) {
out.weight = weightFeatureInReaderUnits(features.weight, units);
}
if (features.gripStrength) {
const t = getReadingTransform("GRIP_STRENGTH", units);
out.gripStrength = {
...features.gripStrength,
latest: abs(features.gripStrength.latest, t),
avg30: abs(features.gripStrength.avg30, t),
slope30: delta(features.gripStrength.slope30, t),
unit: t.displayUnit,
} as AggregatedFeatures["gripStrength"];
}
if (features.waist) {
const t = getReadingTransform("WAIST_CIRCUMFERENCE", units);
out.waist = {
...features.waist,
latest: abs(features.waist.latest, t),
avg30: abs(features.waist.avg30, t),
slope30: delta(features.waist.slope30, t),
unit: t.displayUnit,
} as AggregatedFeatures["waist"];
}
if (features.glucose) {
const t = getReadingTransform("BLOOD_GLUCOSE", units);
out.glucose = {
...features.glucose,
avg7: abs(features.glucose.avg7, t),
avg30: abs(features.glucose.avg30, t),
avg90: abs(features.glucose.avg90, t),
latest: abs(features.glucose.latest, t),
slope30: delta(features.glucose.slope30, t),
unit: t.displayUnit,
} as AggregatedFeatures["glucose"];
}
const weightHistory = features.historicalComparison?.weight;
if (features.historicalComparison && weightHistory) {
const t = getReadingTransform("WEIGHT", units);
out.historicalComparison = {
...features.historicalComparison,
weight: {
current7dAvg: abs(weightHistory.current7dAvg, t),
previous30dAvg: abs(weightHistory.previous30dAvg, t),
change: delta(weightHistory.change, t),
unit: t.displayUnit,
} as NonNullable<AggregatedFeatures["historicalComparison"]>["weight"],
};
}
if (features.signalsOfDay) {
out.signalsOfDay = features.signalsOfDay.map((signal) =>
signalInReaderUnits(signal, units),
);
}
if (features.workouts) {
const t = getQuantityTransform("distance", units.system);
// The block is computed in kilometres; the transform takes metres. The
// keys lose their "Km" so a mile figure never sits under a kilometre
// name.
const distance = (value: Num) =>
abs(typeof value === "number" ? value * 1000 : value, t);
const { last7, last30, latest } = features.workouts;
const period = ({ totalDistanceKm, ...rest }: typeof last7) => ({
...rest,
totalDistance: distance(totalDistanceKm),
});
out.workouts = {
last7: period(last7),
last30: period(last30),
latest: latest
? (({ distanceKm, ...rest }) => ({
...rest,
distance: distance(distanceKm),
}))(latest)
: latest,
distanceUnit: t.displayUnit,
} as unknown as AggregatedFeatures["workouts"];
}
if ("bucketedMeasurements" in features) {
out.bucketedMeasurements = features.bucketedMeasurements.map((series) =>
bucketedInReaderUnits(series, units),
);
}
return out as T;
}