Files
healthlog/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/parsing/spec.md
T
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

325 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## ADDED Requirements
### 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 браться из тела, а не вычисляться из начала и конца: 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 пропускаться со
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
подберёт пересборка, когда разбор научится её понимать.
Отсутствие покрытой секции в теле ошибкой быть 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` либо `id` пуст
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
#### Scenario: Элемент секции не является объектом
- **WHEN** элемент покрытой секции не разбирается как объект JSON
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
разбираются как обычно
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
#### Scenario: Сущность без разбираемой метки времени пропускается
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
разбирается ни одним из поддерживаемых форматов
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком
#### 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** сущностей из неё разбор не отдаёт
## MODIFIED Requirements
### 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 разбирать метку формата `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 перечислять верхнеуровневые ключи объекта `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** содержимое непокрытой секции в результат разбора не попадает