# Разведка на живых данных Журнал наблюдений за реальным потоком Health Auto Export. Документация формата ([wiki](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format)) тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому источником истины служит этот файл. Пополняется по мере накопления доставок. Каждый вывод — с числами и командой, которой он получен, чтобы его можно было перепроверить. ## Как снималось Сервис запущен локально (`task run`), телефон шлёт по локальной сети на IP машины. Автоматизация — REST API, JSON, интервал 5 минут. Накоплено к 2026-08-01: **42 доставки, 165 МБ тел, 6,6 МБ архива**. Три автоматизации, режимы менялись по ходу разведки: | автоматизация | что шлёт | режимы, которые прошли | |---|---|---| | `BC99C8A3` | показатели здоровья | суммирование посекундно → поминутно → **выключено**, период Today → Default → **Since Last Sync** | | `37A43AE1` | тренировки (сперва ошибочно показатели) | период Default | | `F4458FA4` | состояние разума | период Default | За это время снято: суммированные данные обеих гранулярностей, несуммированные, тренировка в помещении и уличная с геотреком, состояния разума, ночь целиком. Разбор — командами вида: ``` gzip -dc raw/2026/07/31/.json.gz | jq -r '...' ``` ## 1. Поле `source` существует Документация утверждает, что источника в точке метрики нет. **Это неверно** — `source` есть в каждой точке, в обоих режимах группировки. Значения бывают **составными**, через `|`: ``` 156386 Apple Watch Ultra 3|iPad (Anton) 133923 Apple Watch Ultra 3 39903 Apple Watch Ultra 3|iPhone (Anton) 294 iPhone (Anton) 18 (пустая строка) 10 AutoSleep ``` Составное значение означает, что несколько устройств писали одно и то же, а Health Auto Export слил их вклад в одну точку и перечислил всех участников. Пустая строка тоже встречается (18 точек `apple_stand_hour`) — поле необязательно. ### Почему в источнике оказался неподвижный iPad Наблюдение, которое сперва выглядело аномалией. Разбор: - iPad **никогда не встречается в одиночку**, только в паре с часами; - он появляется ровно в одной метрике — `basal_energy_burned`; - суммы за сутки правдоподобны (1892 и 2121 ккал базального обмена против 423–519 ккал активной энергии), то есть **вклады не складываются дважды**. Базальный обмен — единственная метрика, не требующая движения: это расчёт от профиля на прошедшее время, и его пишет любое устройство с Health, включая стоящий на столе планшет. По той же причине `iPhone (Anton)` приклеивается к шагам и дистанции — телефон в кармане их честно считает. **Вывод:** составной `source` — это метка «кто вложился», а не признак порчи данных. Значения корректны. ## 2. Порядок ключей в JSON нестабилен Самая важная находка для реализации. Из 81 952 точек, встретившихся в двух доставках, **67 534 отличаются только порядком ключей**: ```json {"qty":0.005001252398249198,"date":"2026-07-31 00:24:43 +0300","source":"Apple Watch Ultra 3"} {"date":"2026-07-31 00:24:43 +0300","source":"Apple Watch Ultra 3","qty":0.005001252398249198} ``` Хеш по сырым байтам поймал бы только **18%** повторов — витрина распухла бы в пять раз дублями одних и тех же точек. **Следствие для схемы:** хеш содержимого считается по **канонической форме с рекурсивной сортировкой ключей**. Рекурсивной — потому что одиннадцать оставшихся «расхождений» оказались тем же самым беспорядком внутри вложенного массива `heartbeatSeries`. ## 3. Значения не меняются задним числом После канонизации расхождений по значению — **ноль на 81 941 общей точке**. Гипотеза, на которой строилась модель идентичности («сырые сэмплы HealthKit неизменяемы, задним числом пересчитываются только агрегаты»), подтвердилась для посекундного режима. Модель «повтор — no-op» верна. **Не проверено для минутного режима** — см. «Открытые вопросы». ## 4. Формы точки Единой формы значения нет, но их немного. При посекундной группировке — шесть на 330 тысяч точек: ``` 328891 [date, qty, source] 934 [date, start, end, qty, source] 537 [date, start, end, Min, Avg, Max, context, source] heart_rate 107 [date, start, end, startDate, endDate, qty, value, source] sleep_analysis 43 [date, start, end, Min, Avg, Max, source] 22 [date, start, end, qty, heartbeatSeries, source] HRV ``` При минутной — три: ``` 6447 [date, qty, source] 573 [Avg, Max, Min, date, source] 2 [asleep, awake, core, date, deep, inBed, inBedEnd, inBedStart, rem, sleepEnd, sleepStart, source, totalSleep] sleep_analysis ``` Обобщённая модель (`payload JSON` + нормализованное время) покрывает оба режима без выделения типов. Даты — строкой с офсетом: `2026-07-31 12:00:00 +0300`, не RFC 3339. Внутри `heartbeatSeries` время другое — числовой Unix timestamp с долями. ## 5. «Минимальная гранулярность» даёт синтетические данные При группировке Default точки идут **раз в секунду**, непрерывно: ``` 2026-07-31 00:00:00 +0300 2026-07-31 00:00:01 +0300 2026-07-31 00:00:02 +0300 ``` 86 400 точек в сутки на один только `basal_energy_burned`. Но посекундный базальный обмен **никто не измеряет** — это гладкая расчётная кривая, которую Health Auto Export нарезает на секундные ломтики по запрошенной группировке. Проверка: суммы за завершившиеся сутки 30 июля совпадают до килокалории. | | посекундно | поминутно | |---|---|---| | `basal_energy_burned` | 2181 ккал | 2181 ккал | | `active_energy` | 519 ккал | 519 ккал | **Вывод:** мелкая группировка ≠ сырые данные. > **Уточнено находкой 20.** Причина посекундной сетки — не группировка: > выключение суммирования её не убрало. И это не выдумка на пустом месте, а > нарезка настоящих сэмплов, которых под ней в 2,4 раза меньше. Читать > находку 20 вместе с этой. ## 6. Что теряет минутная группировка Не всё посекундное было мусором. При переходе на минуты исчезает: - **`heartbeatSeries`** — межударные интервалы, по полсотни ударов в каждой из 22 записей `heart_rate_variability`. Единственные по-настоящему измеренные сырые данные во всём наборе; остаётся одно число `qty`. - **Детализация сна** — было 107 эпизодов с фазами и границами, стало 2 суточных агрегата (`totalSleep / core / deep / rem / awake`). - **`context` у пульса и `start`/`end`** у интервальных метрик — схлопываются в одну `date`. По объёму потерянное — около **130 точек в сутки** на фоне 3300. То есть оно стоит копейки, но выброшено заодно с посекундной интерполяцией, которая стоила 150 тысяч точек. Возможный гибрид: вторая автоматизация без группировки только для `sleep_analysis` и `heart_rate_variability`, первая — минутная для остального. Наборы метрик не пересекаются, поэтому коллизий по `(метрика, время)` между автоматизациями не возникнет и «поток-источник» в ключе не понадобится. ## 7. Объём | режим | тело | точек | в архиве | точек в сутки | |---|---|---|---|---| | посекундно | 57,2 МБ | 330 534 | 2,2 МБ | ~155 000 | | поминутно | 1,2 МБ | 7 022 | 72 КБ | ~3 300 | Gzip жмёт такой JSON примерно **в 25 раз** — архив дешевле, чем закладывалось. Пакеты сильно перекрываются: период `Today` покрывал двое суток, `Default` — трое. При интервале в 5 минут каждая доставка целиком переприсылает окно, и архив растёт впустую: посекундно это около 630 МБ в сутки, поминутно — около 20 МБ. Лечится сменой периода на **«Since Last Sync»**. ## 8. Строковые значения локализованы ```json "context": "Не задано" (heart_rate) "value": "Во сне" (sleep_analysis) ``` Приходят **на языке телефона**. Смена языка iOS изменит их, и данные до и после смены перестанут сходиться. Хранить как есть обязаны — это сырьё; но в самоописании такие поля стоит помечать, а клиентам не завязываться на конкретные строки. В минутном режиме оба поля исчезают, так что ловушка возникает только при мелкой группировке. ## 9. Секции Приходит только то, что включено в автоматизации. В снятых доставках — одна секция `metrics` (26 метрик); `workouts` и остальные отсутствуют как ключи, а не приходят пустыми. Разбор обязан это переживать. ## 10. Минутные агрегаты досчитываются задним числом — да Сравнение трёх подряд идущих минутных доставок (интервал 5 минут), ключ `(метрика, дата, источник)`, рекурсивная канонизация: ``` 17:44 → 17:49: общих 7022, изменилось 0, новых 3 17:49 → 17:54: общих 7025, изменилось 2, новых 36 ``` Что именно изменилось: ``` heart_rate 20:36 Avg 69 → 70.25, Max 69 → 71.51 basal_energy_burned 20:32 qty 5.5704 → 7.1345 ``` Обе изменившиеся минуты — **хвост**, возраст 13 и 22 минуты на момент первой отправки. Минутное ведро уезжает неполным, пока сэмплы не досинхронизировались с часов, и в следующей доставке приезжает досчитанным. Дальше в прошлое значения не меняются. Это **отменяет вывод 3 для агрегированного режима**: «повтор — no-op» верно для посекундных сэмплов, но не для минутных агрегатов. ## 11. Ключ `(метрика, дата, источник)` почти уникален, но не всегда Проверка внутри одной доставки, сколько ключей имеют два разных значения: ``` посекундная (330k точек): 2 минутная (7k точек): 0 ``` Оба исключения — `sleep_analysis` от `AutoSleep`: эпизоды сна делят одну `date` (начало сна), а различаются полями `start`/`end` и фазой. То есть `date` там не идентифицирует запись. ### Следствие: координаты против значений Ни чистый хеш содержимого, ни чистая перезапись по ключу не покрывают оба режима: - **хеш содержимого** в минутном режиме накопит по нескольку версий одной минуты (находка 10), и читателю нечем выбрать актуальную; - **перезапись по `(метрика, дата, источник)`** в посекундном режиме потеряет эпизоды сна (эта находка). Разделение полей точки на две группы закрывает оба случая: - **координаты** — `date`, `start`, `end`, `startDate`, `endDate`, `source`: отвечают на вопрос «какая это запись»; - **значения** — всё остальное (`qty`, `Min`/`Avg`/`Max`, `value`, фазы сна, `heartbeatSeries`): отвечают на вопрос «что измерено». Ключ строки — метрика плюс координаты, запись — перезапись значений (last-write-wins). Тогда эпизоды сна расходятся по `start`/`end`, досчитанная минута перезаписывает неполную, а точный повтор не меняет ничего (сверяется по хешу значений). Цена — минимальная интерпретация: список полей-координат фиксирован и не зависит от метрики, незнакомое поле считается значением. Риск в том, что новое координатное поле от Apple схлопнет две разные записи в одну; страхуемся тем, что **перезапись с изменением хеша логируется** — молчаливой потери не будет, а сырой архив хранит все версии. ## 12. Автоматизация опознаётся по `automation-id`, но не по имени Из заголовков, которые шлёт Health Auto Export: ``` automation-id BC99C8A3-8BE7-4519-B545-F3ED6212008E стабилен во всех доставках automation-name (пусто) автоматизация не названа session-id уникален на каждую доставку ``` `automation-id` — стабильный UUID автоматизации, по нему доставки разных автоматизаций различимы на одном эндпоинте. `automation-name` приходит пустым, пока автоматизации не задано имя в приложении; для читаемости `/stats` имя стоит проставить. `session-id` меняется каждую доставку — это идентификатор попытки отправки, а не потока. Годится для сшивания частей, разбитых Batch Requests. ## 13. Заголовок `automation-aggregation` не описывает реальную гранулярность Две доставки с **одинаковым** значением заголовка дали **разную** гранулярность: | автоматизация | заголовок | шаг меток `basal_energy_burned` | |---|---|---| | `BC99C8A3` (17:31) | `Default` | 00:00:00, 00:00:01, 00:00:02 — секунда | | `37A43AE1` (18:00) | `Default` | 00:00:00, 00:01:00, 00:02:00 — минута | Правдоподобное объяснение: `Default` означает «в автоматизации явно не задано», а действующая настройка живёт уровнем выше и была изменена между доставками. Проверить это изнутри данных нельзя. **Следствие:** заголовок годится как метаданные доставки, но **не как описание данных**. Гранулярность определяется по самим меткам времени, а не по `aggregation`. Для самоописания (шаг 5) шаг метрики нужно выводить из данных — как и всё остальное. ## 14. Две автоматизации с одним набором метрик дают дубликат `37A43AE1` была заведена под тренировки, но приехала с теми же 26 показателями здоровья, что и `BC99C8A3` (секция `workouts` в пакете отсутствует). Сравнение по ключу `(метрика, дата, источник)`: ``` общих ключей 6490, значение совпало 6489, изменилось 1 ``` Единственное расхождение — хвостовая минута 20:44, то есть обычный досчёт из находки 10, а не разница между автоматизациями. Пока обе автоматизации шлют одну гранулярность, витрина схлопнет дубликат сама (тот же ключ, то же значение). Опасен другой случай: **одинаковые метрики с разной гранулярностью** — тогда по одному ключу приезжают разные значения, и перезапись начнёт их чередовать в зависимости от того, чья доставка пришла последней. Правило: наборы метрик между автоматизациями не пересекать. ## 15. Тренировка: разнородная структура, много избыточности Первая живая тренировка (ходьба в помещении, 91 секунда, 6,5 КБ на пакет). Двадцать два поля верхнего уровня четырёх разных сортов: ``` id строка UUID из HealthKit — стабильный ключ name, location строка "В помещении Ходьба", "В помещении" ← локализованы start, end строка дата с офсетом, как у метрик duration число 91.746 — СЕКУНДЫ (21:07:25 → 21:08:56) isIndoor булево metadata объект пустой distance, totalEnergy, объект {qty, units} activeEnergyBurned, avgHeartRate, maxHeartRate, intensity, speed, temperature, humidity heartRate объект {avg, max, min}, каждый — {qty, units} activeEnergy массив 2 точки [date, qty, units, source] basalEnergy массив 2 точки [date, qty, units, source] walkingAndRunningDistance массив 2 точки [date, qty, units, source] heartRateData массив 2 точки [date, Min, Avg, Max, units, source] heartRateRecovery массив 13 точек [date, Min, Avg, Max, units, source] ``` Заметное: **сводки дублируют ряды**. `activeEnergyBurned` — это сумма `activeEnergy`, `distance` — сумма `walkingAndRunningDistance`, а `avgHeartRate`/`maxHeartRate` повторяют `heartRate.avg`/`heartRate.max`. `route` отсутствует — тренировка в помещении. Как выглядит маршрут, живьём пока не видели. Точки внутренних рядов по форме совпадают с точками метрик, но **несут `units` в каждой точке**, тогда как у метрик `units` живут на уровне метрики. **Вывод:** решение хранить тренировку одной строкой с полным JSON подтверждается. Раскладывать такую структуру в таблицы означало бы принять десяток решений о том, что здесь главное, — а это уже интерпретация. ## 16. `stateOfMind` живёт по другим соглашениям ```json { "id": "C0E1AF76-1EA3-406B-9F90-BA537FBEB3AD", "kind": "momentary_emotion", "start": "2026-07-31T18:03:51Z", "end": "2026-07-31T18:03:51Z", "valence": 0.02508960573476693, "valenceClassification": "neutral", "labels": ["drained", "calm"], "associations": ["hobbies"] } ``` Отличий от метрик три, и все существенные: 1. **Формат даты другой** — RFC 3339 в UTC с `Z`, а не `2026-07-31 21:03:51 +0300`. То есть разбор дат зависит от секции пакета, одного парсера мало. 2. **Перечисления по-английски** и не локализованы: `kind` (`momentary_emotion` / `daily_mood`), `valenceClassification` (`neutral` / `slightly_pleasant`), `labels`, `associations`. В метриках и тренировках те же по смыслу поля приходят на языке телефона (находка 8) — единого правила у Health Auto Export нет. 3. **Поля `source` нет вовсе.** Набор полей-координат (находка 11) обязан переживать его отсутствие. Есть свой `id` (UUID), как у тренировок, — годится как естественный ключ. ## 17. Набор метрик не фиксирован В одной и той же автоматизации метрик стало **28 вместо 26**: как только появились данные, добавились `mindful_minutes` и `walking_heart_rate_average`. Каталог метрик растёт по факту поступления данных. Это подтверждает выбор обобщённой модели хранения и выводимых схем (шаг 5): фиксированный список метрик в коде устарел бы в тот же день. ## 18. Пустой пакет не отправляется Автоматизация без данных за период молчит совсем — доставки нет. Наблюдение: автоматизация тренировок не слала ничего 15 минут, пока в Health не появилась запись, тогда как автоматизация показателей за то же время доставила трижды. **Следствие для наблюдаемости:** молчание разреженного типа (тренировки, осознанность, ЭКГ, цикл) — норма, а не сбой, и отличить его от сломавшейся автоматизации нечем. Порог тревоги «данных нет N часов» имеет смысл только для показателей здоровья: они идут всегда и годятся как пульс всей связки. По остальным типам в `/stats` осмысленно показывать лишь «когда приходило в последний раз», без тревоги. ## 19. Выключение суммирования: что вернулось и что не изменилось Переключатель «Суммировать данные» выключён (группировка при этом в интерфейсе исчезает). Из пяти проверок, заявленных заранее, подтвердились три. **Вернулось то, что съедала минутная группировка (находка 6):** ``` heart_rate_variability heartbeatSeries на месте: 60, 51 и 48 ударов sleep_analysis снова эпизодами, с value/startDate/endDate heart_rate поле context вернулось ``` **Не изменилось:** посекундная сетка у кумулятивных метрик. `basal_energy_burned` по-прежнему выдаёт ровно **3600 точек в час**, непрерывно, всю ночь. **Заголовок `automation-aggregation` остался `Default`** и при суммировании, и без него. То есть он не различает ни группировку, ни сам факт суммирования — как источник сведений о данных бесполезен полностью. Находка 13 усилена: режим определяется только по самим данным. ## 20. Посекундная сетка — нарезка настоящих сэмплов, а не выдумка Ключевое наблюдение: в пакете 18 014 точек `basal_energy_burned`, но всего **2057 различных значений**, и подряд идущие совпадают до шестнадцатого знака: ``` 21:54:04 qty=0.14020320410520137 21:54:05 qty=0.14020320410520137 21:54:06 qty=0.14020320410520137 21:54:08 qty=0.14020320410520135 ← дрожь последнего разряда от деления ``` Распределение длин серий одинаковых значений: ``` длина 1: 3709 серий длина 4: 523 длина 2: 1786 длина 5: 241 длина 3: 1026 длина 6+: 361 ``` Итого около **7600 серий** — то есть под посекундной сеткой лежат настоящие сэмплы длительностью 1–12 секунд, каждый растянут на свою длину. Инфляция примерно **2,4×**, и вместе с ней теряются границы сэмплов: у кумулятивных метрик `start`/`end` не приходят вовсе. Суммы при этом корректны — нарезка сохраняет итог: ``` 2026-08-01 00:00 334 кДж = 80 ккал точек 3600 2026-08-01 01:00 340 кДж = 81 ккал точек 3600 … ``` 63–82 ккал в час, около 1900 ккал в сутки базального обмена — сходится с измеренным в суммированном режиме (находка 5). **Вывод:** «настоящих» сэмплов от Health Auto Export получить нельзя ни в одном режиме. Выбор такой: посекундная сетка с деталями (эпизоды сна, HRV) и инфляцией 2,4×, либо минутная группировка без деталей. Первое дороже примерно в 20 раз, но при хранении часовыми сжатыми объектами это всё равно копейки. ## 21. Дискретные и кумулятивные метрики ведут себя по-разному Поля `start`/`end` приходят только у части метрик: ``` heart_rate, physical_effort, environmental_audio_exposure, blood_oxygen_saturation, sleep_analysis, heart_rate_variability, apple_stand_hour ← интервал есть basal_energy_burned, active_energy, step_count, walking_running_distance, apple_stand_time … ← только date ``` Деление проходит по границе «дискретное измерение» против «накопительная величина». Накопительные режутся на секунды и теряют интервал (находка 20), дискретные сохраняют свой. Практически: у 24 261 точки из 24 368 в пакете вообще нет `start`/`end`. Поэтому вопрос «в какой час класть сэмпл, пересекающий границу» касается лишь сотни точек в пакете — и решается простым правилом «по `date`». ## 22. Маршрут тренировки Уличная ходьба, 594 секунды, **593 точки маршрута** — одна в секунду. Точка несёт десять полей, а не пять, как обещала документация: ```json {"latitude":44.778909627459036,"longitude":37.70132686458095, "altitude":70.61593273964799,"speed":1.181851863861084, "course":-1,"timestamp":"2026-08-01 10:04:31 +0300", "horizontalAccuracy":10.22251601695661,"verticalAccuracy":9.629558563232422, "speedAccuracy":1.0428272485733032,"courseAccuracy":-1} ``` **Маршрут — 95% веса тренировки**: 190 КБ из 199,6 КБ. Десятиминутная прогулка даёт 200 КБ, часовая пробежка дала бы порядка 1,2 МБ. **Набор полей тренировки зависит от её типа:** ``` только у уличной: route, avgSpeed, maxSpeed, elevationDown, flightsClimbed только у домашней: temperature, humidity, intensity ``` Фиксированной схемы тренировки не существует — ещё один довод за хранение блобом и выводимые схемы. ## 23. Объём в несуммированном режиме ``` basal_energy_burned 3600 точек в час = 86 400 в сутки всего ~135 000 точек в сутки ``` Тот же порядок, что и у посекундного суммированного режима (находка 7). Часовая грань объектов остаётся уместной: 3600 точек в объекте — это около 320 КБ JSON, порядка 13 КБ в сжатом виде. Отдельно: автоматизация тренировок работает с периодом `Default` и потому **переприсылает те же тренировки каждые 5 минут** — 317 КБ на доставку, из которых 95% маршрут. Хеш по `id` тренировки сделает это бесплатным для хранилища, но не для архива. Ей тоже нужен `Since Last Sync`. ## 24. В именах устройств — неразрывные пробелы Источник приходит не тем, чем выглядит: ``` "Apple Watch Ultra 3" ``` Между «Apple», «Watch» и «Ultra» стоят **U+00A0**, а не обычные пробелы (между «Ultra» и «3» — обычный). Обнаружено случайно: фильтр `source == "Apple Watch Ultra 3"`, набранный руками, молча не находил ничего, хотя группировка по тому же полю работала. Источник — сама Apple, не HAE: в родном экспорте `sourceName` содержит те же неразрывные пробелы (находка 34). То есть обойти это выбором источника нельзя. Это ловушка на будущее: любой клиент, отбирающий данные по имени устройства, напишет обычный пробел и получит пустой ответ без всякой ошибки. То же касается составных значений: `"Apple Watch Ultra 3|iPhone (Anton)"`. **Следствие:** в самоописании (шаг 5) значения-примеры нужно отдавать так, чтобы невидимые символы были заметны, а в read API фильтр по источнику — либо не делать, либо нормализовать пробелы на входе и хранить оба варианта. Значение при этом храним дословно, как и всё остальное. ## 25. Разбор ночи: наша сторона чистая, вопросы к устройствам Проверка ночи 31 июля → 1 августа. Из 118 экземпляров `sleep_analysis` во всех доставках после канонизации осталось **43 различных записи** — дедупликация по содержимому отработала, повторов не осталось. **Ряд Apple Watch безупречен:** ``` 35 эпизодов, разрывов 0, пересечений 0 окно 02:02:16 → 07:57:39 сумма эпизодов 5.92 ч = длительность окна 5.92 ч ``` Конец каждого эпизода совпадает с началом следующего, сумма фаз сходится с окном до сотых. Это сильное свидетельство, что в приёме и дедупликации ничего не потерялось: дыра или задвоение сломали бы равенство. **Фазы:** Основная 3,68 ч, БДГ 0,56 ч, Бодрствование 1,68 ч. **Вопросы — не к приёму:** 1. **Часы не покрывают первые четыре часа сна.** AutoSleep фиксирует укладывание в 22:04, владелец сообщает, что уснул около 22:30 и часы были на руке всю ночь, — а записи часов начинаются только с 02:02. Это **аномалия**, а не норма: см. находку 26. 2. **Фазы «Глубокий» этой ночью нет вовсе** — прямое следствие пункта 1: глубокий сон приходится на первые циклы, то есть на пропущенный отрезок. 3. **AutoSleep переписывает ночь более длинной записью.** «В кровати» 22:04–02:21 и «В кровати» 22:04–07:51 — первая вложена во вторую, и обе лежат в хранилище. Третий пункт важен для контракта: **наивная сумма даёт 14,07 ч в кровати за ночь длиной 9,8 ч**. Хранить обе записи правильно (мы не знаем, какая «настоящая», и терять нельзя), но клиент обязан схлопывать вложенные интервалы сам. Это стоит сказать в самоописании. Заодно это первый наблюдавшийся случай, когда **несуммированные данные переписываются задним числом** — не изменением значения по ключу, а выпуском более длинной записи с тем же началом. Идентичность по содержимому его не схлопнет, и это правильно. ## 26. «Since Last Sync» отслеживает время записи, а не дату сэмпла Проверка возникла из аномалии находки 25. Сравнение трёх ночей по данным часов: ``` ночь первая запись последняя эпизодов фазы 2026-07-29 22:21 07:43 57 БДГ, Бодрствование, Глубокий, Основная 2026-07-30 22:34 05:22 40 БДГ, Бодрствование, Глубокий, Основная 2026-07-31 02:02 07:57 35 БДГ, Бодрствование, Основная ``` В обе предыдущие ночи часы начинали писать ровно в момент засыпания. Значит пробел третьей ночи — не обычное поведение устройства. Отсюда вопрос: не потеряли ли данные **мы**? Если бы «Since Last Sync» отбирал сэмплы по их собственной дате, то запись, которую часы дописали в Health утром задним числом, уже не попала бы в выгрузку — метка синхронизации ушла вперёд. Это и есть тот сценарий дыры, ради которого задумывались широкие проходы. Данные говорят, что нет. Записей, чья дата **старше дня доставки**, набралось 78, и часть приехала именно в доставках с периодом «Since Last Sync»: ``` сэмпл 2026-07-31 22:04 (В кровати, AutoSleep) → доставлен 2026-08-01T05:09:07Z ``` К моменту этой доставки метка синхронизации давно прошла 22:04 предыдущего дня — и запись всё равно приехала. Значит **отбор идёт по времени добавления записи в Health, а не по дате самого сэмпла**, и дописанные задним числом данные выгружаются штатно. **Следствия:** - Риск дыр меньше, чем закладывалось: многоуровневая синхронизация остаётся нужной на случай простоя сервиса, но не для ловли поздних дописок. - Задержка данных часов велика: эпизоды 02:02–03:33 приехали в 09:18 по местному времени, то есть через семь часов. Порог тревоги по свежести это обязан учитывать. > **ОПРОВЕРГНУТО находкой 29.** Вывод «пропавшие 22:30–02:02 отсутствуют в > Health» оказался неверным: широкая выгрузка их привезла. Наблюдение про > доставку старых записей верное, но выводить из него сохранность нельзя — > механизм отбора сложнее, чем «по времени добавления». ## 27. Смена режима автоматизации стоила 30 минут данных Покрытие `basal_energy_burned` по минутам за всё время (метрика идёт непрерывно, поэтому годится как индикатор работы канала): ``` 276 798 точек, 3432 минуты с данными, от 2026-07-30 00:04 до 2026-08-01 10:43 разрывов: 4 2026-07-31 21:23 → 21:54 нет 30 мин ← смена режима автоматизации 2026-08-01 07:51 → 08:19 нет 27 мин 2026-08-01 02:54 → 03:12 нет 17 мин 2026-08-01 09:03 → 09:18 нет 14 мин ``` Первый разрыв приходится ровно на паузу между доставками: последняя суммированная пришла в 18:27Z (21:27 местного), первая несуммированная — в 23:58Z. Пока автоматизацию перенастраивали, метка синхронизации ушла вперёд, и полчаса данных не выгрузились **никогда**. Это первая наблюдённая настоящая потеря, и она подтверждает необходимость широких проходов: средний проход (час, за сутки) закрыл бы её сам в течение часа. Их отсутствие и стало причиной — на момент сбоя был настроен только быстрый проход. **Но пробел в фазах сна (22:30–02:02) этим не объясняется.** В том же окне `basal_energy_burned` идёт непрерывно, ровно по 3600 точек в час: ``` 2026-07-31 22:00 3600 точек 2026-07-31 23:00 3600 точек 2026-08-01 00:00 3600 точек 2026-08-01 01:00 3600 точек ``` Канал работал, данные за это время доехали. Значит фазы сна за ранний период просто не появились в Health — вывод находки 26 остаётся в силе. ## 28. Расписание автоматизаций — пожелание, а не гарантия Из документации Health Auto Export ([Automations](https://help.healthyapps.dev/en/health-auto-export/automations/), [Shortcuts](https://help.healthyapps.dev/en/health-auto-export/automations/schedule-automations-using-shortcuts/)): - **Приложение можно не держать открытым**, но в фоне автоматизации зависят от Background App Refresh, и iOS решает сама: «iOS also does not allow apps to run in the background at a specified time… automations are not guaranteed to run precisely at the specified time». - **Пока приложение на переднем плане**, автоматизации перезапускаются примерно раз в 60 секунд — отсюда ровный пятиминутный ритм в наших доставках. - **Заблокированный телефон = экспорта нет вообще:** «Apps are not allowed to access health data while iPhone is locked». Ночью автоматизации не работают в принципе, и данные за ночь приезжают утром — что мы и наблюдали (эпизоды сна 02:02–03:33 доставлены в 09:18, находка 26). - На зарядке ограничения слабее. - Триггер через Shortcuts («Run Automation») предсказуемее фонового расписания, но **телефон всё равно должен быть разблокирован**. **Следствие для наблюдаемости:** ровного ритма доставок не бывает. Поток пачечный: тишина ночью, всплеск утром. Порог тревоги по молчанию должен быть не меньше суток, а осмысленный показатель свежести — возраст самой свежей точки, а не время последней доставки. **Следствие для стратегии:** нельзя строить сохранность на том, что доставка случится вовремя. Либо период выборки перекрывает любой разумный простой (широкие окна идемпотентны по построению), либо метка «Since Last Sync» обязана переживать неудачные попытки — а это **не проверено**. Что известно про непрерывность «Since Last Sync» из наших данных: окна последовательных доставок стыкуются без разрывов — ``` доставлено окно данных 2026-07-31T23:58:11Z 21:44:44 → 02:56:25 2026-08-01T00:09:01Z 22:04:00 → 03:04:20 стык ок 2026-08-01T05:09:07Z 22:04:00 → 08:05:19 стык ок 2026-08-01T06:18:17Z 22:04:00 → 09:12:56 стык ок ``` Но все эти доставки **успешные**. Поведение при неудачной отправке (сервис недоступен) не наблюдалось ни разу — см. «Открытые вопросы». ## 29. «Since Last Sync» теряет данные — подтверждено экспериментом Период основной автоматизации переключили на широкий (`Default`, окно 2026-07-30 21:48 → 2026-08-01 11:14, 42 МБ, 251 976 точек). Сравнение с тем, что за то же время отдал инкрементальный режим. **Окно, которое «Since Last Sync» уже покрывал** (2026-07-31 21:44 → 2026-08-01 11:09), сравнение по ключу «метрика + дата + источник»: ``` через Since Last Sync: 72 428 точек в широкой выгрузке: 85 692 ключей только у широкой: 13 961 ← потеряно инкрементальным режимом ключей только у SLS: 696 ``` **Фазы сна за спорную ночь:** ``` через Since Last Sync: 35 эпизодов, с 02:02, фазы: БДГ, Бодрствование, Основная в широкой выгрузке: 59 эпизодов, с 22:43, фазы: БДГ, Бодрствование, Глубокий, Основная ``` Пропавшие фазы **были в Health** и приехали, как только окно выборки перестало зависеть от метки синхронизации. Гипотеза владельца подтвердилась, вывод находки 26 отменён. После широкой выгрузки покрытие `basal_energy_burned` стало непрерывным по минутам за всё время наблюдения — **разрывов не осталось вовсе**, включая получасовую дыру находки 27. **Вывод, меняющий стратегию:** инкрементальный период нельзя использовать как единственный источник. Он годится для свежести, но сохранность обязаны обеспечивать широкие проходы с фиксированным окном. ## 30. Числа сериализуются нестабильно между выгрузками При сравнении тех же точек всплыло неожиданное: из 71 730 общих ключей **45 507 различались значением** — но вот как: ``` basal_energy_burned 22:24:51 0.09523182962471353 против 0.09523182962471352 active_energy 22:06:32 0.0074754192155406605 против 0.00747541921554066 ``` Расхождение в последнем разряде — шум представления double, а не разные данные. Хеш по канонической форме этого не переживает: **63% повторно доставленных точек считались бы новыми**, и каждый широкий проход дублировал бы витрину. Лечится округлением перед хешированием: ``` нормализация совпало разошлось без округления 26 223 45 507 %.16g 33 799 37 931 %.15g 61 347 10 383 %.14g 69 738 1 992 %.12g 70 184 1 546 ``` Ложных схлопываний округление не даёт: на 251 976 точках одной выгрузки `%.12g` не склеил ни одной пары различных точек. **Следствие для схемы:** хеш считается по канонической форме **с округлением чисел** (порядка 12–14 значащих цифр); значение при этом хранится дословно, как пришло. Округление — только для идентичности, не для данных. Оставшиеся ~1 500 расхождений (2%) — настоящие: **нарезка на секунды не детерминирована между выгрузками**, границы и доли слегка разъезжаются. Это значит, что широкие проходы всё равно будут добавлять небольшой процент дубликатов при идентичности по содержимому. Ключ по координатам (`метрика + date + source`) с перезаписью значений эту проблему снимает и заодно сохраняет эпизоды сна (они различаются `start`/`end`) — стоит вернуться к развилке находки 11 при реализации шага 3. ## 31. Вся мета-информация о выгрузке — в заголовках, и она полуправдива **В теле метаданных нет.** Верхний уровень всегда ровно один ключ `data`, внутри — только секции. Ни версии формата, ни окна выборки, ни настроек, ни времени экспорта. **В заголовках — пять полей**, из которых опираться в коде можно на два: | заголовок | пригодность | |---|---| | `automation-id` | **надёжен** — стабильный UUID автоматизации | | `session-id` | **надёжен** — уникален на доставку | | `automation-name` | пустой, пока имя не задано в приложении | | `automation-aggregation` | `Minutes` \| `Default` — см. ниже | | `automation-period` | `Today` \| `Default` \| `Since Last Sync` — см. ниже | Сопоставление заголовков с фактическим поведением по всем крупным доставкам: ``` aggregation period шаг меток окно данных Minutes Default минута надёжно Default Today секунда 2026-07-30 21:48 → 07-31 20:20 (двое суток!) Default Default секунда 2026-07-29 22:11 → 07-31 20:20 Default Default минута ← и так тоже бывает Default Since Last Sync секунда 2026-07-31 22:04 → 08-01 10:45 ``` - **`aggregation`**: значение `Minutes` действительно означает минутную группировку. Значение `Default` не означает ничего — под ним прошли посекундная сетка при включённом суммировании, минутная у второй автоматизации и несуммированный режим. Одно значение, три поведения. - **`period`**: называет настройку, но не описывает охват. `Today` дал окно шире суток, потому что в него попали записи сна, начавшиеся накануне вечером: окно определяется датами сэмплов, а не календарём. **Вывод:** режим и охват определяются **по самим данным** — шаг меток и min/max даты считаются за один проход при разборе. Заголовки годятся как подсказка человеку и как ключ источника, не более. Поэтому с этого момента **сохраняем весь набор заголовков** в `delivery.headers` (JSON, без секретов): что HAE шлёт помимо задокументированных пяти, мы не знали, а именно из незадокументированного вышли поле `source` (находка 1) и неразрывные пробелы (находка 24). ## 32. Полный набор заголовков: три полезных поля сверх документации После включения записи всех заголовков (15 доставок): ``` Accept */* Accept-Encoding gzip, deflate Accept-Language ru Automation-Aggregation Default Automation-Id BC99C8A3-8BE7-4519-B545-F3ED6212008E Automation-Name (пусто) Automation-Period Today Connection keep-alive Content-Length 12882621 Content-Type application/json Host 192.168.2.60:8080 Session-Id 77BEBA08-E372-42EC-B80F-101863AB4BB1 Upload-Complete ?1 Upload-Draft-Interop-Version 6 User-Agent Auto%20Export/20260729.1 CFNetwork/3860.700.1 Darwin/25.6.0 ``` Задокументированных полей пять, реально приходит пятнадцать. Три из незадокументированных имеют смысл для проекта. ### `User-Agent` несёт версию приложения `Auto%20Export/20260729.1` — датированный номер сборки, плюс версия системы (`Darwin/25.6.0`). Это закрывает вопрос, который висел с самого начала: формат данных может измениться только с обновлением Health Auto Export, и теперь **обновление видно в каждой доставке**. Значит выведенные схемы (шаг 5) можно сверять по версии: изменилась версия — стоит перепроверить формы точек. Версию стоит сохранять отдельной колонкой рядом с доставкой, а не только внутри `headers`, — по ней захочется группировать. ### `Accept-Language` объясняет локализацию `ru` — язык телефона приезжает в каждом запросе. Это делает решаемой ловушку находки 8: строки `value` («Во сне»), `context` («Не задано») и `name` тренировки («В помещении Ходьба») приходят на языке телефона, и теперь мы знаем, на каком именно. Язык можно хранить рядом с данными и помечать им локализованные поля в самоописании — вместо того чтобы клиенту гадать. ### `Upload-Complete` — CFNetwork умеет докачиваемую загрузку `Upload-Complete: ?1` и `Upload-Draft-Interop-Version: 6` — это черновик IETF Resumable Uploads, который реализует CFNetwork на стороне iOS. Пока во всех доставках `?1`, то есть тело приезжает целиком. **Риск на будущее:** тела уже достигают 42 МБ, а по мобильной сети клиент может захотеть слать их по частям. Частичная загрузка приедет с `Upload-Complete: ?0`, и наш сервис сочтёт обрезанное тело битым JSON и ответит 400. Пока мы не отвечаем `104 Upload Resumption Supported`, клиент переходить на докачку не должен — но это стоит помнить и **не включать поддержку случайно**. Если `?0` когда-нибудь придёт, правильнее ответить явной ошибкой, а не молча трактовать тело как испорченное. ## 33. Слой надо выводить как режим доставки, а не как частоту метрики Первая версия правила определяла слой **по каждой метрике отдельно**, по выравниванию её меток. На живых данных она сломалась, и сломалась именно на редких метриках. **Что пошло не так.** Частота метрик различается на порядки: `heart_rate` идёт сотнями точек в час, `vo2_max` и `six_minute_walking_test_distance` — по одной точке за всё время наблюдения. Для редкой метрики выравнивание не значит ничего: единственная несуммированная точка попадает на ровную минуту примерно в 1,7% случаев, а на ровный час — реже, но регулярно, потому что многие такие метрики Apple и записывает на границе часа. Результат классификации «по метрике» на наших данных: ``` apple_sleeping_wrist_temperature hour (12 точек), raw (2) ← одно измерение за ночь apple_stand_hour hour (284), raw (5) ← почасовая по природе walking_heart_rate_average hour, minute, raw ← одна точка в сутки vo2_max minute, raw six_minute_walking_test_distance minute, raw ``` Одна и та же редкая метрика растащена по трём слоям — при том, что режим выгрузки был один. Клиент увидел бы `vo2_max` в двух слоях по одной точке и не понял бы, что это одно и то же измерение. **Дополнительно выяснилось, что и «по доставке целиком» неверно** в лоб: одна доставка законно содержит метрики разной подробности — `apple_stand_hour` почасовой по природе, `sleep_analysis` в минутном режиме становится суточным агрегатом на `00:00:00`, а `heart_rate` рядом идёт секундами. Правило «самый мелкий слой побеждает» переворачивалось от одной метрики. ### Рабочее правило Слой — это **режим выгрузки**, общий для доставки; редкая метрика его наследует, а не голосует. 1. Голосуют только **плотные** метрики доставки — не меньше 10 точек. У десяти несуммированных точек шанс всем лечь на ровную минуту исчезающе мал. 2. Режим — самый мелкий слой среди проголосовавших. 3. Голосовать некому — режим **наследуется** от предыдущей доставки той же автоматизации; если её не было, берётся заголовок. 4. Все метрики доставки, включая редкие, кладутся в слой этого режима. ### Проверка на всей истории 39 доставок с метриками, три автоматизации, четыре смены настроек: ``` расхождений с надёжным заголовком (Minutes): 0 смены режима обнаружены: 3 (ровно там, где настройки меняли) наследование сработало: 2 (доставки без плотных метрик) редких метрик в доставке: 0–12, ни одна не повлияла ``` Правило воспроизводит фактические настройки автоматизаций без единой ошибки. ## 34. Родной экспорт Apple Health — другой источник, и он точнее HAE Сравнение выгрузки Health Auto Export с ручным экспортом из приложения Health за те же трое суток (`apple_health_export/экспорт.xml`, HealthKit Export Version 14). ### Объём: меньше в девяносто раз ``` Health Auto Export, несуммированный: ~135 000 точек в сутки Родной экспорт Apple: ~1 500 записей в сутки ``` За окно наблюдения (30 июля – 1 августа) в родном экспорте **4 629 записей** всех типов. У Health Auto Export за то же окно — сотни тысяч точек. ### Причина: HAE режет сэмплы по секундам У каждой записи Apple есть `startDate` и `endDate` — настоящий интервал измерения: ``` BasalEnergyBurned: 564 записи, интервал: медиана 10с, макс 21990с суммарно покрыто 74.9 ч → нарезка по секундам дала бы 269 693 точки ``` А у нас от HAE за это окно ровно столько и лежит — 3600 точек в час. Совпадение до цифры: **инфляция 478×** для базального обмена, около 90× по всему потоку. Это окончательно закрывает находки 5 и 20: «несуммированный» режим HAE — не сырые данные, а посекундная развёртка настоящих сэмплов. Причём развёртка **теряет информацию**: границы интервала (`startDate`/`endDate`) в неё не попадают. Отсюда неожиданный вывод: **самое точное представление данных одновременно и самое компактное**. Посекундный режим HAE — худший из вариантов: в 90 раз больше объёма, чем у правды, и меньше сведений. ### Что ещё есть в родном экспорте и нет у HAE - `startDate` / `endDate` / `creationDate` — интервал измерения и момент записи в Health отдельно; - `device` — полное описание устройства (модель, версия прошивки, серийный идентификатор объекта), а не только имя; - `sourceVersion` — версия приложения-источника; - фазы сна **английскими идентификаторами**: `HKCategoryValueSleepAnalysisAsleepCore`, `AsleepDeep`, `AsleepREM`, `Awake`. Локализацию («Основная», «Во сне») делает именно HAE — в источнике значения независимы от языка телефона. Это снимает ловушку находки 8 для импортированных данных; - 236 GPX-треков тренировок и 11 CSV с ЭКГ отдельными файлами. ### Перекрёстная проверка сна Спорная ночь в родном экспорте: ``` часы: с 22:43 до 07:57, 59 эпизодов фазы: AsleepCore 25, Awake 21, AsleepREM 10, AsleepDeep 3 первый: 22:43–23:02 AsleepCore, глубокий сон в 23:32 ``` Ровно то же, что привезла широкая выгрузка HAE (находка 29): 59 эпизодов, начало в 22:43. Два независимых источника сошлись — данные достоверны, а инкрементальный режим действительно их терял. ### Цена Формат совершенно другой: XML на **1,69 ГБ** (плюс дублирующий `export_cda.xml` на 1,1 ГБ), архив целиком 104 МБ. Имя файла **локализовано** — `экспорт.xml`, а не `export.xml`: захардкодить нельзя. Значит `healthlog import` из шага 6 — это не «те же JSON, только из файла», а отдельный парсер XML со своей моделью записи. Зато он даёт слой, которого HAE не отдаёт ни в каком режиме. ### Следствие для стратегии Напрашивается другая раскладка источников: | источник | что даёт | объём | как часто | |---|---|---|---| | HAE, минутная группировка | свежесть, непрерывный поток | 3,3 тыс. точек/сутки | каждые 5 минут | | Родной экспорт Apple | настоящие сэмплы с интервалами | 1,5 тыс. записей/сутки | вручную, раз в N недель | Посекундный режим HAE в этой раскладке не нужен вовсе: он дороже обоих и точнее ни одного. ## 35. Три разреза сходятся — и это проверка правила вывода слоя Появилась третья автоматизация (`8364E2C6`, заголовок `Hours`), метрики стали приходить в трёх разрезах одновременно. Сверка сумм между ними и с родным экспортом Apple как эталоном: ``` час sample (Apple) minute (HAE) hour (HAE) 2026-08-01 00 79.74 79.73 79.73 2026-08-01 01 81.24 81.24 81.24 2026-08-01 03 79.53 79.54 79.54 active_energy 09 22.47 22.47 22.47 ``` Три независимых представления совпадают до сотых. Минутный и часовой разрезы HAE достоверны, эталон подтверждает. ### По дороге нашлись две ошибки — обе в измерении, не в данных **Первая: наивный подсчёт эталона.** Я приписывал каждую запись Apple часу её начала — а базальный обмен приходит записями с интервалом до шести часов (находка 34). Суммы скакали от 54 до 580 ккал в час. Лечится раскладкой значения по часам пропорционально перекрытию интервала. **Вторая, важнее: правило вывода слоя мис-филировало транзитные доставки.** Правило находки 33 назначало слой доставке целиком по самой мелкой из плотных метрик. В доставке `494A0C76` от 08:55:57 `heart_rate` был ещё несуммированным, а остальные 29 метрик — уже минутными. Вся доставка ушла в слой `raw`, и минутные точки базального обмена сложились с посекундными: сумма ровно удвоилась. **Исправленное правило** (проверено — суммы сошлись): - метрика с ≥ 10 точками классифицируется **сама по себе**; - метрика с < 10 точками наследует **преобладающий слой доставки** (самый мелкий среди плотных). Так и смешанная доставка раскладывается верно, и редкая метрика не дробится по слоям — оба требования выполняются одновременно. ## 36. Поле `source` нестабильно — и это ломает идентичность по содержимому Самая дорогая находка проверки. Одно и то же измерение — та же метрика, та же минута, **то же значение** — приезжает с разными строками источника: ``` minute 2026-07-31 03:15, basal_energy_burned, qty=5.671724507333192 доставки 31 июля: source = "Apple Watch Ultra 3|iPad (Anton)" доставки 1 августа: source = "Apple Watch Ultra 3" ``` Значение совпадает до последнего разряда, метка та же, а `source` изменился: iPad перестал числиться среди вкладчиков. Судя по всему, Health переосмыслил атрибуцию источников задним числом. **Последствие:** идентичность по хешу содержимого сохраняет обе записи, и сумма за час удваивается. Это не редкий случай — на 31 июля минутный слой содержал **120 точек в час вместо 60**, то есть задвоено всё. ### Следствие для модели Ключ обязан состоять из **устойчивых координат**, а `source` к ним не относится: ``` ключ: метрика + слой + метка времени значения: qty / Min / Avg / Max / source / … ← перезаписываются ``` Это окончательно решает развилку находок 11 и 30 в пользу координат: хеш содержимого хорош тем, что ничего не теряет, но он не переживает ни нестабильной сериализации чисел (находка 30), ни нестабильной атрибуции источника (эта находка). Хеш при этом остаётся полезен — как быстрая проверка «изменилось ли что-нибудь», чтобы не писать зря. **Остаточный случай:** в посекундном слое HAE 65 меток из 3600 несут по два разных значения при одном источнике — там перезапись потеряет одно из двух. В минутном и часовом слоях такого нет вовсе: ровно одна точка на метку. Ещё один довод отказаться от посекундного слоя HAE в пользу `sample` из родного экспорта. ## 37. Категориальные значения переведены на язык телефона HAE отдаёт перечислимые значения не кодами, а строками из локали iOS. По всему потоку: ``` sleep_analysis.value Основная 692 Бодрствование 568 БДГ 206 Во сне 171 Глубокий 94 В кровати 38 heart_rate.context Не задано 3226 Сидячий образ жизни 2964 Активен 2565 workouts.name В помещении Ходьба 22 На улице Ходьба 13 ``` Что это перевод, а не собственный словарь HAE, видно по порядку слов: «В помещении Ходьба» — машинная калька с `Indoor Walk`. **Контрпример в том же пакете.** Секция `stateOfMind` устроена правильно и кодов не переводит: ``` kind momentary_emotion, daily_mood valenceClassification neutral, slightly_pleasant labels ["drained", "calm"], ["relieved", "content"] associations ["hobbies"], ["work"] ``` То есть HAE умеет отдавать стабильные коды HealthKit — просто для старых секций тянет строки из UI. Локаль известна: она приезжает в `Accept-Language` (находка 32). ### Следствие для модели Три удара, и третий — по уже принятому решению: 1. Клиент не может опереться на «БДГ» — ему пришлось бы угадывать словарь. 2. Смена языка телефона молча расколет историю: та же фаза сна станет другим значением, и по координатному ключу (находка 36) это выглядит как изменение данных, а не как переименование. 3. **Родной экспорт Apple говорит на другом языке.** В XML лежит `HKCategoryValueSleepAnalysisAsleepREM`, а не «БДГ». Экспорт объявлен источником истины (находка 34), и на нём же держится план помечать старые данные HAE устаревшими — но сверить покрытие по этим полям нечем. Решение: хранить дословно и **рядом** класть выведенный стабильный код по словарю `(локаль, строка) → код HealthKit`. Дословность не нарушена — код добавляется, а не подменяет строку. Для незнакомой строки код пустой, а не угаданный. ## 38. `sleep_analysis` — две несовместимые схемы под одним именем Под одним именем метрики приезжают два разных объекта. Поэпизодный (1769 точек): ```json { "date": "2026-07-30 21:48:00 +0300", "start": "2026-07-30 21:48:00 +0300", "end": "2026-07-30 22:21:00 +0300", "startDate": "2026-07-30 21:48:00 +0300", "endDate": "2026-07-30 22:21:00 +0300", "qty": 0.55, "value": "Во сне", "source": "AutoSleep" } ``` И суточная сводка (34 точки), где `date` — местная полночь: ```json { "date": "2026-07-30 00:00:00 +0300", "sleepStart": "2026-07-29 22:21:20 +0300", "sleepEnd": "2026-07-30 07:43:09 +0300", "inBedStart": "2026-07-29 22:21:20 +0300", "inBedEnd": "2026-07-30 07:43:09 +0300", "totalSleep": 7.430783703658317, "core": 5.448145056333808, "rem": 1.366182650923729, "deep": 0.6164559964007801, "awake": 1.9326458292537263, "asleep": 0, "inBed": 0, "source": "Apple Watch Ultra 3" } ``` Общих полей, кроме `date` и `source`, нет вовсе. `asleep` и `inBed` в сводке занулены — похоже, наследие старой модели сна, реальные числа в `core`/`rem`/`deep`/`awake`. Правило вывода слоя по выравниванию меток (находка 33) на сводке даст `hour`, потому что полночь выровнена по часу, — но это не часовой разрез, а суточный итог. Ещё и источники разные: эпизоды от `AutoSleep`, сводка от часов. **Следствие:** в каталоге это два разных имени с двумя схемами. Хранение остаётся дословным, разводятся только имена — иначе самоописание вынуждено отдавать две схемы под одним ключом, и клиент обязан гадать, какая пришла. ## 39. У HRV внутри точки — своя серия ударов и третий формат времени `heart_rate_variability` в нижнем слое несёт межударные интервалы: ```json { "date": "2026-07-31 00:16:36 +0300", "start": "2026-07-31 00:16:36 +0300", "end": "2026-07-31 00:17:35 +0300", "qty": 57.6404462528801, "source": "Apple Watch Ultra 3", "heartbeatSeries": [ {"timeSinceStart": 0.359375, "date": 1785446196.4132624, "precededByGap": true}, {"timeSinceStart": 1.5533214807510376, "date": 1785446197.6072087, "precededByGap": false} ] } ``` 261 точка с серией, 13 392 удара, длина серии 35 / 51 / 70 (мин / сред / макс). **Серия занимает 93% объёма метрики** — 1178 КБ из 1267 КБ. Внутри серии время — **Unix-эпоха дробным числом**. Это третий формат в потоке сверх двух известных: ``` локальное со смещением 34 359 data.metrics[].data[].end = 2026-07-31 08:00:00 +0300 число (эпоха) 2 940 …heartbeatSeries[].date = 1785446196.4132624 RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51Z ``` Перепись по 25 доставкам; иных форматов не встретилось. ## 40. Род агрегации из формы точки не выводится — но выводится из слоёв Проверялась гипотеза, которая избавила бы от ручной разметки: HAE при группировке сам показывает род метрики — накопительная сворачивается в `qty`, мгновенная в `Avg`/`Min`/`Max`. **Гипотеза опровергнута.** Перепись форм по всему потоку: `Avg`/`Min`/`Max` есть только у `heart_rate`. Заведомо мгновенные `walking_speed`, `respiratory_rate`, `blood_oxygen_saturation` приходят в `qty` ровно так же, как шаги. Единицы дают процентов девяносто — `count/min`, `%`, `ms`, `m/s`, `km/hr`, `degC`, `dBASPL` мгновенные, `kJ`, `min`, `km`, `count`, `hr` накопительные, — но ломаются на краях: `six_minute_walking_test_distance` в метрах это результат теста, два теста не складывают, а `walking_running_distance` в километрах — складывают. **Род выводится измерением, а не разметкой.** Одна метрика лежит в минутном и часовом разрезе одновременно; если часовое значение сходится с суммой минутных — накопительная, если со средним — мгновенная. Это та же проверка, что делает стенд сходимости (находка 35), только по всем метрикам и с записью результата в каталог. Где данных не хватает (`vo2_max` — восемь точек), род остаётся неизвестным, и агрегация по такой метрике не предлагается вовсе: отдаём значения как есть. ## 41. Чистить есть смысл только нижний слой 89 доставок, 16 МБ архива. Координат (`метрика + слой + метка`) по слоям: | слой | метрик | координат | в сутки | в год | |--------|-------:|----------:|---------:|------:| | raw | 30 | 433 397 | ~100 000 | ~36 млн | | minute | 29 | 9 253 | ~3 700 | ~1.4 млн | | hour | 30 | 398 | ~100 | ~36 тыс | Разница между слоями — три порядка. Удаление часового и минутного слоёв не экономит ничего, но ломает ответы на исторические запросы; всё давление по объёму создаёт нижний слой. Ровно там родной экспорт Apple и оказывается настоящим надмножеством (находка 34), так что ретеншен имеет смысл только для него. **Столкновения на координатном ключе:** из 443 048 координат 2 905 (0.66%) несут под одним ключом разные значения. Разбор выборки показывает, что почти всё это — не расхождение чисел, а разный набор полей: ``` ключ: apple_stand_hour, hour, 2026-07-31 07:00:00 +0300 {"date": "…07:00:00 +0300", "qty": 1, "start": "…07:00:00", "end": "…08:00:00"} {"date": "…07:00:00 +0300", "qty": 1} ``` По метрикам: `heart_rate` 983, `walking_running_distance` 560, `step_count` 507, `active_energy` 406, `basal_energy_burned` 381. При слиянии выигрывает более полная точка, а не последняя пришедшая, — иначе бедная доставка стирает `start`/`end` у богатой. ## 42. Формат экспорта за 5.5 лет: типы только добавляются Девять экспортов из `~/MediaEverything/HealthData/apple_health`, с декабря 2021 по август 2026. Версия формата растёт медленно и давно стоит на месте: ``` 2021-12 Export Version 11 2024-06 Export Version 13 2025-06 Export Version 14 … 14 ← пять экспортов подряд, больше года без изменений 2026-08 Export Version 14 ``` Раскладка архива одинакова во всех девяти: `экспорт.xml`, `export_cda.xml` (клинический формат, нам не нужен), `workout-routes/` с GPX, `electrocardiograms/` с CSV. Объём вырос вчетверо — 369 МБ XML и 944 887 записей в 2021 против 1610 МБ и 3 616 171 записи в 2026. **Главное для импорта: ни один тип не исчез.** Сверка четырёх экспортов (v11, v13, v14 первый, v14 последний) по всем типам записей не нашла ни одного случая пропажи — только появление новых: | появился | типы | |---|---| | к 2024 (v13) | `PhysicalEffort`, `TimeInDaylight`, `DistanceCycling`, `HeartRateRecoveryOneMinute`, `AudioExposureEvent`, `LowCardioFitnessEvent` | | к 2025 (v14) | `AppleSleepingWristTemperature`, `BodyTemperature`, `SexualActivity` | | к 2026 | `DietaryFiber`/`FatTotal`/`Protein`/`Carbohydrates`, `BloodPressureSystolic`/`Diastolic`, `HighHeartRateEvent` | Значит разбор экспорта можно писать «аддитивно»: незнакомый тип — это новый тип, а не сломанный парсер, и падать на нём нельзя. Появлялись и **структурные** элементы, а это уже опаснее: `WorkoutStatistics` внутри тренировки (с 2024) и `Correlation` (см. находку 44). ## 43. Коды HealthKit не вечны — Apple переписывает историю при экспорте Те же самые записи сна, экспортированные с разницей в пять лет, несут **разные коды**: ``` экспорт 2021-12 338 × HKCategoryValueSleepAnalysisAsleep экспорт 2026-08 338 × HKCategoryValueSleepAnalysisAsleepUnspecified 62 × HKCategoryValueSleepAnalysisInBed ← в обоих одинаково ``` Совпадение счётчиков до единицы означает, что это одни и те же исторические записи: `Asleep` переименован в `AsleepUnspecified`, и старые данные при экспорте переписываются новым именем. **Следствие:** код HealthKit устойчивее локализованной строки, но не абсолютен. Словарь категориальных значений обязан переживать переименование самих кодов — иначе после очередного обновления iOS история расколется вторично, теперь уже на «стабильной» стороне. ### Словарь фаз сна выводится из данных В свежих записях экспорта фазы полные, и они однозначно ложатся на локализованные строки HAE из находки 37: ``` Основная 692 → HKCategoryValueSleepAnalysisAsleepCore Бодрствование 568 → HKCategoryValueSleepAnalysisAwake БДГ 206 → HKCategoryValueSleepAnalysisAsleepREM Во сне 171 → HKCategoryValueSleepAnalysisAsleepUnspecified Глубокий 94 → HKCategoryValueSleepAnalysisAsleepDeep В кровати 38 → HKCategoryValueSleepAnalysisInBed ``` То есть первую и главную часть словаря не надо составлять вручную — она выводится сопоставлением потока с экспортом за тот же период. ## 44. `Correlation` — структурный элемент, и он появился только что Давление приезжает не записью, а обёрткой из двух записей: ```xml ``` Арифметика сходится: 9 элементов `Correlation` и по 18 записей систолического и диастолического давления — ровно две записи на обёртку. `Correlation` объявлен в DTD наравне с `Record` и `Workout`, но в данных до 2026 года не встречался ни разу. Парсер, написанный по одним лишь `Record`, давление разберёт как две несвязанные метрики и потеряет то, что делает его измерением — их **парность**. ## 45. DTD экспорта врёт, а имя файла локализовано Две мелочи, каждая из которых ломает разбор на ровном месте. **DTD расходится с данными.** В экспорте 2021 года (v11) `` объявляет четыре атрибута, а сам элемент `` несёт пять — лишний `HKCharacteristicTypeIdentifierCardioFitnessMedicationsUse`. Валидировать документ по его собственному DTD нельзя; разбираем то, что есть. **Имя файла переведено.** Внутри архива лежит `экспорт.xml`, а не `export.xml` — и так во всех девяти архивах начиная с 2021 года. Имя зависит от языка телефона (`locale="ru_RU"` в корневом элементе). Захардкоженное `export.xml` не найдёт ничего. Там же, в ``, значение локализовано: `CardioFitnessMedicationsUse="Нет"`. То есть правило «экспорт говорит кодами» верно для типов записей и категориальных значений, но не для всего документа. ## 46. `stateOfMind` в экспорт не попадает — единственная дыра в журнале Экспорт плюс доставки после его даты образуют полный журнал событий: состояние пересобирается свёрткой `import(снапшот) + replay(хвост)`. Проверка показала ровно одно исключение. В свежем экспорте **нет ни одного типа со словом `StateOfMind`**: ``` grep -oiE 'type="[^"]*(mind|mood|emotion)[^"]*"' → только type="HKCategoryTypeIdentifierMindfulSession" ← минуты осознанности, другое type="HKWorkoutEventTypeMotionPaused|Resumed" ← совпадение по подстроке ``` При этом HAE состояние разума шлёт исправно, и шлёт правильно — стабильными кодами HealthKit (`momentary_emotion`, `slightly_pleasant`, `drained`), в отличие от переведённых фаз сна (находка 37). **Следствие:** для `stateOfMind` доставки HAE — не хвост журнала, а единственный источник. Под общее правило ретеншена он не подпадает: удалив доставки, мы потеряем возможность восстановить его историю навсегда. ### Цена журнала Поток даёт **~23 МБ сырого архива в сутки** (89 доставок за 16.4 часа дали 15.6 МБ). При экспорте раз в 2–3 месяца это ~2 ГБ между снапшотами — дёшево за возможность пересобрать хранилище с любой точки. Прежние 14 дней ретеншена были произвольным числом; правильный срок — до следующего проверенного экспорта, иначе между концом ретеншена и датой снапшота в журнале образуется дыра. ## 47. Идентичность эпизода сна — `start` и `end`, и другой у Apple нет Координата `метрика + слой + метка` для поэпизодного `sleep_analysis` неверна: под одной меткой лежит до трёх разных эпизодов. Замер по всем 94 доставкам (1880 эпизодных точек, суточные сводки исключены): | Ключ | Координат | Схлопнуто точек | Дублей **внутри одной** доставки | |---|---|---|---| | `date` | 170 | 1710 | 33 | | `date + start + end` | 174 | 1706 | **0** | | `date + start + end + value` | 174 | 1706 | 0 | `start`/`end` закрывают всё; `value` в ключе не добавляет ни одной координаты. Сильнее: из 174 координат **ни одна не несёт двух разных содержимых** — ни с `source`, ни без него. Правило разрешения столкновений на эпизодном сне за весь корпус не срабатывает ни разу. Внутридоставочные дубли важнее междоставочных: там `received_at` общий, и любой тай-брейк по времени приёма неприменим в принципе — исход решал бы порядок элементов в JSON-массиве, а он нестабилен (находка 2). **Это не дефект данных, а задуманное представление.** Записи с одним `startDate` и разными `endDate` — вложенность «в кровати» и фазы сна внутри неё; HealthKit намеренно допускает перекрывающиеся сэмплы, чтобы выразить «в кровати» и «спит» одновременно. **Другой идентичности Apple не даёт.** Атрибуты записи сна в экспорте: ``` type sourceName sourceVersion creationDate startDate endDate value ``` `HKObject.uuid` существует в API, но **в выгрузку не попадает**. Значит модель идентичности обязана выражаться через `start`/`end`: иначе `import(экспорт)` не сойдётся с `replay(HAE)` и журнал перестанет быть журналом. ### Уточнение: ключ один, и это интервал Перепись по всем 22 метрикам, несущим `start`/`end`, поправила формулировку: - **`start` всегда равен `date`** — ноль исключений на всём корпусе. Признак «`start` и `end`, отличные от `date`» неработоспособен: отличается только `end`. - **Интервалы несёт не один сон, а 22 метрики** (`heart_rate`, `physical_effort`, `walking_speed`, `apple_stand_hour`, …). - **Обе формы точки никогда не смешиваются** внутри одной метрики в одной доставке: интервальность — свойство режима выгрузки, а не отдельной точки. Значит ключ с интервалом не разорвёт надвое точку, которая приехала то с `end`, то без. - **Разные интервалы под одной меткой** встречаются только у `sleep_analysis` (3 метки) и `resting_heart_rate` (2 метки), и во **всех** случаях содержимое точек различается. То есть это разные данные, а не поправленный задним числом интервал: ключ с интервалом ничего не задваивает. Отсюда ключ не двух форм, а одной: ``` координата = метрика + слой + начало + конец у точки-измерения конец = начало ``` Понятия «эпизодная схема» не требуется вовсе — выводить нечего, ветвления в коде нет, и правило разрешения столкновений по полноте продолжает работать ровно там, где работало. ### Как это решают другие - **Health CSV Importer** дедуплицирует по `Start Date + End Date + Data Type + Type Identifier`. Совпадает с нашим замером, включая отсутствие источника в ключе. - **Ингесторы поверх InfluxDB** (`irvinlim/apple-health-ingester`, `joeecarter/health-import-server`) ключуют по `measurement + tags + timestamp` — то есть имеют ровно эту коллизию и разрешают её молчаливым last-write-wins движка. Один обходит её тем, что кладёт сон плоской точкой (`inBed`/`inBedStart`/`inBedEnd`), то есть поддерживает только суточную схему. - **Пайплайн HAE → FastAPI → Postgres** (ladvien) даёт каждой записи суррогатный UUID-PK без уникального ограничения: обещанная идемпотентность не реализована, повторная доставка задваивает строки. То есть обе распространённые схемы хранения теряют данные ровно там, где мы это измерили, а единственная принятая схема дедупликации — это `start`+`end`. ## 48. Единицы метрики на живом потоке не менялись ни разу Замер по всем 99 доставкам архива: **30 различных метрик, ни у одной единицы не менялись**. Ни между доставками, ни внутри одной. Это снимает основание под гипотезой «единицы — часть координаты объекта»: разряд `метрика + слой + единицы + час` защищал бы от события, которого поток не производит. Сегодняшнее поведение (сохранённые единицы побеждают, расхождение — `MergeStats.UnitsConflicts` и `WARN`) правильно ровно тем, что превращает гипотезу в **наблюдаемое** событие: если единицы когда-нибудь поедут, это будет видно в логе в тот же день, а не через квартал при сверке с экспортом. Оговорка, которая остаётся верной и с этим решением: единицы не входят в хеш содержимого, поэтому доставка с теми же точками и другими единицами уходит по ветке «ничего не изменилось». При правиле «сохранённое побеждает» это не расхождение, а то же самое правило, — но если правило когда-нибудь поменяют, хеш придётся менять вместе с ним. ## 49. Столкновений 0.65%, несравнимых наборов полей нет, тай-брейк системно берёт меньшее Замер по всем 99 доставкам, ключ — реальный (`метрика + слой + начало + конец`), сравнение — по канонической форме с округлением до 12 значащих цифр (находка 30). | что мерялось | столкновений | из 444 256 | |---|---|---| | без слоя в ключе | 53 678 | 12.29% | | со слоем, побайтово | 47 235 | 10.63% | | **со слоем, после канонизации** | **2 897** | **0.65%** | Первая строка меряет не то: без слоя часовая точка сталкивается с минутной, и это не столкновение, а два разных ряда. Разница между второй и третьей — дребезг последних разрядов `float64`, то есть **94% побайтовых расхождений канонизация схлопывает**. Это же и есть независимое подтверждение находки 30: округление до 12 цифр выбрано верно. Разложение настоящих 2 897: | случай | сколько | что делает правило | |---|---|---| | наборы полей **сравнимы** (одно надмножество другого) | 981 | правило полноты работает верно | | наборы полей **равны**, значения разные | 1 916 | срабатывает тай-брейк | | наборы полей **несравнимы** | **0** | не встречается вовсе | **Ноль несравнимых наборов** — важный результат: объединение полей при несравнимых наборах, самая дорогая часть обсуждавшегося правила слияния, на живом потоке не срабатывает ни разу. Правило полноты сводится к «надмножество побеждает», и это не упрощение из лени, а измеренная форма данных. А вот тай-брейк при равной полноте измеримо смещён. Там, где сравнение чисел определено (1 912 случаев из 1 916), лексикографический порядок канонических форм выбирает **меньшее значение в 1 847 случаях — 96%**: ``` heart_rate 985 walking_running_distance 562 step_count 509 active_energy 409 basal_energy_burned 385 apple_stand_time 14 ``` Четыре метрики из шести — накопительные, которые HAE досчитывает задним числом (находка 10): там «меньшее» это систематический недосчёт, порядка 0.4% координат. Но самая крупная группа, `heart_rate`, — мгновенная, и там «большее» не правильнее, там просто пересэмплирование. Отсюда вывод, которого не было до замера: **правильный тай-брейк зависит от рода метрики**, а род по замыслу проекта измеряется сверкой слоёв между собой. Значит выбирать тай-брейк до каталога рода агрегации — значит угадывать ровно то, что через задачу станет известно точно. ### Перемер под расширенным определением пустоты Разложение выше считало пустыми `null` и пустую строку. Правило слияния с тех пор считает пустыми ещё нулевое число, пустой объект и пустой массив — то есть множества ключей стали меньше, а несравнимость от этого может только появиться, но не исчезнуть. Перемер на тех же 99 доставках: **несравнимых по-прежнему ноль**, а состояние витрины совпало с прежним побайтово (отпечаток содержимого 1737 объектов). То есть смена правила — строгий no-op на живых данных, и вся её работа относится к будущему. Отдельно проверено про булевы поля: единственное на весь архив — `isIndoor` у тренировок, и `false` там встречается наравне с `true`. Поэтому `false` пустотой не считается: это одно из двух значений, а не отсутствие сведений. Ноль же пустотой считается, хотя бывает и настоящим измерением (у `walking_asymmetry_percentage` нулевое значение — обычный результат): цена названа вслух и ограничена столкновением, где одно из двух содержимых на одних координатах заведомо неверно. ## 50. Половина потока — не `metrics`, и секции не смешиваются Замер по всем 99 доставкам архива: набор верхнеуровневых ключей `data`. | набор ключей `data` | доставок | |---|---| | `metrics` | 51 | | `workouts` | 24 | | `stateOfMind` | 24 | Три наблюдения, каждое из которых влияло на решение: 1. **48 доставок из 99 сейчас числятся разобранными, не будучи разобранными.** Разбор читает только `metrics`; доставка из одних тренировок получала `parse_status=parsed` с нулём точек — неотличимо от доставки с пустой секцией метрик. Ретеншен, ориентируясь на статус, срезал бы тела, а для `stateOfMind` это необратимо (находка 46). 2. **Ни одна доставка не несла двух секций сразу.** Автоматизация HAE шлёт одну секцию за раз. Полагаться на это в правилах удаления данных, впрочем, нельзя: наблюдение собрано за двое суток потока. 3. **Пустых секций не бывает** — все 99 значений непусты. Это снимает соблазн «пустую секцию не считать непокрытой»: он бы снял шум, если бы HAE слал `"workouts": []` в каждой доставке, а он не слал ни разу. Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус отвечает на вопрос «разобрано ли всё», список — «что именно осталось». ## Инструмент Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная библиотека, каталог под `.gitignore`): ``` python3 tmp/research/hl.py deliveries что приехало python3 tmp/research/hl.py metrics --period 'Since Last Sync' python3 tmp/research/hl.py shapes формы точки python3 tmp/research/hl.py sources источники, с показом невидимых символов python3 tmp/research/hl.py points step_count точки, инфляция серий python3 tmp/research/hl.py sleep разбор ночи python3 tmp/research/hl.py diff что изменилось между доставками python3 tmp/research/hl.py workouts тренировки, ряды, маршрут ``` Он канонизирует JSON перед сравнением и показывает невидимые символы — те две грабли, на которых разбор оболочкой ломался молча. ## Открытые вопросы - **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для стратегии (находка 28). Проверяется экспериментом: остановить сервис на полчаса при открытом приложении, поднять и посмотреть, приедет ли пропущенное окно. Если метка двигается независимо от исхода — на инкрементальный период полагаться нельзя вообще. - **Дальность досчёта.** Наблюдались правки хвоста возрастом до 22 минут. Меняется ли что-то на глубине часов и суток — покажет более длинный ряд доставок. - **Секции, которых мы не видели живьём:** `symptoms`, `ecg`, `heartRateNotifications`, `cycleTracking`, `medications`. - **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен (находка 41) к ним неприменим — их придётся хранить вечно. - **Полнота словаря переводов (находка 37).** Известны значения только русской локали и только для трёх полей. Не проверено, переводятся ли `symptoms` и `cycleTracking`, и совпадут ли строки после обновления iOS. - **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV (находка 39) ради данных, которых, вероятно, нет ни в одном из планируемых запросов. Решать после того, как станет ясна цена хранения нижнего слоя за год.