Health Dashboard, часть 3: мои носимые устройства лгали по умолчанию
Часть 3 моего build log по Health Dashboard: как три устройства за одну ночь расходятся в том, что именно они измерили, почему колонка sleep_core годами была ложью и какие правила решают, какому источнику система верит каждую ночь.
После части 2 ingestion-слой выглядит чистым. Сервер принимает payload-ы, нативный iOS-клиент работает рядом с Health Auto Export, схема знает про посегментный сон, дашборд показывает пять фаз сна. Кажется, всё готово. Нет.
Следующий класс проблем стал виден только после того, как данные стали технически корректными. В любую ночь у меня есть три устройства, которые могут что-то сказать о моём сне: Apple Watch на запястье, RingConn1 на пальце и iPhone рядом с кроватью, который использует собственную оценку Apple Sleep Schedule. Каждое из них знает что-то, чего не знают остальные. И ни одно из них не согласно с другими насчёт того, что именно оно измерило.
Эта часть — о reconciliation: небольшом наборе правил, которые решают, какому источнику система верит каждую ночь, и о том, почему колонка с совершенно разумным названием годами лгала, прежде чем кто-то это заметил.
Три источника коротко
Каждое устройство отвечает на немного другой вопрос и использует немного другой словарь:
| Устройство | Что сообщает о сне | Чего не сообщает |
|---|---|---|
| Apple Watch S7+ | стадии по сегментам: deep, REM, core, awake | ничего, если часы не надеты; пропускает naps короче ~15 минут |
| RingConn | общее время сна + несколько summary-полей | нет разбивки по стадиям; отдаёт дневной summary в полночь |
| iPhone (Sleep Schedule) | оценочные окна bedtime / wake-time | нет реальных sensor data; только inference по экрану и движению |
С пульсом похожая история. Apple Watch и RingConn оба измеряют HR в течение дня, но с разной частотой и разной постобработкой. HRV2 — худший случай: Apple измеряет его событийно — Breathe-сессии, после тренировок, иногда ночью; RingConn считает его непрерывно во время сна по собственной RMSSD3 методологии. Это не одно и то же число с погрешностью измерения. Это разные физиологические агрегаты.
Проблема не в том, что какое-то устройство «неправильное». Проблема в том, что в базе есть колонки sleep_core, sleep_total, hrv_avg, и эти колонки тихо смешивают данные от устройств, которые под одним и тем же словом имеют в виду разные вещи.
Колонка, которая лгала
До v2.3 маппинг сна из iOS в health-sync превращал три значения HealthKit в одно wire name:
case .asleepCore, .asleepUnspecified, .asleep: return "sleep_core"
Выглядит безобидно. На Apple Watch с трекингом стадий это отправляет реальные минуты core sleep — Apple-эвристику для лёгкого non-REM сна. На RingConn это отправляет грубый span «просто спал» без какой-либо информации о стадиях. На старых прошивках Apple Watch, которые ещё не умели стадии, — то же самое. Все три варианта попадали в одну колонку sleep_core.
Stacked chart стадий на дашборде показывал их все как «Core». Daily aggregator суммировал их в daily_scores.sleep_core_hours. Readiness score, о котором была часть 1, использовал этот агрегат как вход для sleep efficiency. Для источников без трекинга стадий это давало фразу вроде «у тебя было 7,5 часов core sleep» — утверждение, которое не соответствует ни одному реальному измерению. Источник сказал только asleep. Система сама придумала фазу.
Вот что на практике значит «лгать через умолчание». Данные технически были на месте и технически соответствовали тому, что было измерено. Ложь была в заголовке колонки: sleep_core имела два значения в зависимости от того, какое устройство записало строку, а ни схема, ни график не могли сказать, какое из них сейчас действует.
Исправление было маленьким структурно и большим методологически: ввести пятую метрику фазы сна, sleep_unspecified, и потребовать от iOS-клиента инспектировать HealthKit-сессию перед отправкой. Маппинг стал таким:
case .asleepCore: return "sleep_core" // stages present, real measurement
case .asleepUnspecified, .asleep: return "sleep_unspecified" // coarse "just asleep", no stages
После rollout-а — часть 2 описывает wire contract и миграцию — RingConn-only и iPhone-only ночи перестали попадать в sleep_core и начали попадать в sleep_unspecified. На дашборде появилась пятая полоса стадий между REM и awake с tooltip-ом «Нет разбивки по стадиям для этого источника». Теперь читатель видит, когда ночь была измерена по стадиям, а когда просто зафиксирована по времени.
Пятислойный график стадий после rollout-а _sleep_unspecified_. Серо-голубая полоса между REM и awake — это категория «нет разбивки по стадиям для этого источника», которая раньше молча попадала в _core_.
Пять параллельных SQL-копий, которые разъехались
Когда два источника могут описывать одну и ту же ночь, серверу нужно правило: кому верить для каждой метрики. Первая версия этого правила была SQL-фрагментом, встроенным прямо в запрос, который собирал daily_scores. Второй callsite скопировал его. Третий скопировал второй. К PR #10 существовало пять параллельных копий SQL для sleep cross-validation, каждая рядом со своим запросом.
Они начинали одинаковыми и за следующий месяц разъехались. Каждый PR, который трогал edge case сна, исправлял одну копию. Остальные четыре сохраняли старую логику. Одна конкретная ночь могла пройти через разные правила в зависимости от того, откуда пришёл read path: daily aggregate, chart, briefing payload, per-source breakdown, export.
Само правило reconciliation несложное: предпочитать Apple Watch перед RingConn, брать источник с более гранулярными стадиями, но требовать минимальный sleep total, прежде чем доверять single-source выбору. Точное правило, в виде, который выжил после дедупликации в единый helper sleepCrossValidationPickExpr:
Если два источника оба сообщили сон за одну и ту же ночь и их totals расходятся больше чем в 1,4×, брать минимум — более длинное значение, скорее всего, содержит двойной подсчёт. Если этого нет, предпочитать Apple Watch, когда его total превышает 1 час — это фильтрует случайные получасовые blips; иначе откатываться на RingConn.
Правило «брать минимум при расхождении» звучит наоборот, пока не увидишь, что оно ловит. RingConn иногда отдаёт 0,x-часовой daily summary «asleep» в полночь — stub синхронизации, а не измерение. Без порога в 1 час этот stub плюс 7-часовая ночь Apple Watch удовлетворили бы условию MAX > MIN × 1.4, и дашборд гордо показал бы 0,x часа как ночной сон. Порог 1 час появился после того, как именно такой баг уехал в прод.
Теперь у правила три callsite-а:
sleepCrossValidationPickExpr(valCol): value twin, используется в read paths (metricDataDayFromHourly,metricDataRaw,briefing.go::fetch).sleepCrossValidationPickSourceExpr(table, valCol): source twin, используется вupsertDailyForDate, чтобы выбрать ОДИН источник на ночь, и чтобы все пятьsleep_*стадий пришли с одного устройства.buildDailySleepBlock: multi-day backfill версия, заинлайненная, потому что формаGROUP BYне подходит под flat-table предположение helper-а.
Пороги — 1,0h floor и 1,4× divergence — и константы приоритета источников задокументированы как load-bearing в комментарии внутри buildDailySleepBlock. Любое изменение должно попасть во все три места. Дедупликация была исправлением. Ранний drift был уроком.
Атомарный выбор источника
Выбор источника необходим, но недостаточен. Как только выбран источник на ночь, нужно принять весь его stage breakdown целиком. Иначе получится строка, где sleep_deep взят с Apple Watch, а sleep_core — с RingConn, и сумма не будет соответствовать ничему, что реально происходило.
upsertDailyForDate обеспечивает это через gate sleep_picked_complete: выбранный источник должен дать все пять sleep_* метрик или, для coarse-источников, sleep_total + sleep_unspecified. Иначе строка сохраняется в предыдущем состоянии. Либо атомарное обновление, либо ничего.
У gate есть вторая ветка для coarse-only источников. Без неё multi-source ночь, где MIN-pick попадает на устройство, которое сообщает только sleep_unspecified, проваливалась бы в NULL-записи, а предыдущая ночь со стадиями выживала бы как «истина»: неправильная, но невидимая. Обе ветки поддерживаются синхронно между upsertDailyForDate и buildDailySleepBlock.
Есть известный случай, где это поведение намеренно консервативно. Если будущий источник когда-нибудь отправит только sleep_total — без стадий и без sleep_unspecified, — gate не пройдёт, и предыдущая строка daily_scores сохранится. Вклад нового источника будет молча отброшен для этой ночи. Это сделано осознанно: принятие single-metric pick позволило бы кривому стороннему импортёру стереть настоящую staged night, записав один sleep_total. Нативно импортируемые данные не могут попасть в этот угол: iOS-клиент v2.3 всегда отправляет sleep_total в паре с sleep_unspecified для coarse-источников, а импортёр Apple Health XML мапит и AsleepUnspecified, и bare Asleep в sleep_unspecified. Если будущий источник заставит систему попасть в этот case, чинить нужно upstream emitter, а не ослаблять gate.
Три слоя качества вокруг всего этого
Source picking отвечает на вопрос «какому устройству верить». Вторая половина reconciliation — «что делать со значениями, которые ни одно устройство не должно было измерить». Для этого есть pipeline валидации качества из трёх слоёв, которые работают независимо от того, какой источник произвёл строку:
Layer 1 (ingest): filterImpossible drops out-of-range values
Layer 2 (schema): quality column flags 'ok' / 'impossible' / 'suspect'
Layer 3 (daily): z-score sweep marks autonomic outliers as 'suspect'
Layer 1 — жёсткое отбрасывание невозможного на ingest. internal/health/quality.go задаёт физиологические диапазоны по метрикам: HRV 4–300 ms, RHR4 28–150 bpm, SpO25 70–100%, температура запястья в пределах ±5°C от baseline и так далее. internal/handler/health.go::filterImpossible отбрасывает out-of-range точки до insert-а и пишет rate-limited лог. Метрики без настроенного диапазона проходят без валидации. Это самый дешёвый gate, который ловит самые громкие sensor failures — те, которые иначе ломали бы график.
Layer 2 — колонка quality в metric_points. TEXT NOT NULL DEFAULT 'ok'. Значения: ok, impossible, suspect. Partial index покрывает hot path WHERE quality='ok'. Baseline reads фильтруются по ok, чтобы аномалии не отравляли rolling baseline, от которого зависит сама anomaly detection. ScoreVersion был поднят, когда этот фильтр подключили, потому что старый кэшированный daily_scores был посчитан по другим правилам доверия.
Layer 3 — мягкий z-score6 sweep. internal/storage/quality_audit.go::MarkSuspectPoints считает z-score по каждой метрике относительно 30-дневного rolling personal baseline. Значения выше 3σ получают флаг suspect. Критично, что участвуют только autonomic metrics: HRV, RHR, SpO2, respiratory rate, wrist temperature, VO27, body mass. Behavioural metrics — шаги, калории, exercise minutes — явно исключены. Их бимодальное распределение rest/active сделало бы z-score шумным и помечало бы каждый weekend hike как аномалию.
Layer 3 sweep запускается каждую ночь по tenant в 03:00 local. В текущей production table это 15 строк из 4,7 млн, помеченных suspect за всю историю проекта. Примеры: respiratory_rate=34.5 за 2026-05-02, heart_rate_variability=186.22012 за 2026-05-10. Каждая такая точка достаточно правдоподобна, чтобы не удалять её вслепую, и достаточно необычна, чтобы не кормить ею baseline без проверки.
Короткий список правил, которые выжили
Несколько reconciliation rules появились по ходу итераций, и именно они теперь держат нагрузку в текущей системе. Каждое существует потому, что его удаление производило конкретный баг:
Держать
sleep_unspecifiedотдельно от staged sleep. Без этого возвращается исходная ложь из нескольких разделов выше.Предпочитать Apple Watch перед iPhone и другими источниками, когда оба присутствуют и физиология это поддерживает. Без этого midnight summary stub от RingConn выигрывает в multi-source ночи.
Дедуплицировать chart views иначе, чем daily scoring. Графики могут показывать overlapping ranges из нескольких источников; daily score должен выбрать один, чтобы избежать double-counting.
Сохранять предыдущую строку
daily_scoresвместо того, чтобы malformed single-metric source стёр staged night. Это all-or-nothing gate выше.Запускать soft z-score sweep только по autonomic metrics. Включение behavioural metrics породило «weekend warrior» anomaly cluster, который помечал половину пользовательских походов.
Фильтровать baseline reads по
quality='ok'. Иначеimpossibleзначение, помеченное в Layer 1, всё равно может попасть в baseline, который Layer 3 использует для поиска аномалий.
Так выглядит вся проблема «лжи через умолчание». Ни одно правило по отдельности не выглядит умным. Умная часть — та, до которой пришлось дойти через несколько регрессий, — в том, что reconciliation должен быть явным и в одном месте. Пять параллельных SQL-копий не остаются согласованными. Колонка схемы с двумя значениями не остаётся честной. Z-score sweep, который не отличает autonomic metrics от behavioural metrics, тихо прячет один сигнал под другим.
Настоящий урок
Потребительские health-данные — это не датасет. Это переговоры между устройствами, которые не согласны насчёт того, что они измерили. Иногда они не согласны, потому что у них разные сенсоры. Иногда — потому что прошивка была другой на момент измерения. Иногда — потому что одно устройство лежало на зарядке, а другое придумало daily summary в полночь. Задача системы — не «смёржить» источники. Задача системы — решить, кому верить каждую ночь, по явному правилу, и признать, когда правило не даёт ответа.
Предыдущая статья была о доверии к колонкам: имена метрик должны совпадать с тем, что storage layer под ними понимает. Эта — о доверии к источнику за каждой колонкой каждую ночь. Те же данные, три устройства, одно правило на метрику и колонка качества, которая помечает всё остальное.
Следующая статья продолжит с того, что становится возможным, когда reconciliation работает устойчиво. Когда на каждую ночь есть один источник и явная непригодность для дней, которые нельзя согласовать, наконец можно спросить: прогнозируема ли эта оценка? Или мы просто описывали вчера и называли это завтра?
Умное кольцо (производства КНР, продаётся с 2023 г.), непрерывно измеряющее пульс, HRV, сатурацию, температуру кожи и фазы сна. Оно дешевле и живёт от батареи дольше Apple Watch, но его проприетарные алгоритмы часто расходятся с аппловскими. Экспортирует данные через Apple HealthKit, из-за чего и оказывается в том же конвейере, что и Apple Watch. ↩︎
Вариабельность сердечного ритма (ВСР / HRV) — колебания интервалов между сокращениями сердца в миллисекундах. Как ни странно, высокая вариабельность — это хорошо: ритм сердца, похожий на метроном, указывает на преобладание симпатического тонуса (стресс, болезнь, утомление), в то время как свободно меняющийся темп говорит о парасимпатическом восстановлении. ↩︎
Root Mean Square of Successive Differences. Временной показатель HRV: берутся последовательные R-R интервалы (время между ударами сердца), вычисляются разности между ними, возводятся в квадрат, усредняются, и из полученного значения извлекается квадратный корень. Метод надёжен на коротких промежутках времени (достаточно 5 минут) и является стандартом в потребительских носимых устройствах. RingConn использует RMSSD, тогда как Apple Watch по умолчанию отдаёт близкий параметр SDNN. ↩︎
Resting heart rate — пульс в состоянии покоя. Замеряется в покое, в идеале — в последние часы ночного сна. Планомерный рост ЧСС покоя на протяжении нескольких дней — классический маркер болезни или переутомления. ↩︎
Сатурация — процент гемоглобина, переносящего кислород. Измеряется оптическим сенсором через кожу. У здорового человека в покое этот показатель держится в рамках 95–100%. ↩︎
Число стандартных отклонений, на которое значение отстоит от среднего. Z-score, равный 2, означает «вдвое дальше от среднего, чем обычное ежедневное отклонение». Порог в 3σ на Уровне 3 соответствует точкам, которые выходят за рамки 99,7% исторических данных. ↩︎
Максимальное потребление кислорода при предельной нагрузке. Apple Watch рассчитывает этот показатель косвенно по динамике пульса во время ходьбы или бега, а не измеряет физиологически. ↩︎