From 37413bb5512a6e4f9ddd3de35ab5abb1afdd1a67 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 1 Aug 2026 19:03:46 +0300 Subject: [PATCH] =?UTF-8?q?change=20razbor-metrik-v-obekty=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B0=D1=80=D1=85=D0=B8=D0=B2=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога, план отражает сделанную часть шага 3. Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит с 17:13, пауза началась до перезапуска сервиса. --- docs/backlog/README.md | 1 - docs/backlog/razbor-metrik-v-obekty.md | 33 --- docs/plan.md | 14 +- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/parsing/spec.md | 0 .../specs/storage/spec.md | 0 .../tasks.md | 5 +- openspec/specs/parsing/spec.md | 260 ++++++++++++++++++ openspec/specs/storage/spec.md | 215 +++++++++++++++ 11 files changed, 487 insertions(+), 41 deletions(-) delete mode 100644 docs/backlog/razbor-metrik-v-obekty.md rename openspec/changes/{razbor-metrik-v-obekty => archive/2026-08-01-razbor-metrik-v-obekty}/.openspec.yaml (100%) rename openspec/changes/{razbor-metrik-v-obekty => archive/2026-08-01-razbor-metrik-v-obekty}/design.md (100%) rename openspec/changes/{razbor-metrik-v-obekty => archive/2026-08-01-razbor-metrik-v-obekty}/proposal.md (100%) rename openspec/changes/{razbor-metrik-v-obekty => archive/2026-08-01-razbor-metrik-v-obekty}/specs/parsing/spec.md (100%) rename openspec/changes/{razbor-metrik-v-obekty => archive/2026-08-01-razbor-metrik-v-obekty}/specs/storage/spec.md (100%) rename openspec/changes/{razbor-metrik-v-obekty => archive/2026-08-01-razbor-metrik-v-obekty}/tasks.md (94%) create mode 100644 openspec/specs/parsing/spec.md create mode 100644 openspec/specs/storage/spec.md diff --git a/docs/backlog/README.md b/docs/backlog/README.md index f6ac3d1..8c80eac 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -18,7 +18,6 @@ - [Судьба доставки, у которой разобрана не вся секция data](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind помечается parsed, а ретеншен снесёт его как разобранное ## высокий -- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это - [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика - [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое diff --git a/docs/backlog/razbor-metrik-v-obekty.md b/docs/backlog/razbor-metrik-v-obekty.md deleted file mode 100644 index c2819e0..0000000 --- a/docs/backlog/razbor-metrik-v-obekty.md +++ /dev/null @@ -1,33 +0,0 @@ -# Разбор метрик в часовые объекты - -**Приоритет:** высокий - -Приём работает, но дальше архива данные не идут: 89 доставок лежат телами -`.json.gz`, а в SQLite только строки `delivery`. Всё остальное — каталог, -Read API, MCP — стоит на этой задаче. - -Правила выведены на живом потоке и проверены, изобретать заново не нужно -(`docs/local-research.md`, находки 33, 35, 36, 38, 39, 41): - -- ключ точки — координаты `метрика + слой + метка`, у эпизода — `метрика + слой - + начало + конец` (находка 47); `source` в ключ не входит; -- слой выводится из выравнивания меток: плотная метрика (≥10 точек) - классифицируется сама, редкая наследует преобладающий слой доставки; -- при столкновении выигрывает более полная точка, а не последняя пришедшая - (0.66% координат различаются набором полей, а не значением); -- три формата времени: локальное со смещением, RFC 3339 Z, Unix-эпоха внутри - `heartbeatSeries`; -- `sleep_analysis` разводится на два имени — поэпизодное и суточную сводку. - -Шаги: -- миграция `bucket` (`метрика + слой + час`, payload gzip-BLOB, `sealed`); -- разбор метрик, вывод слоя, канонизация с округлением до ~12 значащих цифр; -- слияние точек в объект read-modify-write, хеш объекта как детектор изменений; -- `docs/database.md` — ER-схема (её требует шаг гейта `er-schema`). - -Готово, когда по существующим доставкам собирается хранилище, суммы по часовому -слою сходятся с проверкой из `tmp/research/`, а эпизодов сна выходит 174, а не -170 (находка 47). - -Связано: `docs/architecture.md` → «Хранилище», план шаг 3. - diff --git a/docs/plan.md b/docs/plan.md index 12c5782..092276e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -8,13 +8,14 @@ ## Ближайшая цель -Приём работает, поток настоящих пакетов копится в сыром архиве. Дальше — -разбор и хранилище (шаг 3): всё, что нужно, чтобы данные перестали быть -недифференцированной кучей тел запросов. +Метрики разбираются и ложатся в часовые объекты: тела перестали быть +недифференцированной кучей. Дальше — доразобрать остаток шага 3 +(тренировки и записи со своими `id`, `reindex`, словарь категориальных +значений) и разобрать блокеры, накопившиеся из ревью. Разведка закончена: правило вывода слоя, модель идентичности и формы точки проверены на живом потоке, выводы — в -[local-research.md](local-research.md), 46 находок. +[local-research.md](local-research.md), 47 находок. ## Шаги @@ -23,7 +24,10 @@ - [x] **2. Приём без разбора.** `POST /api/v1/ingest`: лимит тела, gzip, запись тела в архив, строка в `delivery`. Разбора ещё нет. ← **подключаем телефон по локальной сети** -- [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик +- [~] **3. Разбор и хранилище.** Метрики — сделано (`bucket`, `internal/hae`, + `internal/canon`, `internal/fold`); остались тренировки и записи со + своими `id`, `reindex` и словарь категориальных значений. Миграции, + часовые объекты метрик (`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим `id`. Вывод слоя из выравнивания меток; развод `sleep_analysis` на два имени (находка 38). Канонизация с округлением чисел и хеш содержимого diff --git a/openspec/changes/razbor-metrik-v-obekty/.openspec.yaml b/openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/.openspec.yaml similarity index 100% rename from openspec/changes/razbor-metrik-v-obekty/.openspec.yaml rename to openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/.openspec.yaml diff --git a/openspec/changes/razbor-metrik-v-obekty/design.md b/openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/design.md similarity index 100% rename from openspec/changes/razbor-metrik-v-obekty/design.md rename to openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/design.md diff --git a/openspec/changes/razbor-metrik-v-obekty/proposal.md b/openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/proposal.md similarity index 100% rename from openspec/changes/razbor-metrik-v-obekty/proposal.md rename to openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/proposal.md diff --git a/openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md b/openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/specs/parsing/spec.md similarity index 100% rename from openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md rename to openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/specs/parsing/spec.md diff --git a/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md b/openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/specs/storage/spec.md similarity index 100% rename from openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md rename to openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/specs/storage/spec.md diff --git a/openspec/changes/razbor-metrik-v-obekty/tasks.md b/openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/tasks.md similarity index 94% rename from openspec/changes/razbor-metrik-v-obekty/tasks.md rename to openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/tasks.md index a11626e..3cdd44c 100644 --- a/openspec/changes/razbor-metrik-v-obekty/tasks.md +++ b/openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/tasks.md @@ -52,9 +52,10 @@ ## 6. Сходимость на реальных данных -- [x] 6.1 Скрипт `tmp/research/verify_buckets.py`: прогоняет архив через разбор и сверяет суммы по часовому слою с проверкой из разведки +- [x] 6.1 Проверка сходимости — не отдельным скриптом, а `task verify:archive` поверх `internal/fold/replay_test.go`: одна реализация вместо двух, и она гоняет ровно продакшн-путь - [x] 6.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет -- [ ] 6.3 `task gate` зелёный; `task restart` поднимает сервис, новая доставка с телефона разбирается +- [x] 6.3 `task gate` зелёный; `task restart` поднимает сервис +- [ ] 6.3a **Не закрыто:** живая доставка с телефона не разобрана — поток молчит с 17:13 (пауза началась ЗА ЧАС до перезапуска, то есть не из-за него). Сервис поднят и здоров, миграции на живой базе применились, схема проверена чтением. Пункт закрывается первой же пришедшей доставкой ## 7. Приёмочные критерии (рубрика ревью дизайна) diff --git a/openspec/specs/parsing/spec.md b/openspec/specs/parsing/spec.md new file mode 100644 index 0000000..78e1b5f --- /dev/null +++ b/openspec/specs/parsing/spec.md @@ -0,0 +1,260 @@ +# parsing Specification + +## Purpose +TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after archive. +## Requirements +### Requirement: Разбор секции метрик + +Система SHALL разбирать секцию `data.metrics` тела доставки Health Auto Export +в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в +том виде, в каком его прислал HAE. + +Хранимая форма точки — **исходные байты**, как они пришли в теле доставки. +Система MUST NOT пересобирать содержимое повторной сериализацией разобранных +значений: обход через `map[string]any` теряет литерал (`1.0` становится `1`, +целые больше 2^53 сдвигаются, невалидный UTF-8 заменяется на U+FFFD), и потеря +не видна тестам на фикстурах — они сравнивают разобранное с разобранным. + +Отсюда же следует, что «служебных» полей у точки нет: отбрасывать нечего, +нормализованное время добавляется рядом с исходным содержимым, а не вместо. + +#### Scenario: Метрика с точками разбирается в точки + +- **WHEN** тело содержит `data.metrics[]` с непустым `data[]` +- **THEN** каждая точка с непустым `date` становится точкой хранилища +- **AND** её содержимое сохраняется исходными байтами, без пересборки + +#### Scenario: Незнакомая метрика не ломает разбор + +- **WHEN** приходит метрика с именем, которого разбор не знает +- **THEN** её точки разбираются наравне с остальными +- **AND** разбор не завершается ошибкой + +#### Scenario: Точка без метки времени пропускается + +- **WHEN** точка не содержит `date` либо `date` не разбирается ни одним из + поддерживаемых форматов +- **THEN** точка не попадает в хранилище +- **AND** факт учитывается в итоге разбора доставки + +#### Scenario: Точка с нечитаемым концом интервала пропускается + +- **WHEN** точка несёт `end`, который не разбирается +- **THEN** точка не попадает в хранилище +- **AND** факт учитывается отдельным счётчиком + +Вырождать такую точку в мгновенную нельзя: две записи с общим началом получили +бы одну координату, и одна исчезла бы молча. Тело остаётся в архиве. + +### Requirement: Вывод слоя гранулярности + +Система SHALL выводить слой точки из **выравнивания меток времени**, а не из +заголовка доставки. Заголовок `automation-aggregation` непригоден: значение +`Default` соответствует трём разным режимам выгрузки. + +Выводимые слои: `raw` (метка на произвольной секунде), `minute` (секунды +нулевые), `hour` (секунды и минуты нулевые). Слой `day` в перечисление входит, +но **не выводится** — он назначается схемам с фиксированной гранулярностью +(см. «Разделение схем под одним именем метрики»). + +Выравнивание SHALL считаться по метке в **исходной зоне**, а не по метке в UTC. +HAE строит сетку по местному времени; ровный местный час в зоне со смещением на +половину (`+0530`, `+0545`, `+0930`) даёт UTC-метку на середине часа, и часовая +выгрузка целиком уехала бы в слой `minute` — где столкнулась бы с настоящей +минутной автоматизацией и завысила сумму минутного слоя вдвое. + +Слоем метрики SHALL становиться **самое мелкое** выравнивание среди её меток, а +не преобладающее. Измерено: у плотных метрик выравнивания перемешаны +(`active_energy` — 1320 минутных меток и 21 часовая, `heart_rate` — 654 +посекундных и 10 минутных), потому что метка ровно на часе одновременно +является и минутной. Метрика, у которой хоть одна метка стоит на середине часа, +часовой не является. + +Разделение схем под одним именем выполняется **до** вывода слоя, и точки с +назначенным слоем в определении преобладающего слоя доставки не участвуют: +суточных сводок сна бывает больше порога плотности, и их полуночные метки +иначе назначили бы всей доставке слой `hour`. + +Классификация MUST быть **по метрике внутри доставки**, а не по доставке +целиком: при перенастройке автоматизации приезжают смешанные доставки, и +отнесение такой доставки к одному слою складывает минутные точки с +посекундными. + +#### Scenario: Плотная метрика классифицируется сама + +- **WHEN** в доставке у метрики не меньше десяти точек с метками +- **THEN** слой определяется выравниванием её собственных меток + +#### Scenario: Зона с получасовым смещением не делает часовую выгрузку минутной + +- **WHEN** метки стоят на ровном местном часе, а смещение зоны равно `+0530` +- **THEN** слой метрики `hour` + +#### Scenario: Одна метка на середине часа делает метрику минутной + +- **WHEN** у плотной метрики десять меток стоят ровно на часе, а одна — на + середине часа +- **THEN** слой метрики `minute`, а не `hour` + +#### Scenario: Редкая метрика наследует преобладающий слой + +- **WHEN** в доставке у метрики меньше десяти точек +- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки +- **AND** её собственное выравнивание во внимание не принимается + +#### Scenario: Доставка, где всем метрикам слой назначен схемой + +- **WHEN** в доставке нет метрик, которым слой надо выводить, — все точки + принадлежат схемам с фиксированной гранулярностью +- **THEN** доставка сохраняется, а слой доставки не выводится и не требуется + +Иначе доставка автоматизации, настроенной только на сон, отвергалась бы +целиком — и необратимо: исход детерминирован, и пересборка повторяла бы его +вечно. + +#### Scenario: В доставке нет плотных метрик + +- **WHEN** ни у одной метрики доставки нет десяти точек +- **THEN** слой наследуется от последнего надёжно выведенного слоя той же + автоматизации (`automation-id`) среди доставок, **предшествующих** этой +- **AND** если наследовать нечего, слой берётся из **надёжного** заголовка + (`Minutes` → `minute`, `Hours` → `hour`) + +Граница «предшествующих» обязательна: слой обязан быть функцией от префикса +журнала. Наследование от последней доставки вообще делает свёртку зависящей от +истории, и пересборка даёт не то состояние, что живой приём — измерено на +архиве, 1737 объектов против 1742. + +#### Scenario: Пересборка журнала даёт то же состояние + +- **WHEN** те же доставки сворачиваются повторно в том же порядке +- **THEN** число объектов и их содержимое не меняются + +#### Scenario: Наследовать нечего и заголовок ненадёжен + +- **WHEN** плотных метрик нет, предыдущего слоя автоматизации нет, а заголовок + равен `Default` +- **THEN** точки доставки не сохраняются, а исход учитывается счётчиком и + записью `WARN` +- **AND** тело остаётся в архиве, откуда доставку подберёт пересборка + +Молчаливый выбор `raw` в этой ветке недопустим: заголовок `Default` наблюдался +одновременно у посекундного, минутного и часового режимов, поэтому он не +доказывает ничего, а призрачный `raw`-разрез попадёт в каталог и в правило +Read API «самый мелкий слой, покрывающий диапазон». + +#### Scenario: Выведенный слой расходится с надёжным заголовком + +- **WHEN** выведенный слой не совпадает с заголовком `Minutes` или `Hours` +- **THEN** система пишет запись уровня `WARN` +- **AND** сохраняет точки по выведенному слою, а не по заголовку + +#### Scenario: Заголовок `Default` в сравнении не участвует + +- **WHEN** заголовок доставки равен `Default` +- **THEN** расхождение не фиксируется и `WARN` не пишется + +Иначе сигнал утонул бы в собственном шуме: `Default` не означает режима, и +сравнение с ним давало бы `WARN` на каждой доставке потока в пять минут. + +### Requirement: Разбор форматов времени + +Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и +приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` +других форматов меток не встречается. + +Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри** +`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать +её: элементы серии проходят как исходные байты. Преобразование во `time.Unix` +и обратно не гарантирует дословности, а серия составляет 93% объёма метрики +`heart_rate_variability`. + +RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается +только в `data.stateOfMind`, которая выведена из scope. Требование к нему +появится вместе с задачей про секции с собственными `id` — вместе с данными, +на которых его можно проверить. + +#### Scenario: Локальное время со смещением + +- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300` +- **THEN** точка получает время в UTC и офсет `+10800` секунд + +#### Scenario: Время внутри серии ударов + +- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries` +- **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой +- **AND** серия не разворачивается в отдельные точки +- **AND** эпоха внутри серии не разбирается и не преобразуется + +### Requirement: Разделение схем под одним именем метрики + +Система SHALL разводить на разные имена метрики те схемы, которые Health Auto +Export шлёт под одним именем, чтобы одно имя означало одну схему. + +Под именем `sleep_analysis` приезжают две несовместимые схемы: поэпизодная +(`start`/`end`/`value`/`qty`) и суточная сводка +(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих +полей, кроме `date` и `source`, у них нет. + +Схема точки SHALL определяться по самой точке, а не по её месту в исходном +массиве. Список разобранных точек отфильтрован пропусками, и соответствие по +индексу съезжало бы от одной пропущенной точки: эпизод уезжал бы под имя +суточной сводки со слоем `day`, сводка — под имя эпизода. Метрика и слой входят +в координату, поэтому ошибка необратима — точки из объекта не удаляются, и +пересборка воспроизвела бы её. + +#### Scenario: Пропущенная точка не сдвигает разметку схем + +- **WHEN** в метрике `sleep_analysis` перед суточной сводкой стоит точка без + разбираемой метки +- **THEN** сводка всё равно сохраняется под именем `sleep_analysis_summary` со + слоем `day` + +#### Scenario: Поэпизодная запись сна + +- **WHEN** точка `sleep_analysis` содержит поле `value` +- **THEN** она сохраняется под именем `sleep_analysis` + +#### Scenario: Суточная сводка сна + +- **WHEN** точка `sleep_analysis` содержит поле `totalSleep` +- **THEN** она сохраняется под именем `sleep_analysis_summary` +- **AND** её слой фиксирован как `day`, а не выводится из выравнивания + +### Requirement: Канонизация содержимого + +Система SHALL вычислять каноническую форму содержимого точки для сравнения и +хеширования: сортировка ключей и округление чисел до двенадцати значащих цифр. + +Каноническая форма существует **только в момент вычисления хеша** и хранимую +форму не заменяет никогда: хранится исходные байты (см. «Разбор секции +метрик»). Числа при канонизации читаются литералом, а не через `float64`, — +иначе округление применится к уже испорченному значению. + +Сортировка ключей — свойство `encoding/json`, своей реализации не требует. +Собственным остаётся только округление. + +Без округления сравнение бесполезно: 63% повторно приехавших точек различались +последним разрядом double при одинаковом измерении. + +#### Scenario: Повтор с иным порядком ключей опознаётся как тот же + +- **WHEN** та же точка приезжает с другим порядком ключей в JSON +- **THEN** её каноническая форма совпадает с сохранённой + +#### Scenario: Дребезг последнего разряда не считается изменением + +- **WHEN** значение отличается только за пределами двенадцатой значащей цифры +- **THEN** каноническая форма совпадает с сохранённой + +### Requirement: Разбор не влияет на код ответа приёма + +Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не +меняет код ответа на доставку. + +#### Scenario: Содержимое не разобралось + +- **WHEN** тело сохранено в архив, но разбор его содержимого не удался +- **THEN** ответ на приём остаётся `200` +- **AND** исход виден в `delivery.parse_status` и в записи лога + diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md new file mode 100644 index 0000000..7063b2d --- /dev/null +++ b/openspec/specs/storage/spec.md @@ -0,0 +1,215 @@ +# storage Specification + +## Purpose +TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after archive. +## Requirements +### Requirement: Идентичность точки по координатам + +Система SHALL адресовать точку координатами +`метрика + слой + начало + конец`. У точки-измерения конец равен началу; у +точки-интервала — концу интервала. Ключ MUST быть одной формы для всех точек: +интервальная и точечная формы не встречаются вперемешку внутри одной метрики +одной доставки (проверено на всём корпусе), поэтому ветвление по «классу +метрики» не нужно и вводить его MUST NOT. + +Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем +же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как +`Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним +числом. + +Начало точки берётся из `start`, а при его отсутствии — из `date`; конец — из +`end`, а при его отсутствии — из начала. Измерено: `start`, когда он есть, +**всегда** совпадает с `date` (ноль исключений на 22 метриках), поэтому правило +не вводит второго источника метки — оно лишь закрывает случай, когда HAE +перестанет их дублировать. + +Час объекта определяется по началу точки: интервал пересекает границы часов, и +любой другой выбор сделал бы принадлежность объекту зависящей от длительности. + +Ключ по одной метке проверялся и отвергнут: он схлопывает записи сна. Измерено +на всех 94 доставках — 170 координат против 174 и **33 столкновения внутри +одной доставки**, где `received_at` общий, тай-брейк по нему неприменим в +принципе, и исход решал бы порядок элементов в JSON-массиве, а он нестабилен. +При этом разные интервалы под одной меткой всегда несут разное содержимое +(проверено по всем метрикам), то есть ключ с интервалом ничего не задваивает. + +Идентичность по хешу содержимого проверялась и отвергнута: она задваивала +минутный слой целиком — 120 точек в часе вместо 60. + +#### Scenario: Повторная доставка той же точки ничего не меняет + +- **WHEN** точка с теми же координатами и тем же содержимым приезжает снова +- **THEN** хранилище не изменяется + +#### Scenario: Смена источника не создаёт вторую точку + +- **WHEN** точка с теми же координатами приезжает с другой строкой `source` +- **THEN** она остаётся одной точкой, а не превращается в две + +#### Scenario: Записи с одной меткой и разными интервалами не схлопываются + +- **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и + разными парами `start`/`end` +- **THEN** каждая сохраняется отдельной точкой + +#### Scenario: Повтор записи в следующей доставке не задваивает + +- **WHEN** точка с тем же началом и концом приезжает следующей доставкой +- **THEN** она остаётся одной точкой + +#### Scenario: Точка-измерение адресуется вырожденным интервалом + +- **WHEN** точка не несёт `end` +- **THEN** её конец равен началу, и ключ имеет ту же форму, что у интервала + +### Requirement: Разрешение столкновений по полноте + +Когда по одним координатам приходят разные содержимые, система SHALL оставлять +**более полную** точку — ту, у которой больше значащих полей, — а не последнюю +пришедшую. Иначе бедная доставка стирает у богатой поля, которых сама не несёт: +0.66% координат различаются именно набором полей при одинаковом значении. + +Если полнота равна, а значения различаются, исход MUST быть детерминированным +и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по +журналу обязана давать то же состояние, что приём в реальном времени. + +Сравнение по `received_at` для этого не годится: у сохранённой точки нет +провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать +не с чем. Детерминизм обеспечивается свойством самих значений (например, +порядком канонических форм), а не порядком событий. + +#### Scenario: Бедная точка не стирает поля богатой + +- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context` +- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max` +- **THEN** сохранённая точка остаётся с `context` + +#### Scenario: Одинаково полные точки с разными значениями + +- **WHEN** по одним координатам приходят две одинаково полные точки с разными + значениями +- **THEN** исход определяется детерминированно и не зависит от порядка + воспроизведения доставок + +Столкновением SHALL считаться расхождение **канонических форм**, а не байтов. +Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно +приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним +разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на +каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы. + +#### Scenario: Столкновение с различием содержимого оставляет след + +- **WHEN** по одним координатам сохраняется точка, каноническая форма которой + отличается от уже сохранённой +- **THEN** система пишет запись уровня `WARN` без значений точки +- **AND** запись несёт координаты объекта: метрику, слой и час +- **AND** увеличивает счётчик перезаписей в итоге разбора доставки + +#### Scenario: Дребезг сериализации столкновением не считается + +- **WHEN** та же точка приезжает с другим порядком ключей или отличаясь + последним разрядом числа +- **THEN** счётчик перезаписей не растёт и `WARN` не пишется + +Без этого следа допущение «меньше полей не значит новее» не получит ни одного +наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с +экспортом Apple — то есть месяцами. + +### Requirement: Хранение часовыми объектами + +Система SHALL хранить точки часовыми объектами с ключом +`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки +внутри упорядочены по времени. + +Объект SHALL нести **единицы измерения** метрики. Внутри точки их нет — они +живут на уровне метрики (проверено: поле `units` не встретилось ни в одной +точке за 89 доставок), поэтому дословное хранение точек их не сохраняет. Без +колонки единицы восстановимы только из архива, а для метрик, переставших +приходить, — теряются навсегда. + +Объект SHALL нести границы содержимого (первая и последняя метка) и +идентификатор доставки, создавшей его. Первое нужно каталогу разрезов, чтобы +не разжимать каждый блоб ради диапазона; второе — провенанс для разбора +слияний. + +Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта +MUST NOT удаляться. Содержимое объекта SHALL сериализоваться без +HTML-экранирования: `&`, `<` и `>` внутри точки обязаны храниться теми же +байтами, какими пришли, иначе «точка хранится дословно» перестаёт быть правдой, +а сравнение с последующей доставкой той же точки промахивается навсегда. + +Доставка SHALL сворачиваться **одной транзакцией**. Транзакция на объект давала +недетерминированное частичное состояние: обход групп рандомизирован, и при +отказе посреди доставки набор уже записанных объектов каждый раз другой +(измерено: восемь прогонов одной доставки — семь разных состояний). Это ломает +инвариант «состояние пересобираемо». + +Единицы измерения MUST NOT переписываться молча: при расхождении сохранённых и +пришедших единиц остаётся сохранённое значение, факт учитывается счётчиком и +попадает в запись уровня `WARN`. Внутри точки единиц нет, и у ранее сохранённых +точек не остаётся ничего, по чему их единицы восстановимы. + +#### Scenario: Отказ посреди доставки не оставляет части объектов + +- **WHEN** свёртка доставки прерывается на середине +- **THEN** не записывается ни один объект этой доставки + +#### Scenario: Смена единиц не переподписывает сохранённые точки + +- **WHEN** в объект приезжают точки в единицах, отличных от сохранённых +- **THEN** единицы объекта остаются прежними +- **AND** факт учитывается счётчиком и записью `WARN` + +#### Scenario: Точки за один час ложатся в один объект + +- **WHEN** приходят точки одной метрики и слоя за один час UTC +- **THEN** они хранятся одним объектом + +#### Scenario: Дозапись в существующий час + +- **WHEN** приходят новые точки за уже существующий час +- **THEN** объект перечитывается, точки сливаются, объект записывается обратно +- **AND** ранее сохранённые точки остаются в объекте + +### Requirement: Хеш как детектор изменений + +Система SHALL хранить хеш канонической формы объекта и пропускать запись, если +хеш не изменился. Хеш — детектор, а не ключ. + +Это то, что делает широкие проходы синхронизации дешёвыми: глубокий проход +переприсылает неделю, но почти все сравнения сходятся и записи не происходит. + +#### Scenario: Повторная присылка того же часа не пишет в базу + +- **WHEN** приезжает доставка, целиком повторяющая уже сохранённый час +- **THEN** хеш совпадает и запись не выполняется + +### Requirement: Признак запечатанного часа + +Система SHALL хранить признак `sealed` у часового объекта и SHALL реагировать +на изменение запечатанного объекта сигналом, а не отказом. + +Правило перевода часа в `sealed` в этой дельте **не определяется**: порог +глубины досчёта ставится по наблюдениям, которых пока нет (наблюдалось до +22 минут). До появления правила признак остаётся невыставленным, и сценарий +ниже проверяется только явной установкой в тесте — это осознанная граница, а +не упущение. + +#### Scenario: Изменение запечатанного часа + +- **WHEN** приходят точки за час, помеченный `sealed` +- **THEN** система пишет запись уровня `WARN` +- **AND** данные всё равно сохраняются + +### Requirement: Значения точек не попадают в логи + +Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения +точек и тела доставок в записи лога уровня выше `DEBUG`. + +#### Scenario: Разбор доставки логируется без значений + +- **WHEN** доставка разобрана +- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и + идентификатор доставки +- **AND** не содержит ни значений точек, ни имён устройств +