change razbor-metrik-v-obekty заархивирован

Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога,
план отражает сделанную часть шага 3.

Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит
с 17:13, пауза началась до перезапуска сервиса.
This commit is contained in:
av
2026-08-01 19:03:46 +03:00
parent cd7a4c1493
commit 37413bb551
11 changed files with 487 additions and 41 deletions
-1
View File
@@ -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) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
-33
View File
@@ -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.
+9 -5
View File
@@ -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). Канонизация с округлением чисел и хеш содержимого
@@ -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. Приёмочные критерии (рубрика ревью дизайна)
+260
View File
@@ -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` и в записи лога
+215
View File
@@ -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** не содержит ни значений точек, ни имён устройств