fix(measurements): wire the six deferred Apple Health identifiers

Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining
deferred quantity types to MeasurementType plus the Apple Health
mapping. The batch route already returns 200 with a per-entry skip
for unmappable identifiers; iOS reads that as success and advances
its sync anchor, so any sample with one of these identifiers used
to be dropped forever. The mapping now accepts them and the rows
land.

The walking-asymmetry and walking-double-support percent values
adopt the project's server-side scaling convention (raw HK reading
on the wire, server applies the ×100 at ingest), matching the
walking-steadiness path. iOS releases that still send the
pre-multiplied percent need a one-release shim and are tracked in
the iOS coord notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 22:32:18 +02:00
1 parent b43b57f657
commit befac0ec65
18 files changed
+552 -9

No files matched your search

@@ -71,6 +71,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "measurements.typeWalkingSteadiness",
AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent",
// ── v1.5.5 iOS-coord additions ──
RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
BODY_MASS_INDEX: "measurements.typeBodyMassIndex",
LEAN_BODY_MASS: "measurements.typeLeanBodyMass",
WALKING_HEART_RATE_AVERAGE: "measurements.typeWalkingHeartRateAverage",
WALKING_ASYMMETRY: "measurements.typeWalkingAsymmetry",
WALKING_DOUBLE_SUPPORT: "measurements.typeWalkingDoubleSupport",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -119,6 +126,16 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
// event flag — the event is a louder-cousin of the env quantity.
WALKING_STEADINESS: Gauge,
AUDIO_EXPOSURE_EVENT: Volume2,
// ── v1.5.5 iOS-coord additions ──
// Wind already carries the breathing family (SpO2 uses it);
// Scale carries body-comp; HeartPulse rounds out the cardio
// pair; Footprints + Gauge live in the gait family.
RESPIRATORY_RATE: Wind,
BODY_MASS_INDEX: Scale,
LEAN_BODY_MASS: Scale,
WALKING_HEART_RATE_AVERAGE: HeartPulse,
WALKING_ASYMMETRY: Footprints,
WALKING_DOUBLE_SUPPORT: Footprints,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -161,4 +178,13 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "bg-chart-2/20 text-chart-2",
AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5",
// ── v1.5.5 iOS-coord additions ──
// Reuse the existing palette families: chart-5 (cardio/pulse),
// chart-1 (mass), chart-2 (activity/gait).
RESPIRATORY_RATE: "bg-chart-5/20 text-chart-5",
BODY_MASS_INDEX: "bg-chart-1/20 text-chart-1",
LEAN_BODY_MASS: "bg-chart-1/20 text-chart-1",
WALKING_HEART_RATE_AVERAGE: "bg-chart-3/20 text-chart-3",
WALKING_ASYMMETRY: "bg-chart-2/20 text-chart-2",
WALKING_DOUBLE_SUPPORT: "bg-chart-2/20 text-chart-2",
};
@@ -49,10 +49,17 @@ const EXPECTED_TYPES = [
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
// ── v1.5.5 iOS-coord — six previously-deferred HK identifiers wired ──
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 30 canonical measurement types", () => {
it("exposes the 36 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -105,6 +112,17 @@ describe("measurementTypeEnum coverage", () => {
// in the clinical PDF until the iOS-app sync lands.
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
// v1.5.5 iOS-coord additions held under the same v1.5 PDF gate.
// Respiratory rate, BMI, lean body mass, walking HR average, and
// the two gait-percent metrics ride the same clinical-layout
// deferral as the other v1.4.23+ additions; the doctor PDF picks
// them up once the layout and reference ranges land.
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+16
View File
@@ -65,6 +65,15 @@ export const ALLOWED_CHART_TOKENS = [
// alongside the iOS-app sync.
"metric:WALKING_STEADINESS",
"metric:AUDIO_EXPOSURE_EVENT",
// v1.5.5 — iOS-coord additions. Same posture: the LLM can
// reference these in prose; the chart components ride the
// existing card-shelf renderer.
"metric:RESPIRATORY_RATE",
"metric:BODY_MASS_INDEX",
"metric:LEAN_BODY_MASS",
"metric:WALKING_HEART_RATE_AVERAGE",
"metric:WALKING_ASYMMETRY",
"metric:WALKING_DOUBLE_SUPPORT",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -142,6 +151,13 @@ const ORPHAN_ENUMS = [
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// v1.5.5 iOS-coord additions — same shape, same posture.
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
] as const;
// `\b` boundaries keep ordinary English prose untouched — "weight"
@@ -346,6 +346,117 @@ describe("mapAppleHealthEntry", () => {
});
});
describe("v1.5.5 iOS-coord additions — six previously-deferred identifiers", () => {
it("maps every one of the six identifiers", () => {
const expected: Array<[string, string]> = [
["HKQuantityTypeIdentifierRespiratoryRate", "RESPIRATORY_RATE"],
["HKQuantityTypeIdentifierBodyMassIndex", "BODY_MASS_INDEX"],
["HKQuantityTypeIdentifierLeanBodyMass", "LEAN_BODY_MASS"],
[
"HKQuantityTypeIdentifierWalkingHeartRateAverage",
"WALKING_HEART_RATE_AVERAGE",
],
[
"HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
"WALKING_ASYMMETRY",
],
[
"HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
"WALKING_DOUBLE_SUPPORT",
],
];
for (const [hkId, type] of expected) {
const mapping = APPLE_HEALTH_TYPE_MAP[hkId];
expect(mapping, `${hkId} should be mapped`).toBeDefined();
expect(mapping.measurementType).toBe(type);
}
});
it("scales the gait percent identifiers ×100 server-side (project convention)", () => {
const asym =
APPLE_HEALTH_TYPE_MAP
.HKQuantityTypeIdentifierWalkingAsymmetryPercentage;
const ds =
APPLE_HEALTH_TYPE_MAP
.HKQuantityTypeIdentifierWalkingDoubleSupportPercentage;
// Apple ships these as 0..1 fractions; HealthLog stores 0..100.
expect(asym.convertToDbUnit(0.07)).toBeCloseTo(7);
expect(ds.convertToDbUnit(0.2)).toBeCloseTo(20);
expect(asym.dbUnit).toBe("%");
expect(ds.dbUnit).toBe("%");
});
it("keeps identity conversion for the non-percent additions", () => {
expect(
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierRespiratoryRate.convertToDbUnit(
16,
),
).toBe(16);
expect(
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierBodyMassIndex.convertToDbUnit(
24.5,
),
).toBe(24.5);
expect(
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierLeanBodyMass.convertToDbUnit(
62,
),
).toBe(62);
expect(
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierWalkingHeartRateAverage.convertToDbUnit(
96,
),
).toBe(96);
});
it("removes the six identifiers from the deferred set", () => {
const previouslyDeferred = [
"HKQuantityTypeIdentifierRespiratoryRate",
"HKQuantityTypeIdentifierBodyMassIndex",
"HKQuantityTypeIdentifierLeanBodyMass",
"HKQuantityTypeIdentifierWalkingHeartRateAverage",
"HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
"HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
];
for (const id of previouslyDeferred) {
expect(
HK_QUANTITY_TYPE_DEFERRED.has(id),
`${id} should no longer be deferred`,
).toBe(false);
}
});
it("round-trips a respiratory-rate sample through mapAppleHealthEntry", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate",
value: 16,
unit: "count/min",
startDate: "2026-05-28T03:00:00.000Z",
endDate: "2026-05-28T07:00:00.000Z",
});
expect(out).toEqual({
type: "RESPIRATORY_RATE",
value: 16,
unit: "breaths/min",
takenAt: new Date("2026-05-28T07:00:00.000Z"),
});
});
it("round-trips a walking-asymmetry sample with ×100 scaling", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
value: 0.07,
unit: "%",
startDate: "2026-05-28T08:00:00.000Z",
endDate: "2026-05-28T08:00:00.000Z",
});
expect(out).not.toBeNull();
expect(out!.type).toBe("WALKING_ASYMMETRY");
expect(out!.unit).toBe("%");
expect(out!.value).toBeCloseTo(7);
});
});
describe("v1.4.30 Tier-1 additions (R-F T1.4 + T1.5)", () => {
it("maps appleWalkingSteadiness to WALKING_STEADINESS with × 100 scaling", () => {
const mapping =
+111 -7
View File
@@ -95,6 +95,34 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record<number, SleepStage> = {
* fraction; HealthLog stores percent (0..100).
* - `HKQuantityTypeIdentifierBodyFatPercentage` ships as a 0..1
* fraction; HealthLog stores percent (0..100).
*
* ── Project convention: server-side scaling is canonical ─────────────
*
* Every HealthKit value travels over the wire as the raw HK reading.
* Whatever ×100 / unit-bend / clamp the canonical DB shape needs, the
* server applies it at ingest inside `convertToDbUnit`. Two reasons:
*
* 1. The wire contract is the HK contract — iOS clients (and any
* future Health Connect / Garmin / Fitbit bridge) emit the
* native sensor reading without per-platform pre-massaging.
* 2. The conversion lives next to the canonical DB unit + the
* plausibility-range guard, so a future contributor adding a
* new identifier touches one file instead of three.
*
* The pre-existing precedents already follow the convention:
*
* - `HKQuantityTypeIdentifierOxygenSaturation` (0..1 → 0..100)
* - `HKQuantityTypeIdentifierBodyFatPercentage` (0..1 → 0..100)
* - `HKQuantityTypeIdentifierAppleWalkingSteadiness` (0..1 → 0..100)
*
* The v1.5.5 gait additions (`walkingAsymmetryPercentage` +
* `walkingDoubleSupportPercentage`) extend the precedent. Older iOS
* releases pre-multiplied those two identifiers by ×100 before
* upload (a footgun the audit team flagged); the iOS client is on
* track to drop the pre-multiplication so every HK percent flows
* through the same canonical server-side scaling path. The coord
* note in `.planning/ios-coord/` documents the one-release shim and
* the migration window.
*/
export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
// ── Body composition ────────────────────────────────────────
@@ -322,6 +350,83 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
convertToDbUnit: () => 1,
aggregation: "sum",
},
// ── v1.5.5 iOS-coord — six previously-deferred identifiers ───
// Background: each entry below sat in `HK_QUANTITY_TYPE_DEFERRED`.
// `mapAppleHealthEntry()` returned null; the batch route emitted
// 200 with a per-entry `skipped:"unmappable_identifier"`; the iOS
// app read 200 as success and advanced its sync anchor. Result:
// every sample carrying one of these identifiers was lost
// forever, no retry path. Wired through end-to-end now.
// Respiratory rate — count-per-minute breaths. Watch + iPhone
// sample this during sleep + workouts. Mean aggregation matches
// Apple's own Health-app display (resting RR averaged over the
// sleep window).
HKQuantityTypeIdentifierRespiratoryRate: {
hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate",
measurementType: "RESPIRATORY_RATE",
hkUnit: "count/min",
dbUnit: "breaths/min",
convertToDbUnit: (v) => v,
aggregation: "mean",
},
// BMI — iOS computes it from weight + height before upload. We
// still want a first-class metric for trend display so the iOS
// chart can read a single series instead of recomputing per
// datapoint. Unit-less ratio on the wire; canonical label is
// `kg/m²` to match clinical convention.
HKQuantityTypeIdentifierBodyMassIndex: {
hkIdentifier: "HKQuantityTypeIdentifierBodyMassIndex",
measurementType: "BODY_MASS_INDEX",
hkUnit: "count",
dbUnit: "kg/m²",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
// Lean body mass — body-composition counterpart to FAT_MASS.
// Apple ships this in kg; canonical DB unit is kg too.
HKQuantityTypeIdentifierLeanBodyMass: {
hkIdentifier: "HKQuantityTypeIdentifierLeanBodyMass",
measurementType: "LEAN_BODY_MASS",
hkUnit: "kg",
dbUnit: "kg",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
// Walking heart-rate average — distinct from RESTING_HEART_RATE
// (sleep-window minimum) and spot PULSE. Daily rollup.
HKQuantityTypeIdentifierWalkingHeartRateAverage: {
hkIdentifier: "HKQuantityTypeIdentifierWalkingHeartRateAverage",
measurementType: "WALKING_HEART_RATE_AVERAGE",
hkUnit: "count/min",
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "mean",
},
// Walking asymmetry — Apple ships as a 0..1 fraction; HealthLog
// stores 0..100 (same convention as walking steadiness, body fat,
// oxygen saturation). See the "server-side scaling is canonical"
// block above for the rationale. The iOS client's previous
// pre-upload ×100 multiplication is the documented migration item.
HKQuantityTypeIdentifierWalkingAsymmetryPercentage: {
hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
measurementType: "WALKING_ASYMMETRY",
hkUnit: "%",
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
},
// Walking double-support percentage — gait companion metric.
// Same ×100 server-side scaling convention.
HKQuantityTypeIdentifierWalkingDoubleSupportPercentage: {
hkIdentifier: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
measurementType: "WALKING_DOUBLE_SUPPORT",
hkUnit: "%",
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
},
};
/**
@@ -417,12 +522,13 @@ export function hkIdentifierForType(
* `__tests__/apple-health-mapping.test.ts` flags double-bookings.
*/
export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// Body composition / vitals — v1.5
"HKQuantityTypeIdentifierBodyMassIndex", // computed from weight + height — never stored
// Body composition / vitals
// v1.5.5 — `BodyMassIndex`, `LeanBodyMass`, `RespiratoryRate`,
// `WalkingHeartRateAverage`, `WalkingAsymmetryPercentage`,
// `WalkingDoubleSupportPercentage` moved into the mapping table.
// The remaining identifiers below stay deferred until a sibling
// MeasurementType lands.
"HKQuantityTypeIdentifierHeight", // already on User.heightCm
"HKQuantityTypeIdentifierLeanBodyMass",
"HKQuantityTypeIdentifierRespiratoryRate",
"HKQuantityTypeIdentifierWalkingHeartRateAverage",
"HKQuantityTypeIdentifierHeartRateRecoveryOneMinute",
"HKQuantityTypeIdentifierAppleSleepingWristTemperature",
"HKQuantityTypeIdentifierBasalEnergyBurned",
@@ -432,8 +538,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// Running / walking form — v1.5+
"HKQuantityTypeIdentifierWalkingSpeed",
"HKQuantityTypeIdentifierWalkingStepLength",
"HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
"HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
"HKQuantityTypeIdentifierStairAscentSpeed",
"HKQuantityTypeIdentifierStairDescentSpeed",
"HKQuantityTypeIdentifierSixMinuteWalkTestDistance",
+12
View File
@@ -64,6 +64,9 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["PULSE", "vitals"],
["OXYGEN_SATURATION", "vitals"],
["BODY_TEMPERATURE", "vitals"],
// v1.5.5 — respiratory rate sits alongside SpO2 + body temp as a
// point-in-time clinical vital.
["RESPIRATORY_RATE", "vitals"],
// ── Body composition ──
["WEIGHT", "body"],
@@ -74,6 +77,10 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["TOTAL_BODY_WATER", "body"],
["BONE_MASS", "body"],
["VISCERAL_FAT", "body"],
// v1.5.5 — BMI + lean body mass round out the body-composition
// surface (FAT_MASS already covers the fat side).
["BODY_MASS_INDEX", "body"],
["LEAN_BODY_MASS", "body"],
// ── Activity (cumulative + fitness + mobility) ──
["ACTIVITY_STEPS", "activity"],
@@ -82,6 +89,11 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["WALKING_RUNNING_DISTANCE", "activity"],
["VO2_MAX", "activity"],
["WALKING_STEADINESS", "activity"],
// v1.5.5 — gait + walking-HR-average sit alongside walking
// steadiness as Apple-Health Mobility-section signals.
["WALKING_ASYMMETRY", "activity"],
["WALKING_DOUBLE_SUPPORT", "activity"],
["WALKING_HEART_RATE_AVERAGE", "activity"],
// ── Sleep ──
["SLEEP_DURATION", "sleep"],
+16
View File
@@ -59,6 +59,9 @@ export function getPRDirection(
// Mobility section treats higher as the achievement (the
// recovery direction from a low-steadiness window).
case "WALKING_STEADINESS":
// v1.5.5 — lean body mass is the muscle-mass-adjacent body-comp
// axis where higher is the goal.
case "LEAN_BODY_MASS":
return PersonalRecordDirection.MAX;
// MIN direction — lower value is the record.
@@ -70,6 +73,11 @@ export function getPRDirection(
case "PULSE_WAVE_VELOCITY":
case "AUDIO_EXPOSURE_ENV":
case "AUDIO_EXPOSURE_HEADPHONE":
// v1.5.5 — gait asymmetry + double-support percentages: Apple's
// Mobility section frames lower as the achievement (more
// symmetric gait + shorter double-support fraction = healthier).
case "WALKING_ASYMMETRY":
case "WALKING_DOUBLE_SUPPORT":
return PersonalRecordDirection.MIN;
// Explicitly no PersonalRecord — see comment block above.
@@ -88,6 +96,14 @@ export function getPRDirection(
// a row" is interesting but better surfaced as a streak than a
// PR direction. Defer.
case "AUDIO_EXPOSURE_EVENT":
// v1.5.5 — homeostatic / display-only metrics. Respiratory rate
// is goal-neutral (homeostatic), walking HR average is fitness
// dependent without a clean direction (a higher value can be
// either ill or harder-effort), and BMI direction is user-goal
// dependent — defer until the worker can read User.thresholdsJson.
case "RESPIRATORY_RATE":
case "WALKING_HEART_RATE_AVERAGE":
case "BODY_MASS_INDEX":
return null;
}
}
+42
View File
@@ -35,6 +35,13 @@ export const measurementTypeEnum = z.enum([
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
// ── v1.5.5 iOS-coord — six previously-deferred HK identifiers wired end-to-end ──
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
]);
export const glucoseContextEnum = z.enum([
@@ -97,6 +104,23 @@ const unitMap: Record<string, string> = {
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
// and outdoor athletes accumulate a few hours.
TIME_IN_DAYLIGHT: "minutes",
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths-per-minute — the HK identifier ships as
// `count/min`; we keep the more conventional clinical label.
RESPIRATORY_RATE: "breaths/min",
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
// display string mirrors clinical convention.
BODY_MASS_INDEX: "kg/m²",
// Lean body mass kg — body-composition partner to FAT_MASS.
LEAN_BODY_MASS: "kg",
// Walking heart rate average bpm — daily rollup; distinct from
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
WALKING_HEART_RATE_AVERAGE: "bpm",
// Walking gait percent (0-100 after server-side ×100 scaling).
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
// — see the project convention block in `apple-health-mapping.ts`.
WALKING_ASYMMETRY: "%",
WALKING_DOUBLE_SUPPORT: "%",
};
export function getUnitForType(type: string): string {
@@ -176,6 +200,24 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// Time in daylight (minutes/day) — full 24-hour window. Outdoor work
// tops the range; sedentary indoor days sit near 0.
TIME_IN_DAYLIGHT: { min: 0, max: 1440 },
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths/min — adult range ~12-20 at rest; severe
// distress can spike past 40; bradypnea floor ~4. Generous bounds.
RESPIRATORY_RATE: { min: 3, max: 60 },
// BMI kg/m² — sub-12 is starvation-class, 70 covers extreme obesity.
// Outside the band is almost certainly a height-or-weight typo.
BODY_MASS_INDEX: { min: 8, max: 70 },
// Lean body mass kg — adult plausibility, ~25 kg (small adult) to
// 150 kg (extreme lean athlete). Same shape as FAT_FREE_MASS.
LEAN_BODY_MASS: { min: 10, max: 250 },
// Walking heart rate average bpm — endurance athletes can sit in
// the high 70s while walking; ailing or post-stimulant readings can
// touch 200. Same upper bound as the spot PULSE / RESTING_HEART_RATE.
WALKING_HEART_RATE_AVERAGE: { min: 30, max: 220 },
// Gait percent (0-100). Apple ships these as 0..1 fractions; after
// the ×100 server-side scaling the canonical band is 0..100.
WALKING_ASYMMETRY: { min: 0, max: 100 },
WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 },
};
export function validateMeasurementRange(