# parsing Specification ## Purpose TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after archive. ## 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` других форматов меток не встречается. Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри** `heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать её: элементы серии проходят как исходные байты. Преобразование во `time.Unix` и обратно не гарантирует дословности, а серия составляет 93% объёма метрики `heart_rate_variability`. RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается только в `data.stateOfMind`, которая выведена из scope. Требование к нему появится вместе с задачей про секции с собственными `id` — вместе с данными, на которых его можно проверить. #### Scenario: Локальное время со смещением - **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300` - **THEN** точка получает время в UTC и офсет `+10800` секунд #### Scenario: Время внутри серии ударов - **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries` - **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой - **AND** серия не разворачивается в отдельные точки - **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 сохранять правило «сохранили — значит приняли»: исход разбора не меняет код ответа на доставку. #### Scenario: Содержимое не разобралось - **WHEN** тело сохранено в архив, но разбор его содержимого не удался - **THEN** ответ на приём остаётся `200` - **AND** исход виден в `delivery.parse_status` и в записи лога ### Requirement: Перечисление непокрытых секций доставки Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до 42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации. Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление MUST ходить по одному объявленному множеству покрытых имён: состояние «секция разбирается, но числится непокрытой» невыразимо по построению. Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает. Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок). Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между доставками. Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99). #### Scenario: Незнакомая секция попадает в список непокрытых - **WHEN** тело содержит `data.workouts` наряду с `data.metrics` - **THEN** разбор возвращает `workouts` в списке непокрытых ключей - **AND** точки секции `metrics` разбираются как обычно #### Scenario: Доставка без метрик разбирается и не теряется - **WHEN** тело содержит только `data.stateOfMind` - **THEN** разбор завершается без ошибки, точек нет - **AND** `stateOfMind` возвращается в списке непокрытых ключей #### 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 оставаться операцией «всё или ничего»: ошибка, встреченная **после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в следующем члене), MUST NOT оставлять точки в результате — доставка считается неразобранной целиком. Иначе часть точек оказалась бы в витрине под статусом, по которому доставку никто не подберёт, и свёртка перестала бы быть детерминированной по журналу. Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а не победу последней: молча терять точки нельзя. #### Scenario: Тело оборвано после секции метрик - **WHEN** тело содержит целую секцию `metrics`, а следующий член `data` оборван - **THEN** разбор завершается ошибкой и точек не отдаёт #### Scenario: Секция метрик встречается дважды - **WHEN** объект `data` содержит два ключа `metrics` - **THEN** точки обеих секций попадают в результат