заведён change на разбор метрик в часовые объекты

- proposal и две capability: parsing (вывод слоя, форматы времени, канонизация)
  и storage (координатный ключ, слияние по полноте, часовые объекты)
- design фиксирует границы: разбор отдельным пакетом, синхронно после записи в
  архив, конкурентная запись в один час — риск с тестом
- вне scope сознательно: тренировки, reindex, словарь кодов, род агрегации
This commit is contained in:
av
2026-08-01 14:50:10 +03:00
parent 01b13084ba
commit c6f27e3890
7 changed files with 515 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-01
@@ -0,0 +1,157 @@
## Context
Приём принимает пакеты Health Auto Export и складывает тела в архив
(`internal/ingest`, `internal/archive`). Разбора нет: в SQLite только строки
`delivery`. Накоплено 89 доставок, 16 МБ архива, поток идёт непрерывно.
Правила разбора выведены измерением, а не спроектированы: 46 находок в
`docs/local-research.md`. Половина расходится с документацией HAE, поэтому
источник истины по формату — живые пакеты, а не документация.
Ограничение, определяющее форму решения: **сервис нельзя останавливать**.
Телефон шлёт непрерывно и молча; доставка, не попавшая в архив, не попадает в
журнал вовсе — телефон её не перешлёт. Значит разбор не имеет права уронить
приём.
## Goals / Non-Goals
**Goals:**
- Точки из секции `metrics` попадают в хранилище с правильным слоем.
- Повторные и пересекающиеся доставки не задваивают и не затирают данные.
- Разбор отделён от хранения: импорт родного экспорта Apple будет другим
разбором поверх того же хранилища.
- Ошибка разбора не влияет ни на код ответа приёма, ни на сохранность архива.
**Non-Goals:**
- Тренировки и секции с собственными `id` — другая модель хранения.
- `healthlog reindex` — накопленные 89 доставок доедут отдельной задачей.
- Словарь категориальных значений: строка пока хранится дословно и без кода.
- Род агрегации и каталог разрезов.
- Своя агрегация при записи: слои не сводятся друг к другу никогда.
## Decisions
### Разбор — отдельный пакет `internal/hae`, а не метод `ingest`
`ingest` — use-case приёма: сохранить тело, записать доставку. Разбор формата
живёт своей жизнью: у него будет второй потребитель (`reindex`) и второй
источник (родной экспорт Apple, свой пакет). Втянуть разбор в `ingest` значит
получить пакет, который меняется по двум несвязанным причинам.
Альтернатива — разбор внутри `store`. Отвергнута: `store` не должен знать
формат HAE, иначе импорт из Apple потребует второй реализации хранения.
Граница: `hae.Parse(body []byte, hdr Meta) (Parsed, error)` возвращает точки с
уже выведенным слоем и нормализованным временем. Дальше их принимает `store`,
который о HAE ничего не знает.
### Разбор — синхронно в приёме, после записи в архив
Тело сначала ложится на диск, потом разбирается. Отказ разбора не откатывает
архив: журнал важнее витрины, и восстановить точки из тела можно всегда, а
тело из точек — нет.
Асинхронный разбор (очередь, воркер) отвергнут: он даёт окно, в котором
доставка принята, но не разобрана, а сервис перезапущен — и мы теряем понимание,
что доразобрать. Синхронный разбор при 42 МБ теле стоит секунд, а `read_timeout`
уже пять минут.
### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB
Единица хранения — час, а не точка: 30 метрик × 24 часа × 365 ≈ 260 тыс. строк
на слой в год независимо от плотности точек внутри. Строка на точку дала бы
десятки миллионов.
Плата: внутрь объекта не заглянуть средствами SQL. Для хранилища, отдающего
диапазоны точек, это не потеря; каталог и свёртка получат свои производные
структуры отдельной задачей.
Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ.
### Слияние — по полноте, при равенстве — по `received_at`
Координатный ключ означает перезапись значения. Кто побеждает — решает
полнота: 0.66% координат несут разные содержимые, и разбор выборки показал,
что почти всё это разный **набор полей** при одинаковом `qty`. Правило «последний
победил» стирало бы `start`/`end` у уже сохранённой точки.
Полнота считается по числу значащих полей точки, `source` в счёт не идёт.
При равной полноте побеждает точка из доставки с большим `received_at` — это
делает свёртку по журналу детерминированной: проигрывание архива обязано дать
то же состояние, что приём в реальном времени.
### Слой выводится по метрике внутри доставки, а не по доставке целиком
Правило проверено на всей истории (находка 33) и уже дважды ломалось на живых
данных при более простых формулировках. Классификация доставки целиком
сложила минутные точки с посекундными и удвоила сумму за час; классификация
каждой метрики по отдельности растащила редкие метрики по трём слоям.
Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая
наследует самый мелкий слой среди плотных.
### Сводка сна — отдельное имя метрики и фиксированный слой `day`
Разводить схемы на имена приходится потому, что правило вывода слоя на суточной
сводке даёт `hour` (полночь выровнена по часу), хотя это суточный итог. Имя
`sleep_analysis_summary` — наше, не Apple; инвариант «форма Apple не
транслируется» это не нарушает: переименования полей внутри точки нет,
разделяются только имена метрик, под которыми HAE смешал две схемы.
### `testdata` — реальные пакеты с вычищенными значениями
Конвенция требует тестов на реальных пакетах; инвариант запрещает данным о
здоровье попадать под контроль версий. Обе цели совместимы: в фикстурах
сохраняется всё, что важно разбору, — порядок ключей, три формата времени,
неразрывные пробелы в именах устройств, точность чисел, обе схемы сна,
смешанная доставка, — а измеренные величины заменяются.
Скрипт порождения фикстур из архива лежит в `tmp/research/` и позволяет собрать
их заново, когда поток принесёт новую форму.
Альтернатива — писать фикстуры руками по документации. Отвергнута ровно тем,
ради чего заводилось исследование: документация врёт.
## Risks / Trade-offs
**Разбор роняет приём** → разбор идёт после `f.Sync()` и переименования файла
архива; паника в разборе перехватывается, доставка помечается
`parse_status=failed`, ответ остаётся `200`.
**Конкурентные доставки правят один час** → read-modify-write без блокировки
теряет точки. Три автоматизации шлют одновременно, и перекрытие часов — норма,
а не край. Запись объекта идёт в транзакции; при `SQLITE_BUSY` — повтор.
Проверяется тестом с параллельной записью в один `hour_utc`.
**Правило полноты ошибочно для метрики, где меньше полей значит новее**
таких в потоке не наблюдалось, но допущение не доказано. Помечается как
предположение в спеке; расхождение всплывёт при сверке с экспортом Apple.
**Вычищенные фикстуры прячут свойство реальных данных** → риск реален: именно
дребезг последнего разряда double едва не увёл модель идентичности не туда.
Смягчение — сохранять точность чисел как в оригинале и держать отдельный тест
на канонизацию с настоящими значениями из находки 30.
**Объект за час распухает** → в нижнем слое HRV несёт `heartbeatSeries`, 93%
объёма метрики. Порог не выбран, поведение при большом объекте не определено;
наблюдаемость размера объекта уходит в задачу про `/stats`.
## Migration Plan
Миграция `00003_bucket.sql` — только добавление таблицы, существующие данные не
трогает. Откат: сервис прежней версии игнорирует новую таблицу, доставки
продолжают приниматься и складываться в архив, разбор просто не происходит.
Накопленные 89 доставок этой миграцией не разбираются: их подхватит `reindex`
отдельной задачей. До тех пор в хранилище только точки из доставок, пришедших
после выката.
## Open Questions
- **Порог `sealed`.** С какого возраста час считается запечатанным — ставим по
факту: сначала `WARN` на изменение старых объектов, потом смотрим, какая
глубина досчёта встречается в жизни (наблюдалось до 22 минут).
- **Поведение при объекте необычного размера.** Отдельного решения пока нет;
ждём наблюдаемости.
@@ -0,0 +1,75 @@
## Why
Приём работает третьи сутки, но дальше архива данные не идут: 89 доставок лежат
телами `.json.gz`, а в SQLite только строки `delivery`. Ни одна из трёх целей
проекта — агент-медик, трекер тренировок, фитнес-игра — не может прочитать
ничего, потому что читать нечего. Каталог, Read API, MCP и OpenAPI упираются в
эту задачу.
Правила разбора не надо изобретать: они выведены измерением на живом потоке и
записаны в `docs/local-research.md` (находки 30, 33, 35, 36, 38, 39, 41).
Задача — перенести их в код, а не спроектировать заново.
## What Changes
- Секция `metrics` тела доставки разбирается в **точки**: метрика, слой, метка
времени, содержимое как пришло.
- **Слой выводится из выравнивания меток**, а не из заголовка HAE: плотная
метрика (≥10 точек) классифицируется сама, редкая наследует преобладающий
слой доставки. Заголовок `automation-aggregation` непригоден — значение
`Default` соответствует трём разным режимам.
- Точки складываются в **часовые объекты** (`bucket`, ключ `метрика + слой +
час`), содержимое — gzip-BLOB. Запись — read-modify-write со слиянием.
- **Идентичность точки — координаты** (`метрика + слой + метка`). `source` в
ключ не входит: он нестабилен и меняется задним числом. При столкновении
выигрывает **более полная** точка, а не последняя пришедшая.
- **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической
формы — детектор изменений, а не ключ.
- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку —
под одним именем HAE шлёт две несовместимые схемы.
- Три формата времени на входе: локальное со смещением, RFC 3339 Z,
Unix-эпоха внутри `heartbeatSeries`. В хранилище — UTC RFC 3339 плюс офсет
исходной зоны.
- Разбор не влияет на код ответа приёма: непонятое содержимое по-прежнему
даёт 200, исход разбора виден в `delivery.parse_status` и в логе.
Не входит в изменение (сознательно, чтобы задача мерджилась целиком):
- **тренировки и секции с собственными `id`** (`workouts`, `stateOfMind`,
прочие `record`) — отдельная задача, у них другая модель хранения;
- **`healthlog reindex`** — отдельная задача. Следствие: разбираются только
доставки, пришедшие после выката; накопленные 89 доедут пересборкой.
Сходимость на них проверяется скриптом поверх архива, а не командой сервиса;
- **словарь категориальных значений** (переведённые строки → коды HealthKit) —
отдельная задача; пока строка хранится дословно и без кода рядом;
- **род агрегации и каталог разрезов** — отдельная задача, она следующая.
## Capabilities
### New Capabilities
- `parsing`: превращение тела доставки Health Auto Export в точки — вывод
слоя, разбор трёх форматов времени, канонизация содержимого, разделение
схем под одним именем метрики. Отдельно от хранения потому, что импорт
родного экспорта Apple будет другим разбором поверх того же хранилища.
- `storage`: идентичность точки, слияние и хранение часовыми объектами —
координатный ключ, правило разрешения столкновений, хеш как детектор
изменений, признак запечатанного часа.
### Modified Capabilities
Нет: `openspec/specs/` пуст, приём кодом существует, но спекой не описан и в
этом изменении не трогается.
## Impact
- Новые пакеты `internal/hae` (разбор) и расширение `internal/store` (объекты).
- Миграция `internal/store/migrations/00003_bucket.sql` — первая миграция после
приёма; вместе с ней заводится `docs/database.md` (ER-схема), которую требует
шаг гейта `er-schema`.
- `internal/ingest` получает шаг разбора после записи в архив; контракт приёма
не меняется.
- Появляется `testdata` с реальными пакетами HAE. Значения в них **вычищаются**:
структура, порядок ключей, форматы времени, неразрывные пробелы в именах
устройств и точность чисел сохраняются, измеренные величины заменяются —
данные о здоровье не попадают под контроль версий.
@@ -0,0 +1,137 @@
## ADDED Requirements
### Requirement: Разбор секции метрик
Система SHALL разбирать секцию `data.metrics` тела доставки Health Auto Export
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
том виде, в каком его прислал HAE.
Незнакомое поле внутри точки MUST сохраняться, а не отбрасываться: сырой архив
недолговечен, и отброшенное поле теряется безвозвратно.
#### Scenario: Метрика с точками разбирается в точки
- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
- **THEN** каждая точка с непустым `date` становится точкой хранилища
- **AND** все поля точки, кроме служебных, сохраняются дословно
#### Scenario: Незнакомая метрика не ломает разбор
- **WHEN** приходит метрика с именем, которого разбор не знает
- **THEN** её точки разбираются наравне с остальными
- **AND** разбор не завершается ошибкой
#### Scenario: Точка без метки времени пропускается
- **WHEN** точка не содержит `date` либо `date` не разбирается ни одним из
поддерживаемых форматов
- **THEN** точка не попадает в хранилище
- **AND** факт учитывается в итоге разбора доставки
### Requirement: Вывод слоя гранулярности
Система SHALL выводить слой точки из **выравнивания меток времени**, а не из
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
`Default` соответствует трём разным режимам выгрузки.
Слои: `raw` (метка на произвольной секунде), `minute` (секунды нулевые),
`hour` (секунды и минуты нулевые).
Классификация MUST быть **по метрике внутри доставки**, а не по доставке
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
отнесение такой доставки к одному слою складывает минутные точки с
посекундными.
#### Scenario: Плотная метрика классифицируется сама
- **WHEN** в доставке у метрики не меньше десяти точек с метками
- **THEN** слой определяется выравниванием её собственных меток
#### Scenario: Редкая метрика наследует преобладающий слой
- **WHEN** в доставке у метрики меньше десяти точек
- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки
- **AND** её собственное выравнивание во внимание не принимается
#### Scenario: В доставке нет плотных метрик
- **WHEN** ни у одной метрики доставки нет десяти точек
- **THEN** слой берётся из заголовка `automation-aggregation`
(`Minutes``minute`, `Hours``hour`, иначе `raw`)
#### Scenario: Выведенный слой расходится с заголовком
- **WHEN** выведенный слой не совпадает с тем, что объявляет заголовок доставки
- **THEN** система пишет запись уровня `WARN`
- **AND** сохраняет точки по выведенному слою, а не по заголовку
### Requirement: Разбор форматов времени
Система SHALL понимать три формата времени, встречающиеся в теле доставки, и
приводить их к UTC, сохраняя офсет исходной зоны.
- `2026-07-31 21:03:51 +0300` — метрики и тренировки;
- `2026-07-31T18:03:51Z` — RFC 3339 в UTC;
- `1785446196.4132624` — Unix-эпоха дробным числом внутри `heartbeatSeries`.
#### Scenario: Локальное время со смещением
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
- **THEN** точка получает время в UTC и офсет `+10800` секунд
#### Scenario: Время внутри серии ударов
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
- **THEN** элементы серии сохраняются дословно вместе с их эпохой
- **AND** серия не разворачивается в отдельные точки
### Requirement: Разделение схем под одним именем метрики
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
Export шлёт под одним именем, чтобы одно имя означало одну схему.
Под именем `sleep_analysis` приезжают две несовместимые схемы: поэпизодная
(`start`/`end`/`value`/`qty`) и суточная сводка
(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих
полей, кроме `date` и `source`, у них нет.
#### Scenario: Поэпизодная запись сна
- **WHEN** точка `sleep_analysis` содержит поле `value`
- **THEN** она сохраняется под именем `sleep_analysis`
#### Scenario: Суточная сводка сна
- **WHEN** точка `sleep_analysis` содержит поле `totalSleep`
- **THEN** она сохраняется под именем `sleep_analysis_summary`
- **AND** её слой фиксирован как `day`, а не выводится из выравнивания
### Requirement: Канонизация содержимого
Система SHALL приводить содержимое точки к канонической форме перед сравнением
и хешированием: рекурсивная сортировка ключей и округление чисел до двенадцати
значащих цифр.
Без округления сравнение бесполезно: 63% повторно приехавших точек различались
последним разрядом double при одинаковом измерении.
#### Scenario: Повтор с иным порядком ключей опознаётся как тот же
- **WHEN** та же точка приезжает с другим порядком ключей в JSON
- **THEN** её каноническая форма совпадает с сохранённой
#### Scenario: Дребезг последнего разряда не считается изменением
- **WHEN** значение отличается только за пределами двенадцатой значащей цифры
- **THEN** каноническая форма совпадает с сохранённой
### Requirement: Разбор не влияет на код ответа приёма
Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не
меняет код ответа на доставку.
#### Scenario: Содержимое не разобралось
- **WHEN** тело сохранено в архив, но разбор его содержимого не удался
- **THEN** ответ на приём остаётся `200`
- **AND** исход виден в `delivery.parse_status` и в записи лога
@@ -0,0 +1,100 @@
## 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 оставлять
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю
пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой.
Если полнота равна, а значения различаются, исход определяет порядок
воспроизведения, и он MUST быть по `received_at` доставки: свёртка по журналу
обязана давать то же состояние, что приём в реальном времени.
#### Scenario: Бедная точка не стирает поля богатой
- **WHEN** сохранена точка с `qty`, `start` и `end`
- **AND** по тем же координатам приезжает точка только с `qty`
- **THEN** сохранённая точка остаётся с `start` и `end`
#### Scenario: Одинаково полные точки с разными значениями
- **WHEN** по одним координатам приходят две одинаково полные точки с разными
значениями
- **THEN** побеждает точка из доставки с большим `received_at`
### Requirement: Хранение часовыми объектами
Система SHALL хранить точки часовыми объектами с ключом
`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки
внутри упорядочены по времени.
Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
MUST NOT удаляться.
#### Scenario: Точки за один час ложатся в один объект
- **WHEN** приходят точки одной метрики и слоя за один час UTC
- **THEN** они хранятся одним объектом
#### Scenario: Дозапись в существующий час
- **WHEN** приходят новые точки за уже существующий час
- **THEN** объект перечитывается, точки сливаются, объект записывается обратно
- **AND** ранее сохранённые точки остаются в объекте
### Requirement: Хеш как детектор изменений
Система SHALL хранить хеш канонической формы объекта и пропускать запись, если
хеш не изменился. Хеш — детектор, а не ключ.
Это то, что делает широкие проходы синхронизации дешёвыми: глубокий проход
переприсылает неделю, но почти все сравнения сходятся и записи не происходит.
#### Scenario: Повторная присылка того же часа не пишет в базу
- **WHEN** приезжает доставка, целиком повторяющая уже сохранённый час
- **THEN** хеш совпадает и запись не выполняется
### Requirement: Признак запечатанного часа
Система SHALL отмечать признаком `sealed` часы, которые уже не должны
меняться. Изменение запечатанного объекта — не отказ, а сигнал.
#### Scenario: Изменение запечатанного часа
- **WHEN** приходят точки за час, помеченный `sealed`
- **THEN** система пишет запись уровня `WARN`
- **AND** данные всё равно сохраняются
### Requirement: Значения точек не попадают в логи
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек и тела доставок в записи лога уровня выше `DEBUG`.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
@@ -0,0 +1,43 @@
## 1. Фикстуры и схема
- [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
- [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`
- [ ] 1.3 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `payload` BLOB, `content_hash`, `points`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)`
- [ ] 1.4 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
## 2. Разбор — пакет `internal/hae`
- [ ] 2.1 Разбор трёх форматов времени в UTC с сохранением офсета исходной зоны; неразобранная метка — не паника, а пропуск точки со счётчиком
- [ ] 2.2 Вывод слоя: выравнивание меток, плотная метрика (≥10 точек) сама, редкая наследует преобладающий слой, при отсутствии плотных — заголовок доставки
- [ ] 2.3 Расхождение выведенного слоя с заголовком доставки — запись `WARN` без значений точек
- [ ] 2.4 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`
- [ ] 2.5 Канонизация: рекурсивная сортировка ключей, округление чисел до 12 значащих цифр
- [ ] 2.6 `hae.Parse` возвращает точки со слоем, метрикой, меткой и содержимым как пришло; незнакомые поля и метрики сохраняются
- [ ] 2.7 Тесты разбора на фикстурах из 1.2, включая смешанную доставку и обе схемы сна
## 3. Хранение — часовые объекты
- [ ] 3.1 Модель точки и объекта в `internal/store`; сериализация содержимого в gzip-BLOB, точки внутри упорядочены по времени
- [ ] 3.2 Слияние: координатный ключ `метрика + слой + метка`, победа более полной точки, при равной полноте — по `received_at`
- [ ] 3.3 Хеш канонической формы объекта как детектор изменений: совпал — записи нет
- [ ] 3.4 Запись объекта в транзакции; повтор при `SQLITE_BUSY`
- [ ] 3.5 Признак `sealed`: изменение запечатанного часа пишет `WARN` и всё равно сохраняет
- [ ] 3.6 Тест конкурентной записи в один `hour_utc`: точки обеих сторон на месте
- [ ] 3.7 Тест идемпотентности: повторное слияние того же набора не меняет ни содержимое, ни хеш
## 4. Сшивка с приёмом
- [ ] 4.1 `internal/ingest` вызывает разбор после записи тела в архив и строки `delivery`
- [ ] 4.2 Отказ и паника разбора не меняют код ответа: `200`, `parse_status=failed`, запись лога уровня `ERROR` без значений
- [ ] 4.3 Единственный логирующий чекпоинт на границе: счётчики метрик, точек и объектов, идентификатор доставки; ни значений, ни имён устройств
- [ ] 4.4 Тест приёма: битый JSON — 400, непонятое содержимое — 200 с `parse_status=failed`
## 5. Сходимость на реальных данных
- [ ] 5.1 Скрипт `tmp/research/verify_buckets.py`: прогоняет архив через разбор и сверяет суммы по часовому слою с прежней проверкой из разведки
- [ ] 5.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет
- [ ] 5.3 `task gate` зелёный; `task restart` поднимает сервис, новая доставка с телефона разбирается
## 6. Приёмочные критерии ревью дизайна
<!-- Заполняется рубрикой из healthlog-review-rubric на шаге ревью дизайна -->
+1
View File
@@ -67,4 +67,5 @@ rules:
# Кавычки обязательны: без них YAML обрежет строку на первом '#'.
- "Каждое ### Requirement обязано содержать SHALL или MUST (иначе валидация падает)"
- "Сценарий — ровно #### (четыре решётки); три или список молча теряются"
- "SHALL/MUST должно стоять в ПЕРВОМ абзаце требования: валидатор смотрит только его, обоснование ниже он не видит (проверено)"
- "Заголовки и WHEN/THEN/GIVEN — на английском, остальной текст на русском"