предложение переработано по итогам ревью дизайна

- три решения опровергнуты экспериментами: повтор при SQLITE_BUSY не сходится
  без _txlock=immediate (242 из 800 против 800 из 800), разбор в map[string]any
  держит 197 МиБ против 54, канонизация без json.Number теряет литерал
- канонизации назначен дом: общий internal/canon вместо hae, иначе импорт
  экспорта Apple потребует второй реализации и хеш-детектор станет бесполезен
- вывод слоя вернул шаг наследования, WARN сравнивается только с надёжным
  заголовком; в bucket возвращены units и границы содержимого
This commit is contained in:
av
2026-08-01 15:16:58 +03:00
parent 65351ab6e7
commit 809153e03f
8 changed files with 394 additions and 76 deletions
@@ -6,14 +6,20 @@
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
том виде, в каком его прислал HAE.
Незнакомое поле внутри точки MUST сохраняться, а не отбрасываться: сырой архив
недолговечен, и отброшенное поле теряется безвозвратно.
Хранимая форма точки — **исходные байты**, как они пришли в теле доставки.
Система MUST NOT пересобирать содержимое повторной сериализацией разобранных
значений: обход через `map[string]any` теряет литерал (`1.0` становится `1`,
целые больше 2^53 сдвигаются, невалидный UTF-8 заменяется на U+FFFD), и потеря
не видна тестам на фикстурах — они сравнивают разобранное с разобранным.
Отсюда же следует, что «служебных» полей у точки нет: отбрасывать нечего,
нормализованное время добавляется рядом с исходным содержимым, а не вместо.
#### Scenario: Метрика с точками разбирается в точки
- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
- **THEN** каждая точка с непустым `date` становится точкой хранилища
- **AND** все поля точки, кроме служебных, сохраняются дословно
- **AND** её содержимое сохраняется исходными байтами, без пересборки
#### Scenario: Незнакомая метрика не ломает разбор
@@ -34,8 +40,15 @@
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
`Default` соответствует трём разным режимам выгрузки.
Слои: `raw` (метка на произвольной секунде), `minute` (секунды нулевые),
`hour` (секунды и минуты нулевые).
Выводимые слои: `raw` (метка на произвольной секунде), `minute` (секунды
нулевые), `hour` (секунды и минуты нулевые). Слой `day` в перечисление входит,
но **не выводится** — он назначается схемам с фиксированной гранулярностью
(см. «Разделение схем под одним именем метрики»).
Разделение схем под одним именем выполняется **до** вывода слоя, и точки с
назначенным слоем в определении преобладающего слоя доставки не участвуют:
суточных сводок сна бывает больше порога плотности, и их полуночные метки
иначе назначили бы всей доставке слой `hour`.
Классификация MUST быть **по метрике внутри доставки**, а не по доставке
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
@@ -56,23 +69,54 @@
#### Scenario: В доставке нет плотных метрик
- **WHEN** ни у одной метрики доставки нет десяти точек
- **THEN** слой берётся из заголовка `automation-aggregation`
(`Minutes``minute`, `Hours``hour`, иначе `raw`)
- **THEN** слой наследуется от последнего надёжно выведенного слоя той же
автоматизации (`automation-id`)
- **AND** если наследовать нечего, слой берётся из **надёжного** заголовка
(`Minutes``minute`, `Hours``hour`)
#### Scenario: Выведенный слой расходится с заголовком
#### Scenario: Наследовать нечего и заголовок ненадёжен
- **WHEN** выведенный слой не совпадает с тем, что объявляет заголовок доставки
- **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 понимать три формата времени, встречающиеся в теле доставки, и
приводить их к UTC, сохраняя офсет исходной зоны.
Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и
приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics`
других форматов меток не встречается.
- `2026-07-31 21:03:51 +0300` — метрики и тренировки;
- `2026-07-31T18:03:51Z` — RFC 3339 в UTC;
- `1785446196.4132624` — Unix-эпоха дробным числом внутри `heartbeatSeries`.
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
её: элементы серии проходят как исходные байты. Преобразование во `time.Unix`
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
`heart_rate_variability`.
RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается
только в `data.stateOfMind`, которая выведена из scope. Требование к нему
появится вместе с задачей про секции с собственными `id` — вместе с данными,
на которых его можно проверить.
#### Scenario: Локальное время со смещением
@@ -82,8 +126,9 @@
#### Scenario: Время внутри серии ударов
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
- **THEN** элементы серии сохраняются дословно вместе с их эпохой
- **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой
- **AND** серия не разворачивается в отдельные точки
- **AND** эпоха внутри серии не разбирается и не преобразуется
### Requirement: Разделение схем под одним именем метрики
@@ -108,9 +153,16 @@ Export шлёт под одним именем, чтобы одно имя оз
### Requirement: Канонизация содержимого
Система SHALL приводить содержимое точки к канонической форме перед сравнением
и хешированием: рекурсивная сортировка ключей и округление чисел до двенадцати
значащих цифр.
Система SHALL вычислять каноническую форму содержимого точки для сравнения и
хеширования: сортировка ключей и округление чисел до двенадцати значащих цифр.
Каноническая форма существует **только в момент вычисления хеша** и хранимую
форму не заменяет никогда: хранится исходные байты (см. «Разбор секции
метрик»). Числа при канонизации читаются литералом, а не через `float64`, —
иначе округление применится к уже испорченному значению.
Сортировка ключей — свойство `encoding/json`, своей реализации не требует.
Собственным остаётся только округление.
Без округления сравнение бесполезно: 63% повторно приехавших точек различались
последним разрядом double при одинаковом измерении.
@@ -21,15 +21,42 @@
- **WHEN** точка с теми же координатами приезжает с другой строкой `source`
- **THEN** она остаётся одной точкой, а не превращается в две
### Requirement: Эпизодные схемы не сохраняются до решения об идентичности
Система MUST NOT сохранять точки схем, у которых метка времени не уникальна, —
пока модель их идентичности не определена. Такие точки разбираются и
учитываются счётчиком, но в объекты не пишутся.
Известная такая схема одна: поэпизодный `sleep_analysis`. Измерено: 31
координата в 21 доставке из 89 задвоена **внутри одной доставки**, где
`received_at` общий и тай-брейк по нему неприменим в принципе. Сохранять их по
правилу, о котором известно, что оно теряет, хуже, чем не сохранять: тела
остаются в архиве, и после решения их подберёт пересборка.
Развилка вынесена блокером `identichnost-epizodnyh-metrik`. Суточной сводки
(`sleep_analysis_summary`) ограничение не касается — у неё метка на полуночи
уникальна.
#### Scenario: Поэпизодная запись сна не попадает в объект
- **WHEN** разобрана точка метрики `sleep_analysis` поэпизодной схемы
- **THEN** она не сохраняется в часовой объект
- **AND** факт учитывается счётчиком в итоге разбора доставки
### Requirement: Разрешение столкновений по полноте
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю
пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой.
Если полнота равна, а значения различаются, исход определяет порядок
воспроизведения, и он MUST быть по `received_at` доставки: свёртка по журналу
обязана давать то же состояние, что приём в реальном времени.
Если полнота равна, а значения различаются, исход MUST быть детерминированным
и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по
журналу обязана давать то же состояние, что приём в реальном времени.
Сравнение по `received_at` для этого не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
не с чем. Детерминизм обеспечивается свойством самих значений (например,
порядком канонических форм), а не порядком событий.
#### Scenario: Бедная точка не стирает поля богатой
@@ -41,7 +68,19 @@
- **WHEN** по одним координатам приходят две одинаково полные точки с разными
значениями
- **THEN** побеждает точка из доставки с большим `received_at`
- **THEN** исход определяется детерминированно и не зависит от порядка
воспроизведения доставок
#### Scenario: Столкновение с различием содержимого оставляет след
- **WHEN** по одним координатам сохраняется точка, каноническая форма которой
отличается от уже сохранённой
- **THEN** система пишет запись уровня `WARN` без значений точки
- **AND** увеличивает счётчик перезаписей в итоге разбора доставки
Без этого следа допущение «меньше полей не значит новее» не получит ни одного
наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с
экспортом Apple — то есть месяцами.
### Requirement: Хранение часовыми объектами
@@ -49,6 +88,17 @@
`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки
внутри упорядочены по времени.
Объект SHALL нести **единицы измерения** метрики. Внутри точки их нет — они
живут на уровне метрики (проверено: поле `units` не встретилось ни в одной
точке за 89 доставок), поэтому дословное хранение точек их не сохраняет. Без
колонки единицы восстановимы только из архива, а для метрик, переставших
приходить, — теряются навсегда.
Объект SHALL нести границы содержимого (первая и последняя метка) и
идентификатор доставки, создавшей его. Первое нужно каталогу разрезов, чтобы
не разжимать каждый блоб ради диапазона; второе — провенанс для разбора
слияний.
Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
MUST NOT удаляться.
@@ -78,8 +128,14 @@ MUST NOT удаляться.
### Requirement: Признак запечатанного часа
Система SHALL отмечать признаком `sealed` часы, которые уже не должны
меняться. Изменение запечатанного объекта — не отказ, а сигнал.
Система SHALL хранить признак `sealed` у часового объекта и SHALL реагировать
на изменение запечатанного объекта сигналом, а не отказом.
Правило перевода часа в `sealed` в этой дельте **не определяется**: порог
глубины досчёта ставится по наблюдениям, которых пока нет (наблюдалось до
22 минут). До появления правила признак остаётся невыставленным, и сценарий
ниже проверяется только явной установкой в тесте — это осознанная граница, а
не упущение.
#### Scenario: Изменение запечатанного часа