Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога, план отражает сделанную часть шага 3. Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит с 17:13, пауза началась до перезапуска сервиса.
261 lines
18 KiB
Markdown
261 lines
18 KiB
Markdown
# 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` и в записи лога
|
||
|