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

Наблюдения за реальным потоком Health Auto Export и за родным экспортом Apple
Health. Документация формата HAE
([wiki](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format))
тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому
источником истины служит этот файл, а не она.

Файл пополняется по мере накопления доставок. Находки нумерованы сквозным
номером, и **номер — это ссылка**: на «находку 49» ссылаются спеки,
предложения и задачи, поэтому нумерация не пересчитывается и записи не
переставляются. Как снималось и каким инструментом — в
[README.md](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 отличаются только порядком ключей**:

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

## 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`.

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

## 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)"`.

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

## 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: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` — это не «те же 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
```

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

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

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

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

```xml
<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`) измерению не нужно: две конкурирующие гипотезы
отсеивают неполный час сами.

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

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