# parsing Specification

## Purpose

Разбор тела Health Auto Export: какие секции покрыты, как читается точка и её
метка времени, как выводится слой доставки и что происходит с непонятым.
Источник истины о формате — не документация HAE, а живые пакеты
(`docs/research/apple-health.md`).

## Requirements
### Requirement: Разбор секции метрик

Система SHALL разбирать секцию `data.metrics` тела доставки Health Auto Export
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
том виде, в каком его прислал HAE.

Хранимая форма точки — **исходные байты**, как они пришли в теле доставки.
Система MUST NOT пересобирать содержимое повторной сериализацией разобранных
значений: обход через `map[string]any` теряет литерал (`1.0` становится `1`,
целые больше 2^53 сдвигаются, невалидный UTF-8 заменяется на U+FFFD), и потеря
не видна тестам на фикстурах — они сравнивают разобранное с разобранным.

Отсюда же следует, что «служебных» полей у точки нет: отбрасывать нечего,
нормализованное время добавляется рядом с исходным содержимым, а не вместо.

#### Scenario: Метрика с точками разбирается в точки

- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
- **THEN** каждая точка с непустым `date` становится точкой хранилища
- **AND** её содержимое сохраняется исходными байтами, без пересборки

#### Scenario: Незнакомая метрика не ломает разбор

- **WHEN** приходит метрика с именем, которого разбор не знает
- **THEN** её точки разбираются наравне с остальными
- **AND** разбор не завершается ошибкой

#### Scenario: Точка без метки времени пропускается

- **WHEN** точка не содержит `date` либо `date` не разбирается ни одним из
  поддерживаемых форматов
- **THEN** точка не попадает в хранилище
- **AND** факт учитывается в итоге разбора доставки

#### Scenario: Точка с нечитаемым концом интервала пропускается

- **WHEN** точка несёт `end`, который не разбирается
- **THEN** точка не попадает в хранилище
- **AND** факт учитывается отдельным счётчиком

Вырождать такую точку в мгновенную нельзя: две записи с общим началом получили
бы одну координату, и одна исчезла бы молча. Тело остаётся в архиве.

### Requirement: Вывод слоя гранулярности

Система SHALL выводить слой точки из **выравнивания меток времени**, а не из
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
`Default` соответствует трём разным режимам выгрузки.

Выводимые слои: `raw` (метка на произвольной секунде), `minute` (секунды
нулевые), `hour` (секунды и минуты нулевые). Слой `day` в перечисление входит,
но **не выводится** — он назначается схемам с фиксированной гранулярностью
(см. «Разделение схем под одним именем метрики»).

Выравнивание SHALL считаться по метке в **исходной зоне**, а не по метке в UTC.
HAE строит сетку по местному времени; ровный местный час в зоне со смещением на
половину (`+0530`, `+0545`, `+0930`) даёт UTC-метку на середине часа, и часовая
выгрузка целиком уехала бы в слой `minute` — где столкнулась бы с настоящей
минутной автоматизацией и завысила сумму минутного слоя вдвое.

Слоем метрики SHALL становиться **самое мелкое** выравнивание среди её меток, а
не преобладающее. Измерено: у плотных метрик выравнивания перемешаны
(`active_energy` — 1320 минутных меток и 21 часовая, `heart_rate` — 654
посекундных и 10 минутных), потому что метка ровно на часе одновременно
является и минутной. Метрика, у которой хоть одна метка стоит на середине часа,
часовой не является.

Разделение схем под одним именем выполняется **до** вывода слоя, и точки с
назначенным слоем в определении преобладающего слоя доставки не участвуют:
суточных сводок сна бывает больше порога плотности, и их полуночные метки
иначе назначили бы всей доставке слой `hour`.

Классификация MUST быть **по метрике внутри доставки**, а не по доставке
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
отнесение такой доставки к одному слою складывает минутные точки с
посекундными.

#### Scenario: Плотная метрика классифицируется сама

- **WHEN** в доставке у метрики не меньше десяти точек с метками
- **THEN** слой определяется выравниванием её собственных меток

#### Scenario: Зона с получасовым смещением не делает часовую выгрузку минутной

- **WHEN** метки стоят на ровном местном часе, а смещение зоны равно `+0530`
- **THEN** слой метрики `hour`

#### Scenario: Одна метка на середине часа делает метрику минутной

- **WHEN** у плотной метрики десять меток стоят ровно на часе, а одна — на
  середине часа
- **THEN** слой метрики `minute`, а не `hour`

#### Scenario: Редкая метрика наследует преобладающий слой

- **WHEN** в доставке у метрики меньше десяти точек
- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки
- **AND** её собственное выравнивание во внимание не принимается

#### Scenario: Доставка, где всем метрикам слой назначен схемой

- **WHEN** в доставке нет метрик, которым слой надо выводить, — все точки
  принадлежат схемам с фиксированной гранулярностью
- **THEN** доставка сохраняется, а слой доставки не выводится и не требуется

Иначе доставка автоматизации, настроенной только на сон, отвергалась бы
целиком — и необратимо: исход детерминирован, и пересборка повторяла бы его
вечно.

#### Scenario: В доставке нет плотных метрик

- **WHEN** ни у одной метрики доставки нет десяти точек
- **THEN** слой наследуется от последнего надёжно выведенного слоя той же
  автоматизации (`automation-id`) среди доставок, **предшествующих** этой
- **AND** если наследовать нечего, слой берётся из **надёжного** заголовка
  (`Minutes` → `minute`, `Hours` → `hour`)

Граница «предшествующих» обязательна: слой обязан быть функцией от префикса
журнала. Наследование от последней доставки вообще делает свёртку зависящей от
истории, и пересборка даёт не то состояние, что живой приём — измерено на
архиве, 1737 объектов против 1742.

#### Scenario: Пересборка журнала даёт то же состояние

- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** число объектов и их содержимое не меняются

#### Scenario: Наследовать нечего и заголовок ненадёжен

- **WHEN** плотных метрик нет, предыдущего слоя автоматизации нет, а заголовок
  равен `Default`
- **THEN** точки доставки не сохраняются, а исход учитывается счётчиком и
  записью `WARN`
- **AND** тело остаётся в архиве, откуда доставку подберёт пересборка

Молчаливый выбор `raw` в этой ветке недопустим: заголовок `Default` наблюдался
одновременно у посекундного, минутного и часового режимов, поэтому он не
доказывает ничего, а призрачный `raw`-разрез попадёт в каталог и в правило
Read API «самый мелкий слой, покрывающий диапазон».

#### Scenario: Выведенный слой расходится с надёжным заголовком

- **WHEN** выведенный слой не совпадает с заголовком `Minutes` или `Hours`
- **THEN** система пишет запись уровня `WARN`
- **AND** сохраняет точки по выведенному слою, а не по заголовку

#### Scenario: Заголовок `Default` в сравнении не участвует

- **WHEN** заголовок доставки равен `Default`
- **THEN** расхождение не фиксируется и `WARN` не пишется

Иначе сигнал утонул бы в собственном шуме: `Default` не означает режима, и
сравнение с ним давало бы `WARN` на каждой доставке потока в пять минут.

### Requirement: Разбор форматов времени

Система SHALL разбирать метку формата `2026-07-31 21:03:51 +0300` и приводить
её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` других форматов
меток не встречается.

Система SHALL разбирать вторым форматом RFC 3339 в UTC
(`2026-07-31T18:03:51Z`): им приходят метки секции `data.stateOfMind`, тогда
как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься **у
любой** метки сущности, а не приписываться секции жёстко: формы однозначны и не
пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений —
`stateOfMind` уже шлёт стабильные коды HealthKit там, где старые секции шлют
переводы. Приписанный секции формат ломался бы молча в день такого выравнивания.

Метка RFC 3339 в UTC даёт офсет `0`, и это MUST означать «источник прислал
UTC», а не «человек находился в нулевой зоне»: местной зоны у секции
`stateOfMind` в потоке нет вовсе.

Метка **точки** при этом остаётся строгой — один формат, — и асимметрия
намеренная. По метке точки выводится слой, причём по метке в **исходной зоне**;
терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и
часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час).
У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер
отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и
пересборка вернёт его, когда формат станет известен.

Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
её: элементы серии проходят как исходные байты. Преобразование во `time.Unix`
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
`heart_rate_variability`.

Время внутри маршрута тренировки (`route[].timestamp`) меткой сущности тоже не
является и MUST проходить дословно, не разбираясь.

#### Scenario: Локальное время со смещением

- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
- **THEN** точка получает время в UTC и офсет `+10800` секунд

#### Scenario: RFC 3339 в UTC

- **WHEN** метка сущности имеет вид `2026-07-31T18:03:51Z`
- **THEN** сущность получает время в UTC и офсет `0`

#### Scenario: Тренировка со временем в формате метрик

- **WHEN** тренировка несёт `start` вида `2026-08-01 10:04:31 +0300`
- **THEN** сущность получает время в UTC и офсет `+10800` секунд

#### Scenario: Время внутри серии ударов

- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
- **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой
- **AND** серия не разворачивается в отдельные точки
- **AND** эпоха внутри серии не разбирается и не преобразуется

#### Scenario: Время внутри маршрута не разбирается

- **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки
- **THEN** точки маршрута сохраняются исходными байтами
- **AND** их метки не разбираются и не преобразуются

### Requirement: Разделение схем под одним именем метрики

Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
Export шлёт под одним именем, чтобы одно имя означало одну схему.

Под именем `sleep_analysis` приезжают две несовместимые схемы: поэпизодная
(`start`/`end`/`value`/`qty`) и суточная сводка
(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих
полей, кроме `date` и `source`, у них нет.

Схема точки SHALL определяться по самой точке, а не по её месту в исходном
массиве. Список разобранных точек отфильтрован пропусками, и соответствие по
индексу съезжало бы от одной пропущенной точки: эпизод уезжал бы под имя
суточной сводки со слоем `day`, сводка — под имя эпизода. Метрика и слой входят
в координату, поэтому ошибка необратима — точки из объекта не удаляются, и
пересборка воспроизвела бы её.

#### Scenario: Пропущенная точка не сдвигает разметку схем

- **WHEN** в метрике `sleep_analysis` перед суточной сводкой стоит точка без
  разбираемой метки
- **THEN** сводка всё равно сохраняется под именем `sleep_analysis_summary` со
  слоем `day`

#### Scenario: Поэпизодная запись сна

- **WHEN** точка `sleep_analysis` содержит поле `value`
- **THEN** она сохраняется под именем `sleep_analysis`

#### Scenario: Суточная сводка сна

- **WHEN** точка `sleep_analysis` содержит поле `totalSleep`
- **THEN** она сохраняется под именем `sleep_analysis_summary`
- **AND** её слой фиксирован как `day`, а не выводится из выравнивания

### Requirement: Канонизация содержимого

Система SHALL вычислять каноническую форму содержимого точки для сравнения и
хеширования: сортировка ключей и округление чисел до двенадцати значащих цифр.

Каноническая форма существует **только в момент вычисления хеша** и хранимую
форму не заменяет никогда: хранится исходные байты (см. «Разбор секции
метрик»). Числа при канонизации читаются литералом, а не через `float64`, —
иначе округление применится к уже испорченному значению.

Сортировка ключей — свойство `encoding/json`, своей реализации не требует.
Собственным остаётся только округление.

Без округления сравнение бесполезно: 63% повторно приехавших точек различались
последним разрядом double при одинаковом измерении.

#### Scenario: Повтор с иным порядком ключей опознаётся как тот же

- **WHEN** та же точка приезжает с другим порядком ключей в JSON
- **THEN** её каноническая форма совпадает с сохранённой

#### Scenario: Дребезг последнего разряда не считается изменением

- **WHEN** значение отличается только за пределами двенадцатой значащей цифры
- **THEN** каноническая форма совпадает с сохранённой

### Requirement: Разбор не влияет на код ответа приёма

Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не
меняет код ответа на доставку.

Разбор идёт **после** ответа, поэтому исход виден не в ответе и не в момент
ответа, а асинхронно — в `delivery.parse_status` и в записи лога. Когда именно
отдаётся ответ и кто сворачивает принятое, определяет capability `ingest`;
здесь нормируется только то, что от разбора код ответа не зависит.

#### Scenario: Содержимое не разобралось

- **WHEN** тело сохранено в архив, но разбор его содержимого не удался
- **THEN** ответ на приём остаётся `200`
- **AND** исход виден в `delivery.parse_status` и в записи лога

### Requirement: Перечисление непокрытых секций доставки

Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать
вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.

Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и
перечисление MUST ходить по одному объявленному множеству покрытых имён:
состояние «секция разбирается, но числится непокрытой» невыразимо по построению.

Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).

Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
доставками.

Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).

#### Scenario: Незнакомая секция попадает в список непокрытых

- **WHEN** тело содержит `data.ecg` наряду с `data.metrics`
- **THEN** разбор возвращает `ecg` в списке непокрытых ключей
- **AND** точки секции `metrics` разбираются как обычно

#### Scenario: Доставка из одних тренировок непокрытых ключей не даёт

- **WHEN** тело содержит только `data.workouts`
- **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны
- **AND** список непокрытых ключей пуст

#### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт

- **WHEN** тело содержит только `data.stateOfMind`
- **THEN** разбор завершается без ошибки, записи разобраны
- **AND** список непокрытых ключей пуст

#### Scenario: Доставка из одних метрик непокрытых ключей не даёт

- **WHEN** единственный ключ `data` — `metrics`
- **THEN** список непокрытых ключей пуст

#### Scenario: Один и тот же набор секций даёт один и тот же список

- **WHEN** два тела несут те же секции в разном порядке, а одно из них
  повторяет непокрытый ключ дважды
- **THEN** списки непокрытых ключей у них совпадают

#### Scenario: Содержимое непокрытой секции не удерживается в памяти

- **WHEN** тело в десятки мегабайт состоит преимущественно из непокрытой секции
- **THEN** после разбора удержано не больше четырёх размеров тела — та же
  граница, что и для тела из метрик
- **AND** содержимое непокрытой секции в результат разбора не попадает

### Requirement: Границы списка непокрытых секций

Список непокрытых ключей MUST быть ограничен — не больше 32 имён и не больше
64 байт на имя: имена приходят из тела, которым отправитель управляет целиком.
Срабатывание любой из границ MUST быть видно вызывающему — молчаливое усечение
превратило бы список в уверенный, но неполный ответ на вопрос «что останется
потерянным, если тело удалить».

Число имён сверх предела отдаётся счётчиком. Имя длиннее предела обрезается по
границе рун, к обрезанному приписывается маркер `…` — сверх предела, а не внутри
него. Обрезка не инъективна, поэтому обрезанное имя сравнению со словарём
известных секций не подлежит.

Предел длины считается по байтам **декодированного** имени: escape-
последовательности JSON к этому моменту уже разобраны.

#### Scenario: Ключей больше предела

- **WHEN** объект `data` содержит 40 непокрытых ключей
- **THEN** список содержит 32 имени
- **AND** число отброшенных имён отдано отдельным счётчиком

#### Scenario: Имя ключа длиннее предела

- **WHEN** непокрытый ключ длиннее 64 байт
- **THEN** в списке лежит имя, обрезанное по границе рун, с маркером `…`

### Requirement: Отказ разбора остаётся всё или ничего

Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
**после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей —
доставка считается неразобранной целиком.

Иначе часть данных оказалась бы в витрине под статусом, по которому доставку
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.

Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть
метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя
у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же
«всё или ничего» — доставка получила бы `failed` при частично записанной
витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если
такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу,
а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать
нельзя.

Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение
секций, а не победу последней: молча терять данные нельзя. То же SHALL
относиться к повтору самого члена `data` в теле — результаты **накапливаются**,
включая список непокрытых ключей.

#### Scenario: Тело оборвано после секции метрик

- **WHEN** тело содержит целую секцию `metrics`, а следующий член `data`
  оборван
- **THEN** разбор завершается ошибкой и точек не отдаёт

#### Scenario: Тело оборвано после секции тренировок

- **WHEN** тело содержит целую секцию `workouts`, а следующий член `data`
  оборван
- **THEN** разбор завершается ошибкой и сущностей не отдаёт

#### Scenario: Невыводимый слой не сохраняет и сущностей

- **WHEN** доставка несёт метрики, слой которых не определяется, и вместе с
  ними секцию `stateOfMind`
- **THEN** разбор завершается ошибкой, ни точек, ни записей не отдаёт
- **AND** список непокрытых ключей переживает отказ

#### Scenario: Секция метрик встречается дважды

- **WHEN** объект `data` содержит два ключа `metrics`
- **THEN** точки обеих секций попадают в результат

#### Scenario: Секция тренировок встречается дважды

- **WHEN** объект `data` содержит два ключа `workouts`
- **THEN** сущности обеих секций попадают в результат

#### Scenario: Член `data` встречается дважды

- **WHEN** тело содержит два члена `data`, из которых первый несёт непокрытую
  секцию, а второй — покрытую
- **THEN** данные покрытой секции попадают в результат
- **AND** имя непокрытой секции остаётся в списке непокрытых ключей

### Requirement: Разбор секций с собственными идентификаторами

Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
`метрика + слой + начало + конец` — её адресует сам `id`.

Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
с ними остаётся `partial`.

Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
остальное — включая маршрут, внутренние ряды (`heartRateData`,
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).

**Значение заголовка не того ТИПА SHALL стоить одного поля, а не сущности.**
Каждое поле заголовка читается мягко: строка берётся, когда значение является
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
сущность») и распространяется на весь заголовок. Иначе `name`, приехавшее
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
разобранной.

Мягкость MUST достигаться конструкцией, которая **не полагается на дозаполнение
остальных полей** библиотекой разбора: `encoding/json` при несовпадении типа
«skips that field and completes the unmarshaling as best it can», но тут же
оговаривает, что дозаполнение полей **после** проблемного не гарантировано.
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
функцией тела: одна и та же тренировка давала бы разный заголовок.

Граница правила называется вслух: оно закрывает смену **типа** значения, но не
смену **формата строки**. Наблюдавшийся дрейф — формата дат (разбор дат уже
зависит от секции пакета), и метка в незнакомом формате по-прежнему уносит
сущность целиком; закрыть это может только хранение сущности с неразобранной
меткой, а это отдельная задача. Пропуск при этом перестаёт быть невидимым: он
доходит до учётной записи доставки.

Идентификатор исключением из мягкости MUST быть: сущность без строкового `id`
не адресуема, и приведение чужого нестрокового значения к строке было бы
выдумыванием идентичности за источник. Такая сущность пропускается тем же
счётчиком, что и сущность без `id`.

Началом сущности при **присутствующем, но непрочитанном** `start` подставляться
`date` MUST NOT — включая `start: null`.
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на `date`
существует для сущностей, у которых `start` не прислан вовсе, а подстановка
другого поля вместо непонятого даёт метку **другого момента времени**, ничем не
отличимую от настоящей. Такой `start` SHALL считаться неразбираемой меткой —
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
надо именно «ключ был», а не «значение не той формы»: `null` тоже не даёт
строки, и без этого различения он молча уводил бы тренировку на другой момент
времени.

Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не
накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой
«побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы
заголовок функцией истории вызовов, а не тела.

Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
разобралось как объект» — включая `null`. Разбор в структуру на `null` ошибки не
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет `id`»,
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.

Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
отсутствием значения, а не нулём — ноль является законной длительностью, и
потребитель не отличил бы «источник не прислал» от «измерено ноль».

Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
два разных.

Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
Apple не транслируется» относится и к именам секций.

Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
секций.

Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
внутри тренировки, — и смешение задвоило бы ряд.

Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL
доходить до учётной записи доставки, а не только до лога, — иначе ретеншен,
решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка.

Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
метрик — большинство потока.

#### Scenario: Тренировка разбирается вместе с маршрутом

- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
  офсетом и длительностью
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами

#### Scenario: Ряд пульса тренировки не становится метрикой

- **WHEN** тренировка содержит `heartRateData`
- **THEN** точки этого ряда не попадают в точки метрик
- **AND** остаются внутри содержимого сущности

#### Scenario: Запись состояния разума разбирается

- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
  времени и содержимым исходными байтами

#### Scenario: Имя не той формы не уносит тренировку

- **WHEN** у тренировки с корректными `id` и `start` поле `name` приехало
  числом
- **THEN** тренировка попадает в результат разбора с пустым именем
- **AND** её содержимое сохраняется дословно, включая маршрут

#### Scenario: Конец не той формы не уносит тренировку

- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
- **THEN** тренировка попадает в результат разбора, а конец равен началу

#### Scenario: Сущность без идентификатора пропускается

- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
  приехал не строкой
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается

#### Scenario: Элемент секции не является объектом

- **WHEN** элемент покрытой секции не разбирается как объект JSON
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
  разбираются как обычно

Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
тренировкой в него уехали бы записи `stateOfMind` той же доставки.

#### Scenario: Элемент секции не объект — свой счётчик

- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
- **THEN** факт учитывается счётчиком «не разобралось как объект»
- **AND** счётчик «нет `id`» не растёт

#### Scenario: Повтор ключа метки решается последним значением

- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
  последней
- **THEN** сущность попадает в результат разбора с этой меткой

#### Scenario: Сущность без разбираемой метки времени пропускается

- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
  разбирается ни одним из поддерживаемых форматов либо пришла не строкой
  (включая `null`)
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком
- **AND** поле `date` вместо непонятого `start` не подставляется

#### Scenario: Сущность со слишком длинным идентификатором пропускается

- **WHEN** элемент покрытой секции несёт `id` длиннее предела
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`

#### Scenario: Длительность берётся из тела, а не из интервала

- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
  `start`/`end` в 91 секунду
- **THEN** длительность сущности равна `91.746`

#### Scenario: Тренировка без длительности сохраняется без неё

- **WHEN** тренировка не несёт `duration` либо оно не является числом
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
- **AND** нулём она MUST NOT становиться

#### Scenario: Нечитаемый конец тренировки не отбрасывает её

- **WHEN** тренировка несёт `end`, который не разбирается
- **THEN** конец сущности равен её началу
- **AND** исходное значение остаётся в содержимом дословно

#### Scenario: Незнакомое поле тренировки переживает разбор

- **WHEN** тренировка несёт поле, которого разбор не знает
- **THEN** оно сохраняется в содержимом сущности дословно
- **AND** разбор не завершается ошибкой

#### Scenario: Непокрытая секция с собственными id остаётся непокрытой

- **WHEN** тело содержит `data.ecg`
- **THEN** `ecg` попадает в список непокрытых ключей
- **AND** сущностей из неё разбор не отдаёт

### Requirement: Диагностика разбора не несёт значений из тела

Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно
SHALL называть **тип** встреченного токена и смещение во входе — по смещению
место находится в теле, лежащем в архиве, а значение из тела в логе не имеет
права быть в принципе.

Тип SHALL называться словарём JSON (`object`, `array`, `string`, `number`,
`bool`, `null`), а не именем типа языка реализации: имя типа для делимитера не
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
поэтому печатается значением.

Смещение SHALL указывать на место **перед** виновным токеном и от длины его
значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего
возвращённого токена, поэтому взятая после чтения она отличалась бы от начала
проблемы ровно на длину значения — то есть на восемь мегабайт в том самом
случае, ради которого требование написано, и обещание «место находится в теле»
не выполнялось бы.

Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
чувствительнее токенов, тела запросов пишутся только на `DEBUG` и с обрезкой.
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
8 МиБ, который уходит атрибутом `error` на уровень `WARN` — то есть содержимое
доставки оказывается в логе полностью и без обрезки.

Предел SHALL держаться самим сообщением, а не обрезкой на стороне
логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.

Правило SHALL распространяться и на **чужие** причины: ошибка библиотеки разбора
кладёт в текст литерал значения, поэтому причина, приходящая извне, обрезается по
названной длине на границе. Тот же предел SHALL действовать на проверке формы
конверта при приёме — она пользуется той же библиотекой, и её отказ логируется на
`DEBUG`, где инвариант тоже требует обрезки.

#### Scenario: Огромное значение не доезжает до текста ошибки

- **WHEN** тело содержит на месте ожидаемого объекта строку в несколько
  мегабайт
- **THEN** разбор завершается ошибкой
- **AND** длина текста ошибки не зависит от длины этого значения
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
- **AND** смещение не меняется, если то же значение сделать длиннее

### Requirement: Категориальные значения получают стабильный код

Разбор SHALL выделять из тела **категориальные значения** — строки объявленных
полей, у которых значение перечислимо, — и выводить для каждого стабильный код
HealthKit по словарю `(локаль, строка) → код`.

Объявленных полей три, и все три измерены на живом потоке (находка 37):

```
sleep_analysis   value     «БДГ», «Основная», «Глубокий», «Во сне», «В кровати», «Бодрствование»
heart_rate       context   «Не задано», «Сидячий образ жизни», «Активен»
workouts         name      «В помещении Ходьба», «На улице Ходьба»
```

Список полей SHALL быть объявлен явно, а не выведен из формы значения. Строк в
точке много (`date`, `start`, `source`), и «всякая строка категориальна» завело
бы в реестр метки времени и имена устройств.

Именем в наблюдении SHALL быть **то же** имя метрики или секции, которым
адресуется единица хранения: `sleep_analysis_summary` после разделения схем,
`workouts` для тренировок. Второе имя для того же понятия развело бы наблюдение
и объект по разным ключам.

Исходная строка MUST оставаться в содержимом точки или сущности **дословно и
нетронутой**: код добавляется рядом, отдельной единицей, и обратное
преобразование остаётся возможным всегда. Содержимое точки MUST NOT
пересериализовываться ради кода.

Значение SHALL читаться из исходных байтов и приниматься только как непустая
JSON-строка. Значение другого рода (число, объект, `null`), отсутствие поля и
пустая строка категориальным значением не считаются; точка при этом MUST
разбираться как обычно — новых путей потери точки требование не заводит. Пустая
строка исключена намеренно: сказать о данных ей нечего, а в счётчике строк без
кода она сидела бы вечно.

Результат разбора SHALL нести множество различных наблюдённых категориальных
значений — по одному элементу на тройку `(метрика или секция, поле, строка)`, в
детерминированном порядке. Повтор одной строки в тысяче точек даёт один элемент.
Ключ наблюдения совпадает с ключом реестра: два разных ключа на одно понятие
разошлись бы при первом же поле с одинаковым именем у двух метрик.

#### Scenario: Фаза сна из реального пакета получает код

- **WHEN** разбирается пакет HAE из `testdata`, где `sleep_analysis.value`
  равно «Во сне», а локаль доставки — `ru`
- **THEN** содержимое точки в результате разбора совпадает с исходными байтами
  тела, включая строку «Во сне»
- **AND** результат разбора несёт наблюдение
  `sleep_analysis / value / «Во сне»` с кодом
  `HKCategoryValueSleepAnalysisAsleepUnspecified`

#### Scenario: Имя тренировки попадает в наблюдённые значения

- **WHEN** разбирается пакет с тренировкой «В помещении Ходьба»
- **THEN** результат разбора несёт наблюдение `workouts / name / «В помещении
  Ходьба»`
- **AND** содержимое тренировки не изменено

#### Scenario: Повтор строки не задваивает наблюдение

- **WHEN** в доставке тысяча точек `heart_rate` с одинаковым `context`
- **THEN** в результате разбора это одно наблюдение

#### Scenario: Значение не-строка точку не роняет

- **WHEN** у точки `sleep_analysis` поле `value` пришло числом
- **THEN** точка разобрана как обычно и её содержимое сохранено дословно
- **AND** наблюдения из неё не выводится

#### Scenario: Тренировка без имени наблюдения не даёт

- **WHEN** у тренировки нет поля `name` либо оно пришло пустой строкой
- **THEN** наблюдения из неё не выводится
- **AND** счётчик значений без кода не растёт

### Requirement: Незнакомая строка даёт пустой код и попадает в счётчик

Разбор MUST NOT угадывать код: строка, которой словарь не знает, SHALL получать
**пустой** код. Пустота честнее догадки — по коду сверяются с экспортом Apple, и
неверный код неотличим от верного до тех пор, пока по нему не примут решение.

Разбор SHALL считать различные наблюдения, для которых код не выведен, и
отдавать счётчик вызывающему. Считаются **различные** наблюдения, а не их
вхождения: вопрос, на который счётчик отвечает, — «сколько строк ждёт словаря»,
а не «сколько точек их несло».

Счётчик ненулевой в установившемся режиме — словарь покрывает только фазы сна, а
контекст пульса и имена тренировок объявлены категориальными заранее. Сигналом
«появилось новое» служит поэтому **новая строка в реестре**, а не ненулевой
счётчик; требование называет это, чтобы счётчик не читали как тревогу.

Незнакомая строка MUST NOT влиять на исход разбора: доставка разбирается,
точки сохраняются, статус не меняется.

#### Scenario: Выдуманная фаза сна не роняет разбор

- **WHEN** в теле встречается `sleep_analysis.value` со строкой, которой в
  словаре нет
- **THEN** разбор завершается без ошибки и точка сохраняется дословно
- **AND** её наблюдение имеет пустой код
- **AND** счётчик значений без кода равен единице

#### Scenario: Известная и неизвестная строки в одной доставке

- **WHEN** доставка несёт и «Во сне», и незнакомую строку в том же поле
- **THEN** у первой код выведен, у второй пуст
- **AND** счётчик значений без кода равен единице

### Requirement: Локаль сужает поиск, но её отсутствие не отменяет вывода

Локаль доставки SHALL браться из заголовка `Accept-Language` и нормализоваться
по правилу lookup RFC 4647: берётся первый тег списка, подтеги отсекаются,
регистр свёрнут (`RU-ru,ru;q=0.9` → `ru`). Свёртка регистра обязательна: теги
BCP 47 регистронезависимы, и без неё `RU` и `ru` были бы разными языками.

Вывод кода SHALL идти тремя разрядами:

1. пара `(локаль, строка)` есть в словаре — её код;
2. локали нет либо пары нет, но строка известна в других локалях и **все** они
   дают один код — этот код;
3. иначе — пустой код.

Второй разряд обязателен, а не удобен. Заголовки запроса в сыром архиве не
лежат: они были заголовками запроса, а не телом. Доставка, восстановленная из
осиротевшего тела, приезжает без `Accept-Language`, и правило «нет локали — нет
кода» сделало бы состояние функцией от того, уцелела ли строка учёта, — то есть
сломало бы `import + replay`.

Локаль MUST NOT входить в ключ наблюдения. Ключ, содержащий локаль, оставляет ту
же зависимость этажом ниже: усыновлённая доставка положила бы вторую строку с
пустой локалью, и раздел реестра в отпечатке разошёлся бы вне всякого названного
класса. На каком языке приехала строка, восстанавливается по доставке
провенанса — тем же приёмом, каким устроен провенанс часового объекта.

Расхождение локалей MUST разрешаться пустым кодом, а не выбором: если одна и та
же строка в разных локалях означает разные коды, выбирать не из чего.

#### Scenario: Локаль с подтегом, весами и в верхнем регистре приводится к базовому тегу

- **WHEN** доставка пришла с `Accept-Language: RU-ru,ru;q=0.9,en;q=0.8`
- **THEN** локалью доставки считается `ru`
- **AND** фаза сна «Во сне» получает свой код

#### Scenario: Доставка без заголовка локали код всё равно получает

- **WHEN** у доставки заголовка `Accept-Language` нет вовсе
- **THEN** «Во сне» получает тот же код, что и при локали `ru`
- **AND** наблюдение имеет тот же ключ, что и при локали `ru`

#### Scenario: Незнакомая локаль знакомой строки код не отменяет

- **WHEN** доставка пришла с локалью, которой в словаре нет, а строка известна в
  единственной локали
- **THEN** код выведен

#### Scenario: Мусор в заголовке разбор не роняет

- **WHEN** заголовок пуст, состоит из `*`, из пробелов или из сотни тегов
- **THEN** разбор завершается без паники, локаль либо выведена, либо пуста
- **AND** коды выводятся вторым разрядом

### Requirement: Переименование кода самой Apple не раскалывает историю

Система SHALL держать таблицу синонимов кодов HealthKit — соответствие
устаревшего имени каноническому — и приводить к каноническому имени **всякий**
выведенный код, а не только код, пришедший из экспорта Apple.

Основание измерено (находка 43): те же 338 записей сна экспортированы как
`HKCategoryValueSleepAnalysisAsleep` в 2021 году и как
`HKCategoryValueSleepAnalysisAsleepUnspecified` в 2026-м — Apple переименовала
значение и переписывает историю при выгрузке. Код устойчивее локализованной
строки, но не абсолютен.

Приведение SHALL применяться и к словарю, поэтому «две формы сходятся в один
код» верно по построению. Таблица SHALL быть **плоской**: ни одно её значение не
является ключом, поэтому алиас разрешается ровно за один шаг. Инвариант
проверяется тестом по таблице целиком; рантайм-обхода цепочек и ограничения
глубины у системы быть MUST NOT — у них нет ни одного достижимого сценария, зато
есть собственный вырожденный случай.

#### Scenario: Устаревшее и новое имя дают один код

- **WHEN** канонизируются `HKCategoryValueSleepAnalysisAsleep` и
  `HKCategoryValueSleepAnalysisAsleepUnspecified`
- **THEN** обе формы дают `HKCategoryValueSleepAnalysisAsleepUnspecified`

#### Scenario: Код без синонима остаётся собой

- **WHEN** канонизируется `HKCategoryValueSleepAnalysisAsleepREM`
- **THEN** результат равен исходному коду

#### Scenario: Таблица синонимов плоская

- **WHEN** проверяется таблица синонимов целиком
- **THEN** ни одно её значение не встречается среди её ключей

### Requirement: Границы наблюдённых категориальных значений

Число различных наблюдений одной доставки MUST быть ограничено **64**, а длина
значения — **128 байт**; отброшенное SHALL считаться. Тело контролирует
отправитель целиком: без границы одна доставка кладёт в витрину сколько угодно
строк.

Граница числа SHALL применяться **при накоплении, а не при выдаче**. Накопитель
без границы растёт по числу различных строк тела, а их контролирует
отправитель: измерено — тело в 60 МиБ из миллиона различных значений (предел
приёма 64 МиБ) поднимало пик процесса с 780 до 1002 МиБ. Лимита памяти у
контейнера нет, отказ по памяти в фоновой горутине свёртки не перехватывается,
а перезапуск берёт ту же доставку из архива: приём стоит, телефон доставку не
перешлёт.

Счётчик отброшенного считает **вхождения**, а не различные значения, и единица
MUST быть названа. Различные здесь не считаются не по недосмотру: отброшенный
ключ нигде не запоминается, а запомнить его значило бы вернуть тот самый
неограниченный рост. Счётчик отвечает на «сколько раз сработала граница»; на
«какие строки ждут словаря» отвечает реестр.

Числа названы измерением, а не аналогией: на живом потоке различных значений по
всем трём полям около одиннадцати, самое длинное — «Сидячий образ жизни»,
36 байт UTF-8. Предел в 32 байта, взятый по аналогии с именами непокрытых
секций, отбросил бы две из трёх измеренных строк контекста пульса: там имена
короткие и латинские, здесь — русские фразы.

Значение длиннее предела SHALL **отбрасываться со счётчиком, а не обрезаться**.
Обрезанная строка неотличима от настоящей и попала бы в реестр самостоятельным
значением; настоящая строка при этом остаётся в точке целиком — теряется
наблюдение, а не данные.

При переполнении границы числа уцелевший набор SHALL быть **функцией множества
наблюдений, а не порядка элементов на проводе**: наблюдения упорядочиваются по
ключу и берутся первые. Иначе то же содержимое, переприсланное в другом порядке
ключей (порядок у HAE нестабилен, находка 2), давало бы другой реестр.

#### Scenario: Слишком много различных строк

- **WHEN** доставка несёт различных наблюдений больше предела
- **THEN** в результате разбора их ровно предел
- **AND** счётчик отброшенных наблюдений положителен
- **AND** все точки доставки сохранены

#### Scenario: Уцелевший набор не зависит от порядка точек в теле

- **WHEN** два тела несут одно и то же множество категориальных строк в разном
  порядке, и обоим не хватает предела
- **THEN** уцелевшие наблюдения у них совпадают

#### Scenario: Непомерно длинное значение отброшено целиком

- **WHEN** `sleep_analysis.value` длиннее предела длины
- **THEN** наблюдения из него не выводится, счётчик отброшенных положителен
- **AND** содержимое точки сохранено дословно и целиком
