feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -0,0 +1,415 @@
## Context
Разбор покрывает одну секцию тела — `metrics`. Остальное перечисляется в
`delivery.uncovered_sections`, доставка получает `partial`, тело живёт в архиве.
Замер по 118 доставкам архива: `metrics` — 65 доставок, `workouts` — 27,
`stateOfMind` — 26; ни одна доставка не несла двух секций сразу.
Тренировка и состояние разума устроены иначе, чем метрика, и это не стилистика:
- у них есть **собственный `id`** (UUID из HealthKit) — координатный ключ
`метрика + слой + начало + конец` им не нужен;
- они **редки**: за двое суток потока — 2 разных тренировки и 2 разных записи
состояния разума, при 44 и 52 доставленных копиях соответственно;
- у них **нет слоя**: подробности выгрузки у этих секций в интерфейсе HAE не
бывает, есть только глубина окна;
- тренировка **тяжёлая**: маршрут — 95% её веса (190 КБ из 199,6 КБ у
десятиминутной прогулки), и приезжает она повторно, пока маршрут не доедет.
Замер поведения при переприсылке (тот же архив, группировка по `id`):
```
тренировка A 26 копий 3 различных содержимых поля росли, не убывали
тренировка B 18 копий 1 содержимое маршрут с первой копии
stateOfMind 26 и 26 копий, по 1 содержимому каждая
```
Что именно менялось у тренировки A между версиями:
```
версия 0 → 1 +stepCadence, +stepCount, изменилось значение activeEnergy
версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy
```
То есть тренировка досчитывается задним числом ровно так же, как минутное ведро
(находка 10), и при этом набор полей за весь корпус ни разу не уменьшился.
## Goals / Non-Goals
**Goals:**
- Тренировка лежит в витрине целиком, вместе с маршрутом и внутренними рядами,
дословно и без интерпретации.
- Состояние разума лежит записями — секция, которой нет в экспорте Apple, больше
не зависит от того, что тело не удалили.
- Свёртка остаётся детерминированной: `reindex` даёт то же состояние, что живой
приём, и это проверяется отпечатком, а не «числом строк».
- Правило «при столкновении выигрывает более полная версия» продолжает
действовать и для сущности с собственным `id`.
**Non-Goals:**
- **Отдача наружу.** Read API в проекте нет вовсе; форма конверта, выбор слоя и
предел размера ответа проектируются задачей `read-api-tochki`. Два эндпоинта,
введённые раньше конверта, задали бы контракт мимоходом.
- **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`,
`medications`, `heartRateNotifications`). Модель под них закладывается —
таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их
формы никто не видел, а задача `proverka-novyh-sekcij` существует ровно про
момент, когда они появятся.
- **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет
клиента.
- **Словарь категориальных значений** (`name` тренировки — «В помещении Ходьба»,
машинная калька) — отдельная задача; здесь строка хранится дословно.
## Decisions
### 1. Две таблицы, а не одна с колонкой рода
`workout` и `record` разведены, как и записано в `docs/architecture.md`.
Общая таблица `entity(kind, id, …)` выглядит экономнее и хуже по существу: у
тренировки есть заголовок, который нужен запросом «что было за период» —
`name`, `start`, `end`, `duration`, — а у записи состояния разума его нет.
Общая таблица либо теряет заголовок (тогда список тренировок требует разжатия
каждого блоба), либо заводит колонки, пустые у пяти родов из шести.
Отвергнуто и обратное — таблица на каждый род секции: шесть почти одинаковых
таблиц, и каждая новая секция требует миграции. `record` ключуется родом, и
новая секция добавляется одной строкой в множество покрытых имён.
### 2. Ключ `record` — `(kind, id)`, а не один `id`
Отклонение от схемы, набросанной в `architecture.md` (`record(id PK, kind, …)`),
и оно намеренное. У `stateOfMind` `id` — настоящий UUID HealthKit, но остальные
пять секций живьём не видели никто: форма их идентификатора неизвестна, и
короткий несквозной `id` в двух разных секциях молча затёр бы одну запись
другой. Пара стоит ноль (запросы к `record` всегда идут с родом: `GET
/records/{kind}`) и снимает целый класс.
Ключ `workout``id`: род у него один.
### 3. Сущность заменяется целиком; побеждает не последняя, а не теряющая полей
Центральное решение задачи. Тренировка «перезаписывается» (беклог,
`architecture.md`), но инвариант проекта гласит «при столкновении выигрывает
более полная точка, а не последняя пришедшая». Развилку решает замер выше.
Правило:
```
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
2. приехавшая несёт всё, что сохранённая, и
сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо (наборы равны) → версия из БОЛЕЕ ПОЗДНЕЙ
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**«Теряет содержание» считается по множеству ключей, а не по `canon.Relate`.**
Это единственная деталь, где реализация не может переиспользовать правило точек
как есть, и цена ошибки здесь — маршрут. Проверено выполненной командой на копии
пакета `canon`:
```
сохранённая vs обеднённая, значения общих полей те же : superset
сохранённая vs обеднённая, значения общих полей иные : equal
сохранённая vs усечённый маршрут (2 точки → 1) : equal
```
`canon.Fields.Relate` гасит отношение включения до `equal`, когда значения
общих содержательных ключей разошлись, — и это верно для точки (надмножество
имён при других значениях означает другое измерение), но неверно для сущности:
замер выше говорит, что между версиями тренировки значения меняются **всегда**.
То есть настоящая обеднённая версия пришла бы с изменёнными значениями,
получила бы `equal` и заместила бы сохранённую целиком, а тест на наивной
фикстуре (значения не тронуты) остался бы зелёным.
Поэтому в `canon` заводится вторая, явная операция — сравнение **множеств
содержательных ключей** без условия о совпадении значений, поверх уже
существующего внутреннего `relateKeys`. Именно в `canon`, а не в `store`:
пакет заведён ради единственной реализации сравнения, и вторая копия разошлась
бы с первой молча.
Полнота меряется **верхним уровнем** ключей и длиной верхнеуровневых массивов.
Второе добавлено намеренно: усечённый маршрут (3 точки вместо 593) ключа не
теряет, поэтому одних множеств мало, а маршрут — 95% веса тренировки. Досчёт
ряды удлиняет, а не укорачивает, так что укорачивание — законный сигнал
«приехало меньше». Предел правила назван вслух: сокращение **внутри** элемента
ряда (точка маршрута потеряла `altitude`) не ловится ничем.
**Тай-брейк при равных наборах — позиция доставки в журнале, а не порядок
свёртки.** У точки при равной полноте исход решает порядок канонических форм
(`canon.Less`) — тай-брейк, который намеренно не выбран, пока не измерен род
агрегации. Приложи его к тренировке — и на наших же данных версия 1 → 2 (набор
полей тот же, досчитаны `totalEnergy` и `basalEnergy`) осталась бы на
произвольной из двух **навсегда**: тренировка замерла бы с недосчитанной
энергией. Причина расхождения содержательная: у точки на одной координате
законно встречаются два разных измерения (разные устройства,
пересэмплирование), и предпочитать позднее нет оснований; у сущности `id`
идентичность одного объекта HealthKit, и вторая версия есть тот же объект,
пересчитанный источником.
Отвергнуто и напрашивавшееся «побеждает приехавшая»: приехавшая — это функция
**порядка свёртки**, а он не равен порядку журнала. Спека приёма говорит прямо,
что воркер сворачивает в порядке `(received_at, id)` только среди **видимых**
ему доставок, а абсолютного порядка при конкурентных приёмах не обещает
(`docs/architecture.md`, «Предел порядка назван вслух»; открытый блокер
`poryadok-zhurnala-na-priyome.md`). Доставка с более ранней меткой, свёрнутая
позже, вернула бы витрину к недосчитанной версии — и `reindex` разошёлся бы с
живым приёмом **молча**, в содержимом тренировки. Поэтому сущность несёт
провенанс — `delivery_id` и `received_at` своей доставки, — а тай-брейк
сравнивает пару `(received_at, id)`. Тогда исход при равных наборах зависит
только от журнала, а не от того, кто раньше добрался до базы.
Провенанс нужен и сам по себе: у часового объекта он обязателен («провенанс для
разбора слияний»), а `WARN` об удержанной обеднённой версии без него не связать
с телом в архиве.
Две версии с одинаковым ключом **внутри одной доставки** (позиции равны)
разрешаются минимумом канонической формы: порядок элементов в JSON-массиве
нестабилен, и опираться на него нельзя.
Почему **не** голый upsert по `id` (как делает сервер HealthyApps поверх
MongoDB и как просилось из формулировки «перезаписывается»): единственный
сценарий, ради которого тренировка приезжает повторно, — доезжающий маршрут,
то есть рост. Обратное — приезд версии без маршрута — за 44 доставленные копии
не случилось ни разу, но стоит 95% содержимого тренировки, а восстановление
требует пересборки всего журнала. Условие пункта 3 стоит одного сравнения
множеств и делает событие **наблюдаемым** вместо необратимого.
Несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) в пункте
5 разрешаются в пользу сохранённой: поля не объединяются, объединение отвергнуто
там же, где для точек, — на живом потоке событие не наступало ни разу, и вместо
реализации заведено наблюдение.
**Остаточный предел назван вслух.** Слияние попарное — сохранённая против
приехавшей, — поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
проигрывания. Тот же предел есть у часового объекта: в объекте лежит победитель
прошлых слияний, а не все кандидаты истории. Пункты 2–4 от порядка свёртки не
зависят, а пункт 5 сопровождается счётчиком и `WARN`, поэтому событие не будет
молчаливым.
### 4. Свёртка доставки остаётся одной транзакцией
`MergePoints` превращается в `Merge(ctx, Incoming{Points, Workouts, Records},
deliveryID)`: точки, тренировки и записи одной доставки пишутся одной
транзакцией. Спека хранения требует этого прямо («Доставка SHALL сворачиваться
одной транзакцией»), и требование не про точки, а про доставку: частичное
состояние ломает инвариант «состояние пересобираемо».
Наблюдение «ни одна доставка не несла двух секций сразу» (находка 50) собрано за
двое суток и основанием для второй транзакции не является.
Имя `MergePoints` уходит: метод перестал сливать одни точки, а два метода с
двумя транзакциями были бы вторым способом делать то же самое.
### 5. `payload` — сжатый блоб, как у часового объекта
Дословные байты сущности, gzip. Тот же приём и по той же причине, что у
`bucket`: маршрут — 95% веса тренировки, JSON такого рода жмётся примерно в
25 раз, а прогулка в час даёт порядка мегабайта. Цена названа там же и здесь та
же: внутрь `payload` не заглянуть SQL-функциями. Для хранилища, которое отдаёт
тренировку целиком, это не потеря; заголовок, по которому идёт выборка, лежит
колонками.
Отвергнуто хранение текстом (как было набросано в `architecture.md`, `payload
JSON`): второе кодирование для той же по природе величины стоило бы дороже
любой выгоды от `json_extract`, а объём — сотни мегабайт в год против десятков.
### 6. Заголовок тренировки — ровно то, по чему идёт выборка
`name`, `start_utc`, `end_utc`, `tz_offset`, `duration_sec`. Больше ничего:
любая следующая колонка — это решение за Apple о том, что в тренировке главное
(находка 15: сводки дублируют ряды, `distance` — это сумма
`walkingAndRunningDistance`).
`duration` берётся из тела, а не считается как `end - start`: HAE шлёт
91.746 секунды при интервале в 91 секунду, и вычисленное значение молча
разошлось бы с присланным. Отсутствует или не число — колонка **`NULL`**, а не
ноль: ноль — законная длительность, и потребитель, просуммировавший столбец, не
отличил бы «источник не прислал» от «измерено ноль». Тело в `payload` дословно в
любом случае.
`end` нечитаем или отсутствует — `end_utc` равен `start_utc`. У точки
вырождение интервала в мгновение запрещено, потому что схлопывает координату; у
сущности ключ — `id`, схлопывать нечего, а истина остаётся в `payload`. Офсет
берётся из `start`: колонка одна, а пробежка через смену зоны дала бы два
разных.
Метка записи — `start`, при его отсутствии `date`. `end` в заголовок не идёт:
у рода `daily_mood` он может отстоять от начала на сутки, и вторая колонка
понадобится вместе с запросом, которого пока нет.
`name` локализован («В помещении Ходьба»); хранится дословно, стабильный код
припишет задача словаря категориальных значений.
Значение `kind` у записи — верхнеуровневый ключ секции HAE **дословно**
(`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется»
относится и к именам секций, а переименование после мерджа стоило бы миграции
данных.
### 7. Метка времени: у сущности оба известных формата, у точки — один
`parseEntityTime` пробует формат HAE (`2026-07-31 21:03:51 +0300`), затем
RFC 3339 (`2026-07-31T18:03:51Z`). Форматы измерены (находка 16: у тренировок
первый, у `stateOfMind` второй) и не пересекаются.
Отвергнуто приписывание формата секции: оно точнее описывает сегодняшний день и
ломается молча в тот, когда HAE выровняет секции между собой — а он к этому идёт
(`stateOfMind` уже шлёт честные коды HealthKit там, где старые секции шлют
переводы, находка 37). Цена терпимости нулевая: неоднозначности между двумя
формами нет.
**Точка остаётся строгой, и это не забывчивость.** У точки по метке выводится
слой, причём по метке **местной**: метка в UTC объявила бы часовую выгрузку
минутной, и минутный слой сложился бы с часовым (находка 35 — ровно такое
удвоение уже наблюдалось). Терпимый парсер там означал бы тихую порчу разреза;
строгий отдаёт непонятую метку в счётчик пропусков и `WARN`, а тело остаётся в
архиве. У сущности слоя нет, и терять на строгости нечего — асимметрия
намеренная.
Следствие, которое надо назвать вслух: у `stateOfMind` `tz_offset` всегда `0`,
потому что HAE прислал UTC, а не потому, что человек был в Гринвиче. Местная
зона этой секции в потоке отсутствует.
### 8. Отпечаток витрины покрывает сущности, и снимается одним снимком
`Store.Fingerprint` — единственный оракул сходимости `reindex` и `task
verify:archive`. Оставить его отпечатком одних часовых объектов значило бы
получить «состояние сошлось» при разъехавшихся тренировках — то есть сломать
проверку молча, ровно тем изменением, которое добавляет данные.
Три раздела читаются **одной read-only транзакцией**. Сегодня отпечаток — один
`SELECT`, то есть один снимок; три запроса подряд вне транзакции в режиме WAL
дают три снимка, а рабочий отпечаток снимается под живым приёмом. Свёртка,
закоммитившаяся между запросами, дала бы смесь «объекты до» и «тренировки
после», то есть ложное «разошлись» у единственного оракула. Прецедент в
проекте есть — `Store.Bucket` уже читает в `BeginTx(ReadOnly)`.
Строки разделов идут с константным тегом впереди (`b|`, `w|`, `r|`): без него
строка одного раздела может совпасть со строкой другого — та же причина, по
которой поля переменной длины уже идут с длиной впереди.
Отчёт `reindex` расширяется вместе с отпечатком: счётчики тренировок и записей
«было и стало» рядом с числом объектов, и «покрыта новая секция» в перечне
ожидаемых классов расхождения. Иначе первый же прогон после мерджа даст
гарантированное расхождение отпечатков при неизменившемся числе объектов — и
оракул выродится в шум ровно тогда, когда по нему принимается необратимое
решение о подмене базы.
### 9. Покрытыми становятся ровно две секции
`covered()` — множество из трёх имён: `metrics`, `workouts`, `stateOfMind`.
Прочие секции с собственным `id` остаются в списке непокрытых, доставка с ними
остаётся `partial`, тело — в архиве. Это честно: формы этих секций никто не
видел, а «полнота покрытия HealthKit ради полноты» целью проекта не является
(паспорт).
Следствие, которое надо назвать вслух и передать дальше: доставка из одного
`stateOfMind` теперь получает `parsed` с пустым списком непокрытых, то есть
становится **неотличимой** от доставки из метрик — а метрики восстановимы из
экспорта Apple, состояние разума нет (находка 46). До этой задачи защита
работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь
ничего не ломается сегодня; но предусловие, которое задача
`retenshen-syrogo-arhiva` считала снятым, снова открыто, и это записывается в
её файл тем же изменением.
### 9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей
Действующее требование сформулировано через точки, потому что другого результата
у разбора не было. Три ветки надо назвать явно, иначе каждая решается
реализацией молча:
- **Тело оборвано после уже разобранной секции.** Разбор отдаёт ошибку и
**ни точек, ни сущностей**: иначе часть данных легла бы в витрину под
статусом, по которому доставку никто не подберёт.
- **Слой метрик не выводится, а в теле есть сущности.** Доставка целиком уходит
в `failed`, сущности не пишутся. Соблазн «сущностям слой не нужен, запишем
их» ломает то же «всё или ничего»: доставка получила бы `failed` при частично
записанной витрине, и повторная свёртка перестала бы быть no-op. Тело
остаётся в архиве, доставку вернёт пересборка. Цена названа: если такая
доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу, а
ретеншен `failed`-тела трогать не вправе.
- **Повтор ключа покрытой секции в одном `data`.** Секции **объединяются**, как
уже задано для `metrics`. Заодно чинится существующий дефект уровнем выше:
`decodeEnvelope` при повторе самого члена `data` результат второго члена
**присваивает**, а не добавляет, и имена первого глушатся общим `seen` — тело
с двумя `data` доезжает до `parsed` с молча потерянной секцией.
### 9б. Пределы на чужие строки
`id` приходит из тела и ничем не ограничен, а уезжает и в первичный ключ, и в
записи лога. Предел — 128 байт (UUID HealthKit — 36); сущность с более длинным
`id` пропускается тем же счётчиком, что и сущность без `id`. Правило то же, что
уже действует для имён непокрытых секций, и оно снимает класс, а не случай.
### 10. Миграция пересворачивает то, что стало покрытым
Спека хранения уже требует: «Задача, которая начинает разбирать секцию, тем же
изменением SHALL переводить `partial`-строки с этим ключом в `pending`».
Миграция `00007` переводит в `pending` доставки, у которых в
`uncovered_sections` встречается `workouts` или `stateOfMind`. Дальше их
подберёт обычный проход фонового воркера — отдельного кода для этого не
существует.
Перевод точечный, а не «все `partial`»: список — снимок покрытия, и доставка с
непокрытой `ecg` пересворачивать нечего.
## Risks / Trade-offs
- **Приехала версия без маршрута, а поля при этом переименовались** → правило
пункта 5 удержит сохранённую версию навсегда, и новых полей витрина не
увидит. → Счётчик и `WARN` с `id` сущности; тело в архиве, `reindex` применит
исправленное правило. Событие не наблюдалось ни разу.
- **Сокращение внутри элемента ряда правилом не ловится.** Длина
верхнеуровневых массивов сравнивается, а точка маршрута, потерявшая
`altitude`, — нет. → Названо вслух; ловится только сверкой с телом в архиве.
- **Маршрут удлиняет транзакцию свёртки.** Верхняя граница задаётся не примером,
а пределом тела приёма: 64 МиБ распакованного тела из одних тренировок дают
десятки мегабайт содержимого в одной транзакции, а задача беклога «Цена
слияния на широкой доставке» уже описывает, как 63 МБ на одной координате
держат транзакцию дольше `busy_timeout`. → Хеш-детектор снимает 41 запись из
44 на нашем корпусе, а сравнение начинается с узкого `SELECT content_hash`,
без чтения и разжатия блоба. Отдельной задачи не заводим: случай выражается
той же беклоговой задачей, что и точки.
- **Канонизация маршрута разворачивает его в дерево `any`.** `canon.Form`
материализует значение целиком — та самая форма, от которой отказался разбор
тела (197 МиБ кучи против 54 МиБ на теле 42 МиБ). → Хеш приехавшей сущности
считается **один раз на доставку**, до входа в транзакцию, а не на каждой из
пяти попыток повтора при занятости базы.
- **`import` родного экспорта Apple не даст `id` тренировки.** В `export.xml`
элемент `Workout` идентификатора не несёт — `dogsheep/healthkit-to-sqlite`
поэтому адресует тренировку **хешем содержимого** (`hash_id="id"` в
sqlite-utils). Значит импорт снапшота задвоит тренировки, приехавшие от HAE, —
ровно та же дыра, что у точек, где её закрыли ключом `start + end`. → Предел
назван здесь и заводится задачей беклога; сегодня импорта нет, и решать это
до его формы значило бы угадывать.
- **`tz_offset` у записей состояния разума всегда ноль** — не потеря наша, а
форма источника. → Названо в `docs/database.md`, чтобы клиент не считал по
нему местные сутки.
- **Фикстуры собираются из живого архива.** Тренировка несёт координаты
маршрута, запись состояния разума — эмоциональные метки; и то и другое
чувствительнее токенов. → Скрипт `tmp/research/fixtures.py` расширяется:
вычищаются числа (включая широту и долготу), UUID, метки RFC 3339 и словарные
значения `stateOfMind`; сохраняются форма литерала, структура и порядок
ключей. Проверка «в индексе нет данных о здоровье» остаётся за гейтом.
## Migration Plan
Миграция `00007_workout_record.sql`:
1. `CREATE TABLE workout` и `CREATE TABLE record` с индексами по времени.
2. `UPDATE delivery SET parse_status = 'pending'` для строк, чей
`uncovered_sections` содержит `workouts` или `stateOfMind`.
Откат (`Down`) снимает таблицы; восстановление содержимого — обычная пересборка
из архива, витрина производна по построению. Строки, переведённые в `pending`,
`Down` обратно не возвращает: какими они были, восстановить неоткуда, а
`pending` консервативен — ретеншен его не трогает.
Живой сервис миграцию переживает: новые таблицы никого не блокируют, `UPDATE`
идёт по 118 строкам.
@@ -0,0 +1,80 @@
## Why
Половина живого потока разбором не покрыта. Замер по 118 доставкам архива:
65 несут `metrics`, 27 — `workouts`, 26 — `stateOfMind`. Тренировки и состояние
разума сохраняются в архив и числятся `partial`, но в витрину не попадают:
трекеру (второй потребитель паспорта) взять тренировку неоткуда, агенту-медику
состояние разума — тоже.
Для `stateOfMind` это дороже, чем для метрик: в родном экспорте Apple секции нет
ни одним типом (находка 46), то есть доставки HAE — её **единственный** источник,
и ретеншен архива без разобранной секции нельзя включать вовсе.
## What Changes
- Разбор покрывает две новые секции тела: `workouts` и `stateOfMind`. Остальные
секции с собственными `id` (`ecg`, `symptoms`, `cycleTracking`, `medications`,
`heartRateNotifications`) остаются непокрытыми намеренно — живьём поток их не
приносил ни разу, и модель под них закладывается, а разбор — нет.
- Две новые таблицы: `workout` (заголовок колонками, всё остальное, включая
маршрут и внутренние ряды, — `payload` дословно) и `record` (секции с
собственным `id`, ключ `kind + id`).
- Сущность с собственным `id` **заменяется целиком**, а не сливается по полям.
Правило замены названо явно: приехавшая версия побеждает, если не теряет
содержания сохранённой; иначе сохранённая остаётся, факт считается и идёт в
`WARN`. При равных наборах полей выигрывает версия из более поздней доставки
**журнала**, а не свёрнутая последней, — иначе живая витрина расходилась бы с
пересборкой молча.
- Сущность несёт провенанс — доставку своей версии и её метку приёма.
- Разбор метки времени принимает второй формат — RFC 3339 в UTC, которым HAE шлёт
`stateOfMind` (находка 16).
- Отпечаток витрины (оракул сходимости `reindex`) покрывает тренировки и записи,
а не одни часовые объекты, и снимается одним снимком базы. Отчёт `reindex`
считает «до и после» по каждой единице хранения и называет «покрыта новая
секция» ожидаемым классом расхождения.
- «Отказ разбора — всё или ничего» распространяется на сущности явно, включая
ветку невыводимого слоя и повтор ключа секции.
- Миграция переводит в `pending` доставки, у которых в списке непокрытых секций
стоят ставшие покрытыми имена, — правило «покрыли секцию — пересверните» уже
записано в спеке хранения.
- **Не входит:** отдача тренировок и записей наружу. Read API в проекте пока нет
вовсе; его форма (конверт ответа, выбор слоя, предел размера) проектируется
задачей `read-api-tochki`, и вводить два эндпоинта раньше конверта значило бы
задать контракт мимоходом.
## Capabilities
### New Capabilities
Новых нет: тренировка и запись — это то же хранилище и тот же разбор, только
другая единица хранения. Отдельная capability создала бы второй словарь для того
же домена.
### Modified Capabilities
- `parsing`: покрытых секций становится три вместо одной; появляется разбор
сущностей с собственным `id` и второй формат метки времени (RFC 3339),
оставленный предыдущей дельтой явно ненормированным; «всё или ничего»
распространяется на сущности.
- `storage`: появляется вторая единица хранения — сущность с собственным `id`, со
своим правилом замены версии; отпечаток витрины перестаёт быть отпечатком одних
часовых объектов; запрет на данные о здоровье в логах распространяется на
содержимое сущностей.
- `reindex`: счётчики отчёта покрывают все единицы хранения, а «покрыта новая
секция» становится названным классом ожидаемого расхождения.
## Impact
- `internal/hae` — разбор двух секций, второй формат метки, новые счётчики
пропусков.
- `internal/store` — таблицы `workout` и `record`, слияние доставки одной
транзакцией вместе с точками, отпечаток витрины.
- `internal/fold` — счётчики и единственный логирующий чекпоинт свёртки.
- `internal/replay` — ничего, кроме того, что отпечаток стал шире: пересборка
зовёт ту же свёртку.
- Миграция `00007` — две таблицы и перевод `partial`-доставок в `pending`.
- `docs/database.md`, `docs/architecture.md`, `docs/local-research.md` — схема,
правило замены версии и находка о поведении тренировки при переприсылке.
- Фикстуры `internal/hae/testdata` и скрипт их сборки `tmp/research/fixtures.py`:
тренировка с маршрутом и состояние разума — на реальных пакетах с вычищенными
измерениями.
@@ -0,0 +1,324 @@
## 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** содержимое непокрытой секции в результат разбора не попадает
@@ -0,0 +1,133 @@
## MODIFIED Requirements
### Requirement: Отчёт, оракул и исход команды
Система SHALL завершать пересборку отчётом, который несёт счётчики
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
отличит появление двадцати семи тренировок от пропажи двух.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
дефект там, где его нет. Отдельно называть человеку следует только нештатные
отказы.
Отложенная доставка (занятость базы, отмена работы снаружи) SHALL считаться
нештатной **для пересборки**, хотя для фоновой свёртки она штатна: пересборка
идёт в свежий файл при единственном писателе, и такая доставка в собранной
витрине просто отсутствует — вместе с теми, кто наследовал от неё слой. Классы
при этом общие с фоновой свёрткой: второй классификатор разошёлся бы с первым
молча.
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
поймала прошлый дефект наследования слоя.
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
сломанном правиле.
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
Класс «покрыта новая секция» назван потому, что первый прогон после такого
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
оракул ровно тогда, когда по нему принимается необратимое решение.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
выводится, — штатный исход.
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
молчит минутами, и зависший неотличим от идущего.
#### Scenario: Отчёт сравнивает отпечатки
- **WHEN** пересборка завершилась
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Счётчики покрывают все единицы хранения
- **WHEN** пересборка завершилась
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
тренировок и записей
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
хотя бы одна доставка свёрнута
- **THEN** команда завершается успешно, а расхождение названо в отчёте
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
- **WHEN** в архиве не нашлось ни одного тела
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Ни одна доставка не свернулась
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Приезд доставок за время прогона отменяет подмену
- **WHEN** число доставок в рабочей базе за время прогона изменилось
- **THEN** отчёт называет разницу
- **AND** процедуры подмены не печатает
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
- **WHEN** прогон отменён
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
числа доставок
#### Scenario: Рабочей базы нет вовсе
- **WHEN** файла рабочей базы не существует
- **THEN** пересборка идёт по одним подобранным телам
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
восстанавливаются
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
@@ -0,0 +1,320 @@
## ADDED Requirements
### Requirement: Хранение сущностей с собственным идентификатором
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
часовыми объектами, а по одной строке на сущность: у них есть естественный
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
разума), и группировать их по часам незачем.
Единиц хранения две:
```
тренировка ключ id
заголовок колонками: имя, начало, конец, офсет зоны, длительность
запись ключ род секции + id
заголовок колонками: род, метка времени, офсет зоны
```
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
телом в архиве.
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
бы «источник не прислал» от «измерено ноль».
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
идентификатора остальных пяти секций не наблюдалась никем, и короткий
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
стоит ноль: запросы к записям всегда идут с родом.
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
существует ради выборки по времени и не является разбором содержимого: любая
следующая колонка была бы решением за Apple о том, что в тренировке главное.
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
за двое суток и основанием для второй транзакции не является.
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные
копии дают три различных содержимых.
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на
доставку, а не на каждой попытке повтора транзакции при занятости базы:
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
#### Scenario: Тренировка хранится одной строкой с маршрутом
- **WHEN** приезжает тренировка с маршрутом
- **THEN** она хранится одной строкой, адресуемой своим `id`
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
#### Scenario: Ряд пульса тренировки не попадает в метрику
- **WHEN** тренировка несёт `heartRateData`
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
- **THEN** в хранилище лежат обе
#### Scenario: Повторная присылка той же тренировки не пишет в базу
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым
- **THEN** хеш совпадает и запись не выполняется
#### Scenario: Отказ посреди доставки не оставляет части сущностей
- **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни одна сущность этой доставки
### Requirement: Замена версии сущности не теряет содержания
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
`basalEnergy`.
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
содержания** сохранённой. Порядок разбора:
```
1. хеш канонического содержимого совпал → записи нет
2. содержание приехавшей покрывает сохранённую
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо, наборы равны → версия из более поздней
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**Содержание сравнивается множеством ключей с непустым значением — и только им.**
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение
включения, когда значения общих содержательных ключей разошлись, а у сущности
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при
неизменных значениях и «равенство» при изменившихся, то есть на живых данных
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
законный признак «приехало меньше». Предел правила называется вслух: сокращение
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
сверки с телом в архиве.
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
необратимым.
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
только среди видимых ему доставок и абсолютного порядка при конкурентных
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
а не от того, кто раньше добрался до базы.
Отличие от точки здесь содержательное: у точки на одних координатах законно
встречаются два разных измерения, и предпочитать позднее нет оснований — там
исход решает порядок канонических форм. У сущности `id` — идентичность одного
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией.
Две версии одного ключа **внутри одной доставки** позициями не различаются и
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
наборов. Внутри доставки «сохранённой» версии не существует, есть только
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
приехали две версии одного ключа с разным содержанием» SHALL считаться
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
через раз.
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
точек: на живом потоке событие не наступало, и вместо реализации заведено
наблюдение.
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5
сопровождается счётчиком и `WARN`.
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
- **WHEN** та же тренировка приезжает повторно, добавив `route`
- **THEN** в хранилище лежит версия с маршрутом
#### Scenario: Досчитанные значения при том же наборе полей побеждают
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
изменившимися значениями, доставкой с более поздней позицией журнала
- **THEN** в хранилище лежит приехавшая версия
#### Scenario: Версия из более ранней доставки не откатывает витрину
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
свёрнута сперва более поздняя по журналу, затем более ранняя
- **THEN** в хранилище лежит версия из более поздней доставки
- **AND** тот же исход даёт свёртка в обратном порядке
#### Scenario: Обеднённая версия сохранённую не затирает
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
сохранённой, **и** с изменившимися значениями общих полей
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
тренировки
#### Scenario: Усечённый маршрут сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
`route` короче сохранённого
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Две версии одной сущности в одном теле
- **WHEN** тело содержит два элемента секции с одним `id`
- **THEN** исход не зависит от их порядка в массиве
- **AND** счётчик различающихся версий тоже не зависит от их порядка
#### Scenario: Составной ключ не даёт коллизии отпечатка
- **WHEN** две витрины различаются только тем, где проходит граница между родом
и идентификатором записи
- **THEN** отпечатки не совпадают
#### Scenario: Несравнимые наборы полей не объединяются
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
сохранённой, и теряет содержательный ключ, который у сохранённой есть
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Повторная свёртка того же журнала состояния не меняет
- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** содержимое сущностей не меняется
### Requirement: Отпечаток витрины покрывает все её сущности
Отпечаток витрины SHALL включать тренировки и записи наравне с часовыми
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
ломался бы молча ровно тем изменением, которое добавляет данные.
В отпечаток идут координаты сущности и хеш её содержимого. Значений он
раскрывать MUST NOT — как и для точек.
Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
строк в файле базы. Строки разных разделов SHALL различаться константным
признаком раздела: без него строка одного раздела может совпасть со строкой
другого, и два разных состояния дали бы один отпечаток. По той же причине
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).
Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
длительность) — нет. Они производны от содержимого, поэтому их расхождение
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
«отпечатки совпали» не является утверждением о них.
Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
оракула сходимости.
#### Scenario: Расхождение тренировок видно в отпечатке
- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
тренировки различается
- **THEN** отпечатки не совпадают
#### Scenario: Отпечаток одинаков при одинаковом содержимом
- **WHEN** та же витрина собрана повторно из того же журнала
- **THEN** отпечаток совпадает
#### Scenario: Запись во время снятия отпечатка не смешивает разделы
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков
## MODIFIED Requirements
### Requirement: Значения точек не попадают в логи
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
идентификатор и род сущности, метка времени, идентификатор доставки — они
описывают, что случилось, а не что измерено.
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов. То же относится к идентификатору сущности: он приходит
из чужого тела и ограничен по длине при разборе.
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
с именем длиннее предела на HAE не похоже вовсе.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
#### Scenario: Удержанная обеднённая версия логируется координатами
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
- **THEN** запись `WARN` содержит идентификатор и род сущности
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
#### Scenario: Непокрытые секции названы именами ключей
- **WHEN** доставка содержит непокрытую секцию
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
- **AND** не содержит ничего из содержимого этих секций
- **AND** уровень записи из-за одной лишь частичности не повышается
#### Scenario: Границы списка сработали
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит число отброшенных имён
@@ -0,0 +1,126 @@
## 1. Фикстуры на реальных пакетах
- [x] 1.1 Расширить `tmp/research/fixtures.py`: вычистка UUID (`id`), меток
RFC 3339, `route[].timestamp` и словарных значений `stateOfMind`
(`kind`, `valenceClassification`, `labels`, `associations`) при
сохранении формы литерала, структуры и порядка ключей
- [x] 1.2 Собрать `internal/hae/testdata/workout_route.json` — уличная
тренировка с маршрутом (проверяет дословность маршрута и внутренних рядов)
- [x] 1.3 Собрать `internal/hae/testdata/workout_indoor.json` — тренировка без
маршрута, с полями, которых нет у уличной (`temperature`, `humidity`,
`intensity`)
- [x] 1.4 Собрать `internal/hae/testdata/state_of_mind.json` — состояние разума
(RFC 3339, отсутствие `source`)
- [x] 1.5 Дописать в `handmade_edge.json` случаи, которых поток не даёт:
сущность без `id`, с пустым и со слишком длинным `id`, с неразбираемой
меткой, с неразбираемым `end`, с нечисловой длительностью, два элемента с
одним `id` в одном теле
- [x] 1.6 Обновить `internal/hae/testdata/README.md`: новые файлы и что именно
вычищено
## 2. Разбор (`internal/hae`)
- [x] 2.1 Множество покрытых секций: `metrics`, `workouts`, `stateOfMind`
одно объявление на разбор и на перечисление непокрытых
- [x] 2.2 Типы `Workout` и `Record` в `Result`; счётчики `SkippedNoID`,
`SkippedNoTime` для сущностей
- [x] 2.3 `parseEntityTime`: формат HAE, затем RFC 3339; офсет из разобранной
зоны. Парсер точек остаётся строгим — причина записана в спеке
- [x] 2.4 Разбор `workouts`: заголовок (`id`, `name`, `start`, `end`,
`duration`), содержимое — исходные байты элемента; `end` нечитаем →
равен началу; длительность отсутствует → не заполнена (не ноль)
- [x] 2.5 Разбор `stateOfMind` в записи рода `stateOfMind` (имя секции дословно)
- [x] 2.6 `Parse` отдаёт сущности и при отсутствии секции `metrics`; при
ошибке (обрыв тела, невыводимый слой) не отдаёт ни точек, ни сущностей
- [x] 2.7 Повтор ключа покрытой секции даёт объединение; повтор члена `data`
**накапливает** результаты, а не присваивает последний (существующий
дефект `decodeEnvelope`)
- [x] 2.8 Предел длины `id`: сущность сверх него пропускается тем же счётчиком
- [x] 2.9 Тесты на фикстурах: маршрут дословно, пульс тренировки не стал
метрикой, оба формата времени, пропуски со счётчиками
- [x] 2.10 Тест: доставка из одних тренировок и из одного `stateOfMind`
непокрытых ключей не даёт; из одного `ecg` — даёт
## 3. Схема (`internal/store/migrations`)
- [x] 3.1 Миграция `00007_workout_record.sql`: таблицы `workout` и `record`
(провенанс `delivery_id` + `delivery_received_at`, `content_hash`,
nullable `duration_sec`), индексы по времени, перевод `partial`-доставок
с ключами `workouts` и `stateOfMind` в `pending`
- [x] 3.2 Проверить, что перевод в `pending` отбирает строки точно (по элементу
JSON-массива, а не по подстроке тела)
- [x] 3.3 Обновить `docs/database.md`: обе таблицы, смысл колонок, ключ
`род + id`, провенанс, `NULL` у длительности, офсет `0` у `stateOfMind`
## 4. Хранение (`internal/store`, `internal/canon`)
- [x] 4.1 `canon`: сравнение множеств содержательных ключей **без** условия о
совпадении значений (поверх существующего `relateKeys`) плюс сравнение
длин верхнеуровневых массивов
- [x] 4.2 `Incoming{Points, Workouts, Records}` и `Merge` вместо `MergePoints`:
одна транзакция на доставку
- [x] 4.3 Правило замены версии: хеш-детектор без чтения блоба, условие «не
теряет содержания», тай-брейк по позиции журнала `(received_at, id)`,
внутридоставочный тай-брейк по канонической форме, счётчик и координаты
для `WARN`
- [x] 4.4 Чтение и запись тренировки и записи; содержимое — сжатый блоб; хеш
приехавшей считается один раз на доставку, до входа в транзакцию
- [x] 4.5 `Fingerprint` покрывает тренировки и записи, читает одной read-only
транзакцией, строки разделов различаются константным признаком
- [x] 4.6 Тесты: замещение маршрутом; досчёт при том же наборе полей; обеднённая
версия **с изменившимися значениями** не затирает; усечённый маршрут не
затирает; несравнимые наборы; повтор не пишет; две версии в одном теле;
перестановка **трёх** версий в двух порядках подачи (конвенция
`docs/conventions.md`)
- [x] 4.7 Тест: отпечаток расходится при расхождении одной тренировки
## 5. Свёртка и пересборка (`internal/fold`, `internal/replay`, `cmd`)
- [x] 5.1 `Stats` несёт счётчики сущностей; свёртка зовёт `Merge` один раз;
счётчики доезжают до лога без ручного копирования (или это покрыто тестом)
- [x] 5.2 Единственный логирующий чекпоинт: атрибуты сущностей, ветка `WARN`
для удержанной обеднённой версии — координатами, без содержимого
- [x] 5.3 Отчёт `reindex`: «до и после» по тренировкам и записям, «покрыта новая
секция» в перечне ожидаемых классов расхождения
- [x] 5.4 Тест: содержимое сущности в лог не попадает
## 6. Сходимость и проверка на живом архиве
- [x] 6.1 `task gate` — зелёный
- [x] 6.2 `task verify:archive` — прогон всего `./data/raw`, повтор даёт то же
состояние; он же оракул того, что живой приём и пересборка применяют к
сущностям один порядок
- [x] 6.3 Проверка на копии рабочей базы в отдельном каталоге данных: миграция
накатывается, `partial`-доставки пересворачиваются, тренировки и записи
появляются
## 7. Документация и беклог
- [x] 7.1 `docs/architecture.md` — раздел «Тренировки и прочие секции»: правило
замены версии с обоснованием, отвергнутые варианты, предел `import`;
**плюс блок схемы БД** (`record(kind, id)`, сжатый блоб, `content_hash`,
провенанс)
- [x] 7.2 `docs/conventions.md` — строка про ключ `record` устарела, поправить
- [x] 7.3 `docs/local-research.md` — находка о поведении тренировки при
переприсылке (числа замера) и пересчёт находки 50 на 118 доставок
- [x] 7.4 Беклог: задача про идентичность тренировок при импорте родного
экспорта (в `export.xml` `id` нет — `dogsheep` считает hash_id);
`retenshen-syrogo-arhiva` — предусловие про `stateOfMind` снова открыто;
отметить в `read-api-tochki`, что отдача тренировок и записей входит в
неё; уточнить `proverka-novyh-sekcij` — модель заложена, остались пять
секций
- [x] 7.5 Удалить `docs/backlog/trenirovki-i-zapisi.md` и строку индекса
## 8. Приёмочные критерии (рубрика ревью)
- [x] 8.1 Тренировка с маршрутом переживает круг «разбор → хранение → чтение»
побайтово
- [x] 8.2 Повторная свёртка того же журнала не меняет отпечатка витрины
- [x] 8.3 Ни один путь не пишет содержимое сущности в лог выше `DEBUG`
- [x] 8.4 Доставка из одних тренировок получает `parsed`, а не `partial`
- [x] 8.5 Доставка с непокрытой секцией по-прежнему `partial`, и её тело
ретеншену трогать нельзя
- [x] 8.6 Исход правила замены не зависит от порядка свёртки в пунктах 2–4
правила; зависимость в пункте 5 наблюдаема счётчиком
- [x] 8.7 У каждого класса пропуска при разборе сущности — свой счётчик, и
пропуск одного элемента не уносит соседей
+239 -23
View File
@@ -159,9 +159,29 @@ Read API «самый мелкий слой, покрывающий диапаз
### Requirement: Разбор форматов времени
Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и
приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics`
других форматов меток не встречается.
Система 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 преобразовывать
@@ -169,16 +189,24 @@ Unix-эпоха дробным числом (`1785446196.4132624`) встреч
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
`heart_rate_variability`.
RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается
только в `data.stateOfMind`, которая выведена из scope. Требование к нему
появится вместе с задачей про секции с собственными `id` — вместе с данными,
на которых его можно проверить.
Время внутри маршрута тренировки (`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`
@@ -186,6 +214,12 @@ RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируе
- **AND** серия не разворачивается в отдельные точки
- **AND** эпоха внутри серии не разбирается и не преобразуется
#### Scenario: Время внутри маршрута не разбирается
- **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки
- **THEN** точки маршрута сохраняются исходными байтами
- **AND** их метки не разбираются и не преобразуются
### Requirement: Разделение схем под одним именем метрики
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
@@ -270,32 +304,38 @@ Export шлёт под одним именем, чтобы одно имя оз
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление
MUST ходить по одному объявленному множеству покрытых имён: состояние «секция
разбирается, но числится непокрытой» невыразимо по построению.
Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и
перечисление MUST ходить по одному объявленному множеству покрытых имён:
состояние «секция разбирается, но числится непокрытой» невыразимо по построению.
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок).
Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
доставками.
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99).
оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).
#### Scenario: Незнакомая секция попадает в список непокрытых
- **WHEN** тело содержит `data.workouts` наряду с `data.metrics`
- **THEN** разбор возвращает `workouts` в списке непокрытых ключей
- **WHEN** тело содержит `data.ecg` наряду с `data.metrics`
- **THEN** разбор возвращает `ecg` в списке непокрытых ключей
- **AND** точки секции `metrics` разбираются как обычно
#### Scenario: Доставка без метрик разбирается и не теряется
#### Scenario: Доставка из одних тренировок непокрытых ключей не даёт
- **WHEN** тело содержит только `data.workouts`
- **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны
- **AND** список непокрытых ключей пуст
#### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт
- **WHEN** тело содержит только `data.stateOfMind`
- **THEN** разбор завершается без ошибки, точек нет
- **AND** `stateOfMind` возвращается в списке непокрытых ключей
- **THEN** разбор завершается без ошибки, записи разобраны
- **AND** список непокрытых ключей пуст
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
@@ -345,15 +385,26 @@ JSON от HAE нестабилен, а значение уезжает в баз
### Requirement: Отказ разбора остаётся всё или ничего
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять точки в результате — доставка считается
неразобранной целиком.
**после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей —
доставка считается неразобранной целиком.
Иначе часть точек оказалась бы в витрине под статусом, по которому доставку
Иначе часть данных оказалась бы в витрине под статусом, по которому доставку
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а
не победу последней: молча терять точки нельзя.
Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть
метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя
у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же
«всё или ничего» — доставка получила бы `failed` при частично записанной
витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если
такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу,
а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать
нельзя.
Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение
секций, а не победу последней: молча терять данные нельзя. То же SHALL
относиться к повтору самого члена `data` в теле — результаты **накапливаются**,
включая список непокрытых ключей.
#### Scenario: Тело оборвано после секции метрик
@@ -361,8 +412,173 @@ JSON от HAE нестабилен, а значение уезжает в баз
оборван
- **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 браться из тела, а не вычисляться из начала и конца: 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** сущностей из неё разбор не отдаёт
+20 -2
View File
@@ -309,6 +309,12 @@
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
отличит появление двадцати семи тренировок от пропажи двух.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
@@ -342,8 +348,14 @@
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
отдельно от самого факта расхождения.
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
Класс «покрыта новая секция» назван потому, что первый прогон после такого
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
оракул ровно тогда, когда по нему принимается необратимое решение.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
@@ -373,6 +385,12 @@ SHALL идти в поток ошибок, а не смешиваться с о
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Счётчики покрывают все единицы хранения
- **WHEN** пересборка завершилась
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
тренировок и записей
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
+285 -4
View File
@@ -301,26 +301,39 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
### Requirement: Значения точек не попадают в логи
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек и тела доставок в записи лога уровня выше `DEBUG`.
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
идентификатор и род сущности, метка времени, идентификатор доставки — они
описывают, что случилось, а не что измерено.
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов.
построчный разбор логов. То же относится к идентификатору сущности: он приходит
из чужого тела и ограничен по длине при разборе.
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень.
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
с именем длиннее предела на HAE не похоже вовсе.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
#### Scenario: Удержанная обеднённая версия логируется координатами
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
- **THEN** запись `WARN` содержит идентификатор и род сущности
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
#### Scenario: Непокрытые секции названы именами ключей
- **WHEN** доставка содержит непокрытую секцию
@@ -427,3 +440,271 @@ Apple его нет (находка 46). Поэтому список MUST сох
- **THEN** `parse_status` становится `parsed`
- **AND** сохранённый список непокрытых ключей пуст
### Requirement: Хранение сущностей с собственным идентификатором
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
часовыми объектами, а по одной строке на сущность: у них есть естественный
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
разума), и группировать их по часам незачем.
Единиц хранения две:
```
тренировка ключ id
заголовок колонками: имя, начало, конец, офсет зоны, длительность
запись ключ род секции + id
заголовок колонками: род, метка времени, офсет зоны
```
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
телом в архиве.
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
бы «источник не прислал» от «измерено ноль».
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
идентификатора остальных пяти секций не наблюдалась никем, и короткий
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
стоит ноль: запросы к записям всегда идут с родом.
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
существует ради выборки по времени и не является разбором содержимого: любая
следующая колонка была бы решением за Apple о том, что в тренировке главное.
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
за двое суток и основанием для второй транзакции не является.
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные
копии дают три различных содержимых.
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на
доставку, а не на каждой попытке повтора транзакции при занятости базы:
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
#### Scenario: Тренировка хранится одной строкой с маршрутом
- **WHEN** приезжает тренировка с маршрутом
- **THEN** она хранится одной строкой, адресуемой своим `id`
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
#### Scenario: Ряд пульса тренировки не попадает в метрику
- **WHEN** тренировка несёт `heartRateData`
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
- **THEN** в хранилище лежат обе
#### Scenario: Повторная присылка той же тренировки не пишет в базу
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым
- **THEN** хеш совпадает и запись не выполняется
#### Scenario: Отказ посреди доставки не оставляет части сущностей
- **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни одна сущность этой доставки
### Requirement: Замена версии сущности не теряет содержания
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
`basalEnergy`.
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
содержания** сохранённой. Порядок разбора:
```
1. хеш канонического содержимого совпал → записи нет
2. содержание приехавшей покрывает сохранённую
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо, наборы равны → версия из более поздней
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**Содержание сравнивается множеством ключей с непустым значением — и только им.**
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение
включения, когда значения общих содержательных ключей разошлись, а у сущности
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при
неизменных значениях и «равенство» при изменившихся, то есть на живых данных
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
законный признак «приехало меньше». Предел правила называется вслух: сокращение
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
сверки с телом в архиве.
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
необратимым.
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
только среди видимых ему доставок и абсолютного порядка при конкурентных
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
а не от того, кто раньше добрался до базы.
Отличие от точки здесь содержательное: у точки на одних координатах законно
встречаются два разных измерения, и предпочитать позднее нет оснований — там
исход решает порядок канонических форм. У сущности `id` — идентичность одного
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией.
Две версии одного ключа **внутри одной доставки** позициями не различаются и
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
наборов. Внутри доставки «сохранённой» версии не существует, есть только
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
приехали две версии одного ключа с разным содержанием» SHALL считаться
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
через раз.
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
точек: на живом потоке событие не наступало, и вместо реализации заведено
наблюдение.
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5
сопровождается счётчиком и `WARN`.
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
- **WHEN** та же тренировка приезжает повторно, добавив `route`
- **THEN** в хранилище лежит версия с маршрутом
#### Scenario: Досчитанные значения при том же наборе полей побеждают
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
изменившимися значениями, доставкой с более поздней позицией журнала
- **THEN** в хранилище лежит приехавшая версия
#### Scenario: Версия из более ранней доставки не откатывает витрину
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
свёрнута сперва более поздняя по журналу, затем более ранняя
- **THEN** в хранилище лежит версия из более поздней доставки
- **AND** тот же исход даёт свёртка в обратном порядке
#### Scenario: Обеднённая версия сохранённую не затирает
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
сохранённой, **и** с изменившимися значениями общих полей
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
тренировки
#### Scenario: Усечённый маршрут сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
`route` короче сохранённого
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Две версии одной сущности в одном теле
- **WHEN** тело содержит два элемента секции с одним `id`
- **THEN** исход не зависит от их порядка в массиве
- **AND** счётчик различающихся версий тоже не зависит от их порядка
#### Scenario: Составной ключ не даёт коллизии отпечатка
- **WHEN** две витрины различаются только тем, где проходит граница между родом
и идентификатором записи
- **THEN** отпечатки не совпадают
#### Scenario: Несравнимые наборы полей не объединяются
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
сохранённой, и теряет содержательный ключ, который у сохранённой есть
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Повторная свёртка того же журнала состояния не меняет
- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** содержимое сущностей не меняется
### Requirement: Отпечаток витрины покрывает все её сущности
Отпечаток витрины SHALL включать тренировки и записи наравне с часовыми
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
ломался бы молча ровно тем изменением, которое добавляет данные.
В отпечаток идут координаты сущности и хеш её содержимого. Значений он
раскрывать MUST NOT — как и для точек.
Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
строк в файле базы. Строки разных разделов SHALL различаться константным
признаком раздела: без него строка одного раздела может совпасть со строкой
другого, и два разных состояния дали бы один отпечаток. По той же причине
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).
Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
длительность) — нет. Они производны от содержимого, поэтому их расхождение
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
«отпечатки совпали» не является утверждением о них.
Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
оракула сходимости.
#### Scenario: Расхождение тренировок видно в отпечатке
- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
тренировки различается
- **THEN** отпечатки не совпадают
#### Scenario: Отпечаток одинаков при одинаковом содержимом
- **WHEN** та же витрина собрана повторно из того же журнала
- **THEN** отпечаток совпадает
#### Scenario: Запись во время снятия отпечатка не смешивает разделы
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков