## ADDED Requirements ### Requirement: Идентичность точки по координатам Система SHALL адресовать точку-измерение координатами `метрика + слой + метка времени`. Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним числом. Идентичность по хешу содержимого проверялась и отвергнута: она задваивала минутный слой целиком — 120 точек в часе вместо 60. #### Scenario: Повторная доставка той же точки ничего не меняет - **WHEN** точка с теми же координатами и тем же содержимым приезжает снова - **THEN** хранилище не изменяется #### Scenario: Смена источника не создаёт вторую точку - **WHEN** точка с теми же координатами приезжает с другой строкой `source` - **THEN** она остаётся одной точкой, а не превращается в две ### Requirement: Идентичность эпизода по интервалу Система SHALL адресовать точку-интервал координатами `метрика + слой + начало + конец`. Метки времени для неё недостаточно: под одной меткой лежит до трёх разных эпизодов сна. Эпизодность SHALL выводиться из формы точки — точка несёт `start` и `end`, отличные от `date`, — а не назначаться списком имён метрик. Список был бы вторым способом описывать то, что уже сказано формой точки, и разошёлся бы с ней на первой же новой метрике HAE. Измерено на всех 94 доставках (1880 эпизодных точек, находка 47): ключ по метке даёт 170 координат и 33 столкновения **внутри одной доставки**, ключ по интервалу — 174 координаты и ноль столкновений. Внутридоставочные столкновения и делают ключ по метке неисправимым: `received_at` там общий, тай-брейк по нему неприменим в принципе, и исход решал бы порядок элементов в JSON-массиве — а он нестабилен. Час объекта для точки-интервала SHALL определяться по началу эпизода: эпизод пересекает границы часов, и любой другой выбор сделал бы принадлежность объекту зависящей от длительности. #### Scenario: Эпизоды с одной меткой и разными интервалами не схлопываются - **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и разными парами `start`/`end` - **THEN** каждая сохраняется отдельной точкой #### Scenario: Повтор эпизода в следующей доставке не задваивает - **WHEN** точка с тем же `start` и `end` приезжает следующей доставкой - **THEN** она остаётся одной точкой ### Requirement: Разрешение столкновений по полноте Когда по одним координатам приходят разные содержимые, система SHALL оставлять **более полную** точку — ту, у которой больше значащих полей, — а не последнюю пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой. Если полнота равна, а значения различаются, исход MUST быть детерминированным и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по журналу обязана давать то же состояние, что приём в реальном времени. Сравнение по `received_at` для этого не годится: у сохранённой точки нет провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать не с чем. Детерминизм обеспечивается свойством самих значений (например, порядком канонических форм), а не порядком событий. #### Scenario: Бедная точка не стирает поля богатой - **WHEN** сохранена точка с `qty`, `start` и `end` - **AND** по тем же координатам приезжает точка только с `qty` - **THEN** сохранённая точка остаётся с `start` и `end` #### Scenario: Одинаково полные точки с разными значениями - **WHEN** по одним координатам приходят две одинаково полные точки с разными значениями - **THEN** исход определяется детерминированно и не зависит от порядка воспроизведения доставок #### Scenario: Столкновение с различием содержимого оставляет след - **WHEN** по одним координатам сохраняется точка, каноническая форма которой отличается от уже сохранённой - **THEN** система пишет запись уровня `WARN` без значений точки - **AND** увеличивает счётчик перезаписей в итоге разбора доставки Без этого следа допущение «меньше полей не значит новее» не получит ни одного наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с экспортом Apple — то есть месяцами. ### Requirement: Хранение часовыми объектами Система SHALL хранить точки часовыми объектами с ключом `метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки внутри упорядочены по времени. Объект SHALL нести **единицы измерения** метрики. Внутри точки их нет — они живут на уровне метрики (проверено: поле `units` не встретилось ни в одной точке за 89 доставок), поэтому дословное хранение точек их не сохраняет. Без колонки единицы восстановимы только из архива, а для метрик, переставших приходить, — теряются навсегда. Объект SHALL нести границы содержимого (первая и последняя метка) и идентификатор доставки, создавшей его. Первое нужно каталогу разрезов, чтобы не разжимать каждый блоб ради диапазона; второе — провенанс для разбора слияний. Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта MUST NOT удаляться. #### 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** не содержит ни значений точек, ни имён устройств