Files
healthlog/docs/research/apple-health.md
T
av bd5d17b079 первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
2026-08-04 13:39:48 +03:00

126 KiB
Raw Blame History

Apple Health: наблюдения на живых данных

Наблюдения за реальным потоком Health Auto Export и за родным экспортом Apple Health. Документация формата HAE (wiki) тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому источником истины служит этот файл, а не она.

Файл пополняется по мере накопления доставок. Находки нумерованы сквозным номером, и номер — это ссылка: на «находку 49» ссылаются спеки, предложения и задачи, поэтому нумерация не пересчитывается и записи не переставляются. Как снималось и каким инструментом — в README.md.

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 отличаются только порядком ключей:

{"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. Строковые значения локализованы

"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. Для самоописания шаг метрики нужно выводить из данных — как и всё остальное.

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 живёт по другим соглашениям

{
  "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.

Каталог метрик растёт по факту поступления данных. Это подтверждает выбор обобщённой модели хранения и выводимых схем: фиксированный список метрик в коде устарел бы в тот же день.

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 точки маршрута — одна в секунду. Точка несёт десять полей, а не пять, как обещала документация:

{"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)".

Следствие: в самоописании значения-примеры нужно отдавать так, чтобы невидимые символы были заметны, а в 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, 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 при реализации разбора.

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, и теперь обновление видно в каждой доставке. Значит выведенные схемы можно сверять по версии: изменилась версия — стоит перепроверить формы точек.

Версию стоит сохранять отдельной колонкой рядом с доставкой, а не только внутри 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:4323:02 AsleepCore, глубокий сон в 23:32

Ровно то же, что привезла широкая выгрузка HAE (находка 29): 59 эпизодов, начало в 22:43. Два независимых источника сошлись — данные достоверны, а инкрементальный режим действительно их терял.

Цена

Формат совершенно другой: XML на 1,69 ГБ (плюс дублирующий export_cda.xml на 1,1 ГБ), архив целиком 104 МБ. Имя файла локализованоэкспорт.xml, а не export.xml: захардкодить нельзя.

Значит healthlog import — это не «те же 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 точек):

{
  "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 — местная полночь:

{
  "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 в нижнем слое несёт межударные интервалы:

{
  "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

То есть первую и главную часть словаря не надо составлять вручную — она выводится сопоставлением потока с экспортом за тот же период.

Замер покрытия, 2026-08-03. Прогон всего живого архива (145 доставок) через разбор с этим словарём даёт 12 различных категориальных строк по трём полям: 6 фаз сна — все с кодом, 6 без кода (heart_rate.context и имена тренировок, для которых словарь не выводился). То есть шесть выведенных строк покрывают поток целиком, а не частично: неопознанных фаз сна на корпусе ноль. Заголовков в архиве нет, поэтому прогон идёт с пустой локалью — и коды всё равно выводятся, что подтверждает: сопоставление по строке однозначно, пока словарь одноязычен.

44. Correlation — структурный элемент, и он появился только что

Давление приезжает не записью, а обёрткой из двух записей:

<Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="PICOOC"
             startDate="2026-01-01 09:41:49 +0300" endDate="2026-01-01 09:41:49 +0300">
  <Record type="HKQuantityTypeIdentifierBloodPressureSystolic"   />
  <Record type="HKQuantityTypeIdentifierBloodPressureDiastolic"  />
</Correlation>

Арифметика сходится: 9 элементов Correlation и по 18 записей систолического и диастолического давления — ровно две записи на обёртку.

Correlation объявлен в DTD наравне с Record и Workout, но в данных до 2026 года не встречался ни разу. Парсер, написанный по одним лишь Record, давление разберёт как две несвязанные метрики и потеряет то, что делает его измерением — их парность.

45. DTD экспорта врёт, а имя файла локализовано

Две мелочи, каждая из которых ломает разбор на ровном месте.

DTD расходится с данными. В экспорте 2021 года (v11) <!ATTLIST Me> объявляет четыре атрибута, а сам элемент <Me> несёт пять — лишний HKCharacteristicTypeIdentifierCardioFitnessMedicationsUse. Валидировать документ по его собственному DTD нельзя; разбираем то, что есть.

Имя файла переведено. Внутри архива лежит экспорт.xml, а не export.xml — и так во всех девяти архивах начиная с 2021 года. Имя зависит от языка телефона (locale="ru_RU" в корневом элементе). Захардкоженное export.xml не найдёт ничего.

Там же, в <Me>, значение локализовано: 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: статус отвечает на вопрос «разобрано ли всё», список — «что именно осталось».

51. Тренировка досчитывается задним числом, но поля у неё только прибывают

Замер по всем 118 доставкам архива, группировка элементов секций по id:

сущность копий различных содержимых набор полей рос набор полей убывал
тренировка A 26 3 да нет
тренировка B 18 1
stateOfMind #1 26 1
stateOfMind #2 26 1

Что менялось у тренировки A между версиями:

версия 0 → 1   +stepCadence, +stepCount, изменилось значение ряда activeEnergy
версия 1 → 2   набор полей тот же, изменились totalEnergy и basalEnergy

Два вывода, и оба вошли в правило замены версии.

Тренировка правится задним числом ровно так же, как минутное ведро (находка 10): при неизменном наборе полей значения досчитываются. Значит правило «при равной полноте побеждает тот, чья каноническая форма меньше» — то, что действует для точек, — заморозило бы тренировку на произвольной версии навсегда, вместе с недосчитанной энергией.

Набор полей за весь корпус ни разу не уменьшился. Обеднённая версия — событие, которого поток не производит; но маршрут это 95% веса тренировки (находка 22), а восстановление требует пересборки всего журнала. Поэтому удержание сохранённой версии стоит одного сравнения множеств, а событие делается наблюдаемым — счётчиком и WARN, — вместо необратимого.

Правило полноты, написанное для точек, здесь неприменимо. Оно требует, чтобы значения общих содержательных ключей совпали, иначе отношение включения гасится до «равенства». У точки это верно (надмножество имён при других значениях означает другое измерение), у сущности — нет: значения между версиями расходятся всегда. Проверено на копии пакета canon:

сохранённая с маршрутом vs обеднённая, значения общих полей те же : superset
сохранённая с маршрутом vs обеднённая, значения общих полей иные  : equal
сохранённая vs версия с усечённым маршрутом (2 точки → 1)         : equal

Отсюда же второй разряд правила: усечённый ряд ключа не теряет, поэтому сравнивается ещё и длина верхнеуровневых массивов.

52. Половина потока — не metrics: перемер на 118 доставках

Пересчёт находки 50 на выросшем корпусе. Набор верхнеуровневых ключей data:

набор ключей data доставок
metrics 65
workouts 27
stateOfMind 26

Пропорция та же, что была на 99 доставках (51/24/24), и наблюдение «ни одна доставка не несла двух секций сразу» держится: автоматизация HAE шлёт одну секцию за раз. Полагаться на это в правилах удаления данных по-прежнему нельзя — за двое суток наблюдения смешанная доставка просто не успела бы случиться.

С покрытием workouts и stateOfMind разбором эти 53 доставки перестали быть partial. Прогон живого архива после изменения: 118 тел, свёрнуто 118, отказов ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и 2 записи; повторное проигрывание дало тот же отпечаток.

53. Род агрегации измерен: 16 метрик из 31, противоречий ноль

Правило из находки 40 доведено до кода и прогнано на всём архиве (123 доставки, 31 метрика, витрина 2342 объекта). Сверка идёт по парам «минутный объект — часовой объект за тот же час»; час участвует, только если у часового объекта ровно одна точка со значением на границе часа, у минутного не меньше двух точек, а сумма минутных отличима от их среднего.

исход метрик
cumulative 7
instant 9
unknown 15
cumulative  active_energy, basal_energy_burned, step_count,
            walking_running_distance, apple_stand_time, apple_exercise_time,
            time_in_daylight
instant     heart_rate, respiratory_rate, blood_oxygen_saturation,
            environmental_audio_exposure, walking_speed, walking_step_length,
            walking_double_support_percentage, walking_asymmetry_percentage,
            stair_speed_up

Противоречащих часов ноль на всём корпусе — ни у одной метрики свидетельства не разошлись. Это и есть главный результат: правило не «чаще всего работает», а не дало ни одного контрпримера.

Что выяснилось по дороге

Нулевой час обязан отбрасываться, иначе правило конфликтует само с собой. Первый прогон дал у walking_asymmetry_percentage 4 часа «накопительная» против 3 «мгновенная». Разбор: в часе, где все значения нули, сумма равна среднему, и проверка «сходится с суммой» выполняется тождественно. Условие «сумма отличима от среднего» убирает весь конфликт.

Часовой слой HAE считается арифметически, а не по Apple. HealthKit относит environmental_audio_exposure к логарифмическому усреднению по энергии, а пульс — к среднему, взвешенному по длительности. На наших данных часовое значение аудиоэкспозиции сходится с обычным арифметическим средним минутных в 59 часах из 62, а у пульса — точно в 29 часах из 63 и с точностью 0.1% в 49. Значит четыре стиля агрегации HealthKit в потоке ничем не различимы, и родов ровно два.

Допуск сравнения на вердикты не влияет, а на счётчики влияет вдвое. Прогон сеткой: при относительном допуске от 1e-9 до 1e-3 роды всех метрик одинаковы; число согласных часов у heart_rate при этом меняется с 29 на 49, у step_count — с 25 на 35. Взят строгий 1e-9: канонизация округляет числа до 12 значащих цифр, то есть всё крупнее 1e-12 представлением не объясняется.

Окно в 48 часов обходится дешевле, чем кажется, но редкие метрики уводит в unknown. Полный обход всех 696 пар часов занимал 123 мс, окно даёт 68 мс и перестаёт расти вместе с журналом. Плата: у physical_effort за всю историю было 5 согласных часов, а в последних 48 — только 2, и метрика уходит в unknown. Это честный исход: свидетельств в свежем окне действительно мало.

Неполные часы видны в основании и ничего не ломают. У step_count из 48 часов окна пригодны 41, а вердикт дали 24 — остальные не сошлись ни с суммой, ни со средним, потому что минутный слой за них неполон. Отдельного порога заполненности (xFilesFactor) измерению не нужно: две конкурирующие гипотезы отсеивают неполный час сами.

54. Перемер тай-брейка: 98,8% спорных координат решает не полнота, а порядок форм

Замер 2026-08-04, повод — task verify:archive покраснел на master без единого коммита, с ростом корпуса. Метод назван целиком, потому что прежняя оценка (находка 49) и эта расходятся в 29 раз, и расхождение объясняется методом, а не данными.

Метод. 155 тел архива, разбор настоящий (hae.Parse с наследованием слоя по цепочке), ключ координаты настоящийметрика + слой + начало + конец. Кандидаты схлопываются по канонической форме (canon.SortKey, округление до 12 значащих цифр, находка 30); полнота — canon.Fields.Relate, то есть с условием «значения общих содержательных ключей совпали». Программа лежала в tmp/ (вне репозитория: она ходит в рабочий архив).

Прежний замер того же дня давал «84 978 спорных из 453 171» — он считал ключ без слоя, а без слоя часовая точка сталкивается с минутной, и это не столкновение, а два разных ряда (та же ошибка названа в находке 49 первой строкой её таблицы).

что мерялось сколько
координат всего 460 995
спорных (больше одной канонической формы) 80 129 (17,4%)
из них полнота кого-то отбрасывает 981 (1,2%)
из них все кандидаты непревзойдённые — решает тай-брейк 79 148 (98,8%)
несравнимых пар среди непревзойдённых 2
координат, где смена тай-брейка меняет исход 75 494

Соотношение 1,2% / 98,8% устойчиво — оно совпало у обоих методов, и именно оно, а не абсолютное число, было основанием решения: инвариант «выигрывает более полная точка» на живом потоке отвечает в одном случае из восьмидесяти.

Изменение сосредоточено в одной метрике одного слоя. Из 75 494 изменившихся координат 71 773 (95%) — basal_energy_burned слоя raw, то есть посекундная развёртка HAE, которую Read API суммировать и так не имеет права. Следом basal_energy_burned/minute (1 833), walking_running_distance/raw (777), step_count/raw (746). Ошибка «системно храним меньшее» была массовой по координатам и узкой по метрикам.

Несравнимых наборов больше не ноль. Находка 49 фиксировала 0 из 2 897; на 155 доставках их 2. Порог «объединять поля не будем, пока счётчик молчит» поэтому подтверждается, но уже не абсолютен: событие наступило, просто редко.

Направление, в котором новое правило теряет содержание, замерено отдельно. Разряд полноты гаснет, когда значения общих содержательных ключей разошлись, — и тогда пришедшая точка побеждает, даже если у проигравшей был содержательный ключ, которого у неё нет. Таких координат на корпусе 2, обе sleep_analysis_summary/day, и обе — ровно те же, что дают несравнимые наборы. То есть случай «сохранённая беднее по именам, но значения разошлись» на живом потоке не наблюдался вовсе. Прежний байтовый порядок давал ту же потерю по жребию и так же молча; теперь она детерминирована и считается (MergeStats.PointsErased, WARN).

Исход починки, тем же прогоном. Смена тай-брейка на «побеждает пришедшая» вернула род двум метрикам: step_count (unknowncumulative, ноль противоречащих часов вместо одного) и headphone_audio_exposure (unknowninstant). Итог каталога: накопительных 6 → 7, мгновенных 9 → 10, неизвестных 16 → 14. Отпечаток витрины сменился, как и требовалось: 3 194 объекта, bf36b477…03aace91…. Удержаний правилом полноты на весь корпус — 1 247, потерь содержания — 2.

Проверено ещё раз на выросшем корпусе. Пока шла работа, телефон прислал ещё три доставки; прогон на 158 телах остался зелёным (3 255 объектов, отпечаток c4fbb1c7…, ноль противоречащих часов, step_count по-прежнему cumulative). Это и есть ответ на то, чем дефект был найден: прежнее правило покраснело именно от роста корпуса, новое рост пережило.

Открытые вопросы

  • Переживает ли «Since Last Sync» неудачную отправку. Ключевой вопрос для стратегии (находка 28). Проверяется экспериментом: остановить сервис на полчаса при открытом приложении, поднять и посмотреть, приедет ли пропущенное окно. Если метка двигается независимо от исхода — на инкрементальный период полагаться нельзя вообще.
  • Дальность досчёта. Наблюдались правки хвоста возрастом до 22 минут. Меняется ли что-то на глубине часов и суток — покажет более длинный ряд доставок.
  • Секции, которых мы не видели живьём: symptoms, ecg, heartRateNotifications, cycleTracking, medications. Разбор покрывает ровно остальные три (metrics, workouts, stateOfMinddecodeCovered в internal/hae), сверено поимённо 2026-08-04. Момент их появления больше не требует догадки: первая встреча имени даёт WARN в логе свёртки, а перечень накопленного отдаёт healthlog uncovered. Разбор самой секции пишется, когда её будет на чём проверить, — вслепую он не пишется.
  • Что из этих секций вообще есть в родном экспорте. ЭКГ выгружается отдельными CSV, а не в XML. Если stateOfMind, симптомы или лекарства в экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен (находка 41) к ним неприменим — их придётся хранить вечно.
  • Полнота словаря переводов (находка 37). Известны значения только русской локали и только для трёх полей. Не проверено, переводятся ли symptoms и cycleTracking, и совпадут ли строки после обновления iOS.
  • Хранить ли heartbeatSeries целиком. 93% объёма HRV (находка 39) ради данных, которых, вероятно, нет ни в одном из планируемых запросов. Решать после того, как станет ясна цена хранения нижнего слоя за год.