Каталог разрезов и измеренный род агрегации

- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
This commit is contained in:
av
2026-08-02 19:23:59 +03:00
parent 98e0772ec5
commit 03edf1087d
39 changed files with 4744 additions and 58 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -0,0 +1,453 @@
## Context
Витрина уже держит одну метрику в нескольких слоях одновременно
(`sample`/`raw`/`minute`/`hour`/`day`) и своей агрегации при записи не делает.
Read API обязан уметь свести ряд к запрошенной сетке — но правильная свёртка
зависит от рода метрики, а рода в потоке нет:
- **форма точки его не выдаёт** — `Avg`/`Min`/`Max` есть только у `heart_rate`,
заведомо мгновенные `walking_speed` и `blood_oxygen_saturation` приходят в
`qty` ровно так же, как шаги (находка 40);
- **заголовок доставки не описывает данные** — `automation-aggregation`
принимает значение `Default` у трёх разных режимов (находка 31);
- **единицы дают процентов девяносто и ломаются на краях** —
`six_minute_walking_test_distance` в метрах складывать нельзя, а
`walking_running_distance` в километрах можно.
Зато витрина содержит собственную сверку: у одной метрики есть и минутный, и
часовой разрез за один и тот же час, и они приходят из разных автоматизаций.
Часовое значение либо равно сумме минутных, либо их среднему — и это
наблюдаемое различие.
Prior art по этому вопросу богат, но весь он про **объявление** рода, а не про
измерение: HealthKit зашивает `HKQuantityAggregationStyle` в тип метрики, Home
Assistant получает `state_class` от интеграции, Graphite выводит
`aggregationMethod` регуляркой по имени метрики, Prometheus/Datadog/Splunk
принимают тип от отправителя. Прецедента вывода рода из значений нет ни одного —
и паспорт проекта предупреждал об этом заранее.
## Goals / Non-Goals
**Goals:**
- Род метрики (`cumulative` / `instant` / `unknown`) выводится измерением на
накопленных данных, без ручной разметки и без списка имён в коде.
- Потребитель одним запросом узнаёт, что в хранилище есть: метрики, единицы,
слои с диапазонами и числом точек, род и основание, на котором он измерен.
- Каталог отдаёт наблюдаемое состояние витрины и ничего не досчитывает: период,
у которого верхние слои не пересобираемы, честно объявляет то, что есть.
- Стоимость каталога не растёт вместе с историей.
**Non-Goals:**
- **Свёртка ряда в ответе.** Каталог отвечает «какая свёртка осмысленна», сама
свёртка — задача Read API.
- **Правило неполного ведра** (`xFilesFactor`). Оно нужно свёртке, а не
измерению — см. решение 6.
- **Тай-брейк при равной полноте точек.** Вынут блокером — решение 8.
- **Каталог сущностей** (`workout`, `record`). Тренировки и записи адресуются
своим `id`, слоёв у них нет; их перечисление приезжает вместе с их же
маршрутами Read API.
- **Хранение измеренного рода.** Решение 2.
## Decisions
### 1. Род измеряется двумя конкурирующими гипотезами и единогласием
Для каждого часа, за который у метрики есть **и** минутный, **и** часовой
объект:
```
час пригоден, если
час не позже текущего времени плюс час
единицы обоих объектов совпадают
часовой объект несёт ровно одну точку, и она несёт значение
метка этой точки совпадает с началом часа
у минутного не меньше двух точек со значением
сумма минутных и их среднее сами различимы
вердикт пригодного часа:
часовое ≈ сумма минутных → cumulative
часовое ≈ среднее минутных → instant
иначе → час свидетельства не даёт
```
Вердикт метрики: **не меньше трёх согласных часов и ни одного противоречащего**.
Иначе — `unknown`, и свёртка по метрике не предлагается вовсе. Наличие
противоречащих часов пишется `WARN`: род — свойство, на котором Read API строит
арифметику года, и его смена не имеет права проходить молча.
**Все три сравнения — один предикат с одним допуском**, относительным, величиной
`1e-9`. Это не аккуратность, а устранение целого класса: напиши «различимость»
точным неравенством, а «сходимость» с допуском — появится час, подтверждающий
обе гипотезы сразу, и его исход молча определит порядок веток `if`. При одном
предикате такой час невыразим.
Величина названа числом, потому что от неё зависят счётчики основания в ответе.
Измерено на живом корпусе: вердикты метрик одинаковы при допуске от `1e-9` до
`1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49 —
то есть выбор не влияет на вывод, но влияет на то, что мы о нём сообщаем. Взято
строгое: канонизация содержимого округляет числа до 12 значащих цифр, значит всё
крупнее `1e-12` представлением не объясняется; `1e-9` оставляет три порядка
запаса и остаётся на шесть порядков строже любого содержательного расхождения —
сумма и среднее при `n ≥ 2` различаются не меньше чем вдвое.
Абсолютного порога нет намеренно: второй константы, которую пришлось бы
объяснять, задача не заводит. Цена названа вслух — при обоих нулях предикат
истинен, и от нулевого часа защищает не он, а проверка различимости. Полагаться
на «около нуля не сходится» нельзя: там ровно наоборот.
**Горизонт закрывает подделку и сбитые часы.** Час объекта берётся из метки в
теле доставки, а тело не наше: без верхней границы одна доставка с метками в
будущем занимает окно целиком и подменяет измеренный род. Путь построен и
прогнан — мгновенная метрика объявлялась накопительной при нуле противоречащих
часов, то есть с виду безупречным основанием. Часы позже `now + час` в окно не
входят, а сам факт данных из будущего пишется `WARN`: сбитые часы телефона и
чужое тело в приёме лечатся не кодом.
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
минутным слоем и в `count/hour` часовым даёт в полном часе
`часовое = 60 · среднее = сумма` — уверенный ложный `cumulative`, которого
правило единогласия не ловит по построению: противоречия нет, есть молчание.
Единицы лежат в том же покрывающем индексе, так что проверка не стоит ничего.
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
который покрывают минутные точки того же объекта. Сравнивать их нельзя. Условие
стоит копейки, а закрывает класс целиком — на корпусе одной зоны его не
воспроизвести, и потому оно и записано правилом, а не оставлено на «когда
поедем».
Измерено на живом архиве (123 доставки, 31 метрика, окно — все общие часы):
| исход | метрик |
|---|---|
| `cumulative` | 7 (`active_energy`, `basal_energy_burned`, `step_count`, `walking_running_distance`, `apple_stand_time`, `apple_exercise_time`, `time_in_daylight`) |
| `instant` | 9 (`heart_rate`, `respiratory_rate`, `blood_oxygen_saturation`, `environmental_audio_exposure`, `walking_speed`, `walking_step_length`, `walking_double_support_percentage`, `walking_asymmetry_percentage`, `stair_speed_up`) |
| `unknown` | 15 |
| **противоречащих часов** | **0 на всём корпусе** |
Числа сняты при допуске `1e-9` и окне в 48 часов. Полный обход всей истории дал
бы `instant` ещё и `physical_effort` (5 согласных часов за всё время против 2 в
свежем окне) — окно честно уводит редкие метрики в `unknown`, и это то же
правило, а не издержка.
Три части правила стоят каждая своей причины.
**Фильтр различимости — не украшение.** Без него
`walking_asymmetry_percentage` давала 4 часа «накопительная» против 3
«мгновенная»: в нулевом часе сумма равна среднему, и «сходится с суммой»
выполняется тождественно. Час, в котором гипотезы неразличимы, свидетельством не
является.
**Порог в три часа.** Один совпавший час — свидетельство одного часа, а на роде
потом суммируют год. Цена измерена: порог уводит в `unknown` ровно одну метрику
(`headphone_audio_exposure`, один согласный час).
**Единогласие, а не большинство.** Противоречие означает, что одна из гипотез
ложна для этой метрики; большинство голосов позволило бы объявить род при
известном контрпримере. Измеренная цена этого решения — ноль: конфликтов нет.
Альтернативы отвергнуты:
- **Разметка руками** (так делают все, кроме нас) — она и есть то, от чего
задача уходит: список из сотни метрик Apple, который устареет в день
появления новой.
- **Вывод по имени метрики** (Graphite, `pattern = \.count$`) — противоречит
инварианту «форма Apple не транслируется» и не работает на именах HAE вовсе.
- **Вывод по единицам** — ломается на краях (находка 40).
- **Заголовок доставки** — врёт уже про слой, оснований верить про род нет.
- **Детекция сброса счётчика** (Prometheus `rate`, Home Assistant
`total_increasing` с допуском 10%) — отвечает на другой вопрос: «был ли
рестарт у известного счётчика», а не «счётчик ли это». К данным Apple
неприменима: монотонного накопителя в них нет, накопительная метрика приходит
уже поинтервальными значениями.
### 2. Родов два, а не четыре — потому что больше нечем измерить
HealthKit различает четыре стиля: `cumulative`, `discreteArithmetic`,
`discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel`
(аудиоэкспозиция, логарифмическое усреднение по энергии). Взять весь словарь
напрашивалось — и отвергнуто **измерением**: часовой слой HAE считается
арифметически, а не по Apple.
Прямое свидетельство даёт `environmental_audio_exposure`: Apple усредняет её
логарифмически, а часовое значение HAE сошлось с обычным арифметическим средним
минутных в 59 часах из 62. У `heart_rate`, который Apple взвешивает по
длительности, часовое значение сходится с арифметическим средним точно в 29
часах из 63 и с точностью 0.1% — в 49; с суммой не сошлось ни разу.
Значит четвёртый и третий стили в наших данных ничем не проявляются, и ввести
их можно было бы только разметкой руками — то есть тем, от чего задача уходит.
Правило проекта прежнее: **род, который нечем измерить, не объявляется.**
Появится источник, различающий больше родов (родной экспорт Apple несёт
интервалы сэмплов), — словарь расширится тем же измерением.
### 3. Род не хранится, а считается на запрос по ограниченному окну
Хранить измеренный род означало бы завести **второе производное состояние**
рядом с витриной: колонку, которую надо пересчитывать после каждой свёртки,
переносить или не переносить пересборкой (перечень в `architecture.md`),
мигрировать и объяснять, на каком составе данных она измерена. Цена ошибки
здесь — молчаливая: устаревшее значение выглядит ровно как свежее.
Считанный на запрос род — по построению функция текущей витрины, а витрина есть
функция журнала. Устареть нечему.
Плата — стоимость чтения, и она ограничена **окном в 48 самых свежих общих
часов** метрики. Измерено на живом корпусе: полное измерение по всем 696 парам
часов всех 31 метрики — 123 мс; окно даёт тот же результат и не даёт стоимости
расти вместе с историей (за год окно ограничивает работу 1488 парами вместо
270 тысяч).
Кеш в памяти сознательно не заводится: он вводит третье представление того же
факта, а вопрос его инвалидации («изменился ли хоть один объект окна») стоит
дороже самого измерения. Появится профиль нагрузки, показывающий обратное, —
кеш добавится с числом в руках.
### 4. В измерении участвуют только `minute` и `hour`
Нижний слой HAE — посекундная развёртка настоящих сэмплов с инфляцией 2.4×
(находка 20) и до 478× у базального обмена (находка 34); его сумма завышена, и
в сверке он не сходится. Слой `sample` из родного экспорта в витрине пока пуст,
а его точки несут собственные интервалы — их сверка с часовым слоем это другая
задача (`healthlog import`).
`day` в измерении не участвует: суточная сводка сна — не разрез часов, а другая
схема под тем же именем (находка 38).
### 5. Значение точки — `qty`, а при его отсутствии `Avg`
Единственное место, знающее, какое поле точки HAE несёт число, — пакет `hae`.
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`
(находка 40), и без второго кандидата самая важная метрика потока не измерялась
бы вовсе. Точка, не несущая ни того, ни другого, в сумму не входит и число
точек часа не увеличивает.
Это чтение, а не интерпретация: значение никуда не пишется и ничего не
подменяет.
**Ноль — значение.** Словарь пустоты из `canon` сюда не годится и применяться не
должен: там ноль объявлен пустотой, чтобы точка без измерений не вытесняла
настоящее измерение при столкновении координат, — вопрос другой. Взяв его,
измерение не увидело бы точки `{"qty":0}`, час выпал бы из счётчиков ещё до
правила различимости, и сценарий «нулевой час свидетельством не является»
позеленел бы по неверной причине. Поэтому разбор здесь свой: `*json.Number` для
обоих полей, отсутствие ключа и `null` — «нет значения», ноль — значение.
**Бесконечность — не значение.** `json.Number("1e400").Float64()` возвращает
`+Inf` вместе с `ErrRange`; проглоченная ошибка отравила бы и сумму, и среднее
всего часа. Значение, не разобравшееся в конечное число, считается
неприсланным.
### 6. `xFilesFactor` здесь не нужен, и это сказано вслух
Graphite и RRDtool закрывают вопрос «что делать со свёрткой неполного ведра»
долей заполненности: ниже порога — не число, а пусто. Паспорт называл это
готовым ответом на вопрос, который у нас ещё не задан.
Измерению порог не нужен, потому что у него **две конкурирующие гипотезы**, а
не одна: неполный минутный час не сходится ни с суммой, ни со средним и
свидетельства не даёт сам собой. Это видно в измерении — у `step_count` 25
часов согласны и 17 не дали ничего; ровно эти 17 и есть неполные часы.
Свёртке в ответе порог понадобится, и вместе с ним — выбор полярности:
Graphite `xFilesFactor` задаёт долю **обязательно известных** (умолчание 0.5 при
роллапе и 0 при рендере — один параметр с двумя умолчаниями), RRDtool `xff`
долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины будут
выглядеть как «0.5», означая противоположное. Решение и его полярность
принимает задача Read API; здесь оно названо, чтобы не решалось дважды.
### 7. Разрезы отвечают по индексу, окно читается пакетом, ответ — из одного снимка
Границу надо назвать точно, иначе она запрещает то, ради чего задача есть:
**не разжимается содержимое ради разрезов и границ; объекты окна измерения
разжимаются обязательно** — сумма минутных значений иначе невычислима. Разжатых
объектов не больше `2 × 48` на метрику.
Диапазоны и число точек лежат учётными колонками объекта (`first_ts`,
`last_ts`, `points`), но `bucket` — таблица `WITHOUT ROWID`, то есть строка
целиком, вместе с `payload`, живёт в самом дереве первичного ключа. Обход всех
строк ради агрегата тащил бы за собой страницы сжатого содержимого: при
260 тысячах объектов за год это сотни мегабайт на каждый запрос каталога.
Поэтому миграция `00009` заводит **покрывающий индекс**
`bucket(metric, layer, hour_utc, first_ts, last_ts, points, units)`: и
агрегат разрезов, и поиск общих часов двух слоёв читают только его. Цена —
около 60 байт на объект (≈16 МБ за год) и одна вставка в дерево на запись
объекта.
Данных индекс не меняет, поэтому в перечне того, что не переносит пересборка,
ему места нет.
**Содержимое разжимается только у часов, прошедших отбор по учётным колонкам.**
Число точек и единицы обеих сторон лежат в покрывающем индексе, а условия
пригодности «у крупного слоя ровно одна точка, у мелкого не меньше двух»
проверяются по ним. Замер на раздутой витрине: 109 МиБ аллокаций при нуле
пригодных часов — вся работа шла до того, как выяснялось, что вердикта не будет.
Отбор ПРЕДВАРИТЕЛЬНЫЙ и строго слабее правила вердикта: числа задаёт домен,
хранилище лишь выбирает по ним строки.
**Объекты окна берутся пакетом и в одной транзакции чтения со всем остальным.**
Существующий `Store.Bucket` открывает собственную read-only транзакцию на каждый
вызов: окно в 48 часов дало бы под сотню транзакций на метрику, а ответ
собрался бы из смеси снимков — разрезы одного состояния витрины, род другого,
причём под непрерывным приёмом и неотличимо от обычного свежего ответа. Проект
уже записал это рассуждение у отпечатка витрины, и второй раз оно разошлось бы
молча.
Поэтому хранилище отдаёт каталогу **один снимок**: агрегат разрезов, общие часы
каждой метрики и объекты её окна — за одну транзакцию чтения, двумя запросами на
метрику плюс один общий. Число обращений к базе перестаёт зависеть от размера
окна. В WAL длинная транзакция чтения писателей не блокирует, а измеренные
130 мс на живом корпусе — цена, которую видно.
### 8. Тай-брейк при равной полноте точек не трогаем — вынут блокером
Задача обещала доделать его «по каталогу», и измерение действительно
подтвердило посылку: четыре из шести метрик, где тай-брейк системно берёт
меньшее значение (находка 49), измерены как накопительные — то есть там это
недосчёт, а у `heart_rate` (самая крупная группа) род мгновенный, и выбор
безразличен.
Но сделать тай-брейк зависящим от **измеренного** рода нельзя: род есть функция
витрины, витрина — результат слияния, и правило слияния, читающее собственную
выдачу, повторяет ровно тот дефект, на котором свёртка уже переставала быть
функцией префикса журнала (`docs/review-journal.md`, 2026-08-01). Остаются
варианты, не зависящие от рода, и выбор между ними — развилка с ценой; она
уходит блокером вместе с измеренным основанием.
### 9. Каталог живёт под токеном чтения
`GET /api/v1/metrics` — первый маршрут, который отдаёт данные наружу, поэтому
здесь же появляется проверка `auth.read_tokens`. Правило то же, что у приёма:
пустой список означает выключенную проверку, и о ней сервис предупреждает на
старте. Токен приёма каталог не открывает — раздельность контуров объявлена
архитектурой, и «пишущий умеет читать» её бы отменило.
Цена симметрии названа вслух, потому что она несимметрична: у приёма открытый
контур означает мусор во входе, у чтения — выгрузку истории здоровья любому, кто
нашёл порт. Отказ старта при пустом списке рассматривался и не взят здесь:
сегодня оба образца конфига в репозитории идут с пустыми списками сознательно
(доверенная локальная сеть), и такой отказ сломал бы `task up` до правки
конфигов, заведя асимметрию с приёмом, которую пришлось бы объяснять. Вопрос
принадлежит задаче об управлении секретами — он там уже стоит, и эта задача
добавляет ему второй контур, а не заводит третье место для того же решения.
Проверка **одна на оба контура**, параметризованная списком: копия отличалась бы
одним полем и несла бы три решения сразу — сравнение за постоянное время,
«пустой список = выключено» и текст 401, — правка любого из них в одном месте не
дала бы ни ошибки компиляции, ни красного теста.
Схема строгая: токеном считается только `Authorization: Bearer <значение>`.
Снисходительности к голому значению у приёма нет и не было; заводить её на
контуре чтения, клиенты которого свои, тем более не за чем.
Отдельно — **редакция заголовков**: сохраняемые заголовки доставки чистятся
подстановкой по списку токенов, и сегодня в этом списке только токены приёма.
Токен чтения, посланный заголовком с произвольным именем, осел бы в базе; список
становится общим.
### 10. Форма ответа
```json
{"metrics": [
{"metric": "step_count",
"units": ["count"],
"aggregation": {"style": "cumulative",
"hours": 48, "compared": 40, "agreeing": 25, "conflicting": 0,
"first_hour": "2026-07-31T09:00:00Z",
"last_hour": "2026-08-02T14:00:00Z"},
"layers": [
{"layer": "minute", "from": "2026-07-30T21:48:00Z",
"to": "2026-08-02T14:59:00Z", "points": 1102},
{"layer": "raw", "from": "…", "to": "…", "points": 25636}]}]}
```
- `units`**массив**: единицы метрики на живом потоке не менялись ни разу
(находка 48), но одна форма поля для обоих случаев честнее строки, которая при
расхождении молча выберет одно из двух. Та же форма, что у самоописания. На
слой при этом приходится ровно один элемент `layers`: строки выборки,
разошедшиеся единицами, схлопываются в общий диапазон и общую сумму точек, а
различие видно множеством единиц метрики. Не поручить это схлопывание явно
значило бы отдать клиенту два элемента с одинаковым `layer` в тот единственный
день, ради которого `units` и сделали массивом.
- `aggregation` — объект, а не строка: он несёт **основание**, и числа в нём
подобраны так, чтобы их разности были осмысленны. `hours` — сколько общих
часов попало в окно, `compared` — сколько из них оказалось пригодными,
`agreeing` и `conflicting` — вердикты пригодных. `hours compared` — часы,
отброшенные проверкой пригодности; `compared agreeing conflicting` — часы,
не сошедшиеся ни с одной гипотезой. Одного числа не хватало: «часов было 48, а
пригодным не оказалось ни одного» и «часов не было вовсе» — разные события.
- Поле называется `style`, а не `kind`: слово `kind` в проекте уже занято родом
секции записи (`record.kind`), и два смысла под одним именем в одном API — это
сноска в документации навсегда. `style` — слово HealthKit
(`HKQuantityAggregationStyle`) для ровно этого понятия.
- Значения рода остаются `cumulative` / `instant` / `unknown`. `cumulative`
совпадает со словарём HealthKit; `instant` не совпадает ни с чьим (у Apple
`discrete`, у Prometheus `gauge`, у Home Assistant `measurement`) — и взят
сознательно: `discrete` описывает **природу сэмпла**, а мы называем то, что
измерили, — свёртку средним. Архитектура пользуется словом «мгновенная» с
самого начала, и менять словарь ради чужого сходства значило бы переименовать
понятие, не изменив его.
- `first_hour`/`last_hour` вместо `from`/`to` — потому что это **ярлыки часов**,
а не метки данных: у слоя `to` — метка последней точки (`…14:59:00Z`), у окна
— начало последнего часа окна (`…14:00:00Z`), включая непригодные. Одно имя для двух
семантик в одном ответе стоило бы клиенту ошибки на час, заметной только
расхождением сумм.
- `layers` — только то, что есть. Числа часовых объектов в ответе нет: объект —
деталь хранения, клиент про него не знает.
- Поля присутствуют всегда, в том числе со значением `null`: клиент не должен
выводить смысл из наличия или отсутствия ключа. Это требует внимания к
нулевым значениям Go: nil-срез сериализуется в `null`, а нулевой `time.Time`
в правдоподобную метку `0001-01-01T00:00:00Z`, неотличимую от данных. Поэтому
срезы конструируются пустыми, границы окна — указателями, а приёмочный тест
сравнивает **байты** ответа с литералом, а не разобранную структуру с
разобранной.
- Порядок метрик и слоёв детерминирован: два ответа на неизменившейся витрине
обязаны совпасть побайтово, иначе «повторный запрос не опирается на прошлый»
нечем проверить.
## Risks / Trade-offs
- **Род измеряется по свежему окну, а метрика могла его сменить в прошлом** →
окно и его границы отдаются в ответе (`from`/`to`, `compared`), то есть род
объявлен вместе с периодом, на котором измерен. Так же поступает Home
Assistant, признавая смену `state_class` разрушительным событием, а не
уточнением поля.
- **Метрика приходит только в одном слое — род не измерить никогда** → штатный
`unknown` с `compared: 0`. Сегодня это 14 метрик из 31, в том числе
`sleep_analysis` и `heart_rate_variability`. Лечится не кодом, а второй
автоматизацией HAE на том же наборе метрик.
- **Стоимость каталога растёт с числом метрик** (~4 мс на метрику на живом
корпусе) → окно ограничивает вклад каждой; при сотне метрик это порядка
полусекунды. Число измерено и попадёт в отчёт; кеш заводится по профилю
нагрузки, а не заранее.
- **Часовой слой HAE — тоже досчитываемое задним числом значение** (находка 10)
→ свежайший час окна может быть неполным и вердикта не дать. На исход это не
влияет: неполный час просто не свидетельствует, а окно в 48 часов заведомо
содержит устоявшиеся.
- **Каталог метрик не говорит о невосстановимости `stateOfMind`** — секция живёт
в `record`, а не в метриках, и её единственный источник это доставки HAE
(находка 46). Граница названа: за это отвечает ретеншен и перечень
непокрытого, а не каталог разрезов.
- **Покрывающий индекс удорожает запись объекта** → одна вставка в дерево на
объект; широкий проход, у которого хеш сошёлся, объект не переписывает вовсе,
поэтому цену платят только настоящие изменения.
- **Род дребезжит вместе со скользящим окном**: час, въехавший в окно, может
сменить `cumulative` на `unknown` без единой новой доставки за спрошенный
период, и для агента это выглядит поломкой сервиса → следствие принято вслух и
снабжено двумя средствами. Первое — основание измерения в ответе: клиент
видит, что изменилось и почему. Второе — `WARN` при появлении противоречащих
часов: событие адресовано владельцу, потому что лечится оно настройкой
автоматизаций HAE, а не кодом. Смягчать правило долей согласных вместо
единогласия отвергнуто: это объявление рода при известном контрпримере.
- **Пустой список токенов чтения открывает историю здоровья** → предупреждение на
старте и запись цены в образцах конфига; отказ старта рассмотрен и оставлен
задаче об управлении секретами (решение 9). До выкладки наружу это домашняя
сеть, после — блокирующее условие деплоя, и оно уже записано там.
- **Каталог — первая ручка, где повторный запрос стоит заметного CPU** (порядка
4 мс на метрику) → предела на размер ответа и тайм-аута у него нет, потому что
и то, и другое — правило Read API, которое пишется следующей задачей вместе с
остальными его маршрутами. Названо, чтобы не оказалось забытым.
@@ -0,0 +1,59 @@
## Why
Read API обязан уметь сворачивать метрику к запрошенной сетке, а род свёртки
(сумма или среднее) HAE не присылает: `Avg`/`Min`/`Max` есть только у
`heart_rate`, всё остальное приходит в `qty` (находка 40), заголовок доставки
про род молчит, а единицы врут на краях. Просуммировать мгновенную метрику или
сложить нижний слой HAE значит завысить ответ втрое — поэтому род измеряется
**до** Read API, а не угадывается внутри него.
Второй пробел того же размера: потребитель не может спросить «что у тебя вообще
есть». Слои и их диапазоны — часть контракта (после пересборки старый период
законно теряет верхние слои), и узнать их сегодня можно только через sqlite на
хосте.
## What Changes
- **Род агрегации измеряется сверкой минутного и часового слоёв между собой**:
часовое значение сходится с суммой минутных — метрика накопительная, с
арифметическим средним — мгновенная, ни с тем ни с другим или свидетельства
противоречат — `unknown`. Измерено на живом архиве (123 доставки, 31 метрика):
17 метрик классифицируются, 14 остаются `unknown`, **противоречивых
свидетельств ноль**.
- **Нижний слой (`raw`) в измерении не участвует и не суммируется никогда** — он
посекундная развёртка, а не сэмплы (находка 34).
- **Новая ручка `GET /api/v1/metrics`** — каталог: по каждой метрике единицы,
измеренный род с основанием измерения и список слоёв с диапазонами и числом
точек. Первый маршрут под токеном чтения; появляется проверка этого токена.
- **Род нигде не хранится**: он производен от витрины и считается на запрос по
ограниченному окну. Ни новой колонки, ни строки в перечне того, что не
переносит пересборка.
- Схема получает **только индекс** (`00009`): разрезы и границы обязаны
отвечать, не разжимая содержимое объектов. Само измерение содержимое читает —
иначе сумму минутных значений не получить, — но не больше `2 × 48` объектов на
метрику и одной транзакцией чтения на весь ответ.
- Тай-брейк при равной полноте точек **в этой задаче не меняется** — вынут
блокером: род измеряется из витрины, а витрина есть результат слияния, и
правило слияния, читающее собственную выдачу, повторяет дефект вывода слоя из
журнала ревью.
## Capabilities
### New Capabilities
- `catalog`: каталог разрезов и измеренный род агрегации — что за метрики есть,
в каких слоях, за какие периоды и какая свёртка по ним осмысленна.
### Modified Capabilities
- `storage`: каталог отвечает по учётным полям объекта, не разжимая `payload`;
отсюда требование к стоимости выборки разрезов.
## Impact
- Новый пакет `internal/catalog` — измерение рода и сборка каталога.
- `internal/store` — выборка разрезов метрики и общих часов двух слоёв.
- `internal/hae` — единственное место, знающее, какое поле точки несёт число.
- `internal/httpapi` — маршрут каталога и проверка токена чтения.
- `internal/store/migrations/00009_bucket_catalog.sql` — покрывающий индекс.
- Документация: `architecture.md` (метод измерения и отвергнутые чужие решения),
`database.md` (индекс), `local-research.md` (находка о результате измерения),
`config.example.toml` (read_tokens перестали быть заделом на будущее).
@@ -0,0 +1,517 @@
## ADDED Requirements
### Requirement: Каталог разрезов отдаёт наблюдаемое состояние витрины
Система SHALL отдавать каталог метрик, где по каждой метрике перечислены
единицы и слои с границами данных и числом точек. Каталог MUST показывать
только то, что в витрине есть: досчитывать отсутствующий слой,
экстраполировать границы или помнить о том, чего больше нет, он MUST NOT.
**Границы слоя — это границы данных, а не обещание покрытия.** Внутри
диапазона законно есть дыры: часы, за которые доставок не было, и периоды,
верхние слои которых не пережили пересборку. Поэтому правило выбора слоя в
Read API MUST опираться на фактические объекты запрошенного диапазона, а не
считать каталожную пару границ доказательством непрерывности.
Слои — часть контракта, а не деталь хранения: без каталога вопрос «в каком
разрезе спрашивать» не задать. Часовой объект при этом деталью остаётся, и его
число в ответ не идёт.
Отсюда честность после пересборки: экспорт Apple восстанавливает только слой
`sample`, а `minute` и `hour` за периоды с удалёнными доставками не воскресают.
Метрика, потерявшая слой целиком, объявляет его отсутствие тем, что слоя нет в
списке.
Метрика с пустым именем — законное значение колонки, и каталог MUST показывать
её наравне с остальными: терять на границе, которая отвечает «что у тебя вообще
есть», нельзя ничего.
Единицы отдаются **множеством различных значений** метрики, отсортированным и
ограниченным потолком (пустые в множество не входят):
на живом потоке они не менялись ни разу, но одна форма поля для обоих случаев
честнее строки, которая при расхождении молча выберет одно из двух. На слой при
этом приходится **ровно один** элемент списка: объекты слоя с разными единицами
дают общий диапазон и общую сумму точек, а различие видно множеством единиц
метрики.
#### Scenario: Метрика лежит в нескольких слоях
- **WHEN** у метрики есть объекты в слоях `raw`, `minute` и `hour`
- **THEN** каталог перечисляет все три слоя, у каждого — границы данных и число
точек
#### Scenario: Слоя за период не осталось
- **GIVEN** витрина пересобрана, и у метрики остались объекты только слоя
`sample`
- **WHEN** запрашивается каталог
- **THEN** у метрики объявлен слой `sample` и не объявлены `minute` и `hour`
#### Scenario: Внутри диапазона слоя есть дыра
- **GIVEN** у метрики есть объекты слоя `minute` за январь и за июнь, а между
ними нет ни одного
- **WHEN** запрашивается каталог
- **THEN** слой `minute` объявлен один раз с границами от января до июня, и
каталог не утверждает, что данные есть за весь этот период
#### Scenario: Единицы метрики разошлись
- **GIVEN** объекты одной метрики несут разные единицы
- **WHEN** запрашивается каталог
- **THEN** множество единиц метрики содержит оба значения, а слой остаётся одним
элементом списка с объединённым диапазоном и суммой точек
#### Scenario: Метрика приехала без имени
- **GIVEN** в витрине есть объекты метрики с пустым именем
- **WHEN** запрашивается каталог
- **THEN** метрика присутствует в ответе со своими слоями
#### Scenario: Единиц у метрики стало неправдоподобно много
- **GIVEN** объекты метрики несут десятки различных строк единиц
- **WHEN** запрашивается каталог
- **THEN** множество единиц в ответе ограничено потолком, а слой остаётся одним
элементом
#### Scenario: Витрина пуста
- **WHEN** в витрине нет ни одного объекта
- **THEN** каталог отдаёт пустой список метрик, а не отказ
### Requirement: Форма ответа каталога
Система SHALL отдавать каталог по маршруту `GET /api/v1/metrics` в виде объекта
с полем `metrics`. Каждая запись MUST нести поля `metric`, `units`,
`aggregation` и `layers`; элемент `layers``layer`, `from`, `to`, `points`;
объект `aggregation``style`, `hours`, `compared`, `agreeing`, `conflicting`,
`first_hour`, `last_hour`.
Все перечисленные поля MUST присутствовать всегда, в том числе со значением
`null`: клиент не должен выводить смысл из наличия или отсутствия ключа. Пустой
список MUST отдаваться как `[]`, а не как `null`, и отсутствие измеренного окна
— как `null`, а не как нулевая метка времени: правдоподобная дата в ответе
неотличима от настоящей.
Семантика границ различна, поэтому имена различны:
- `from`/`to` слоя — метки **первой и последней точки** слоя, включительно;
- `first_hour`/`last_hour`**ярлыки часов**, первого и последнего часа окна
измерения, включительно.
Порядок метрик и слоёв в ответе MUST быть детерминированным, чтобы два ответа
на одинаковом состоянии витрины совпадали побайтово.
Поле `style` называет род (`cumulative` / `instant` / `unknown`), а не «kind»:
слово `kind` в проекте уже занято родом секции записи (`record.kind`), и два
разных смысла под одним именем в одном API — вечная сноска.
#### Scenario: Пустая витрина отдаётся пустым списком
- **WHEN** каталог запрашивается на пустой витрине
- **THEN** тело ответа — `{"metrics":[]}`
#### Scenario: Род не измерен
- **WHEN** у метрики нет общих часов двух слоёв
- **THEN** `style` равен `unknown`, `hours` равен нулю, а `first_hour` и
`last_hour` равны `null`
#### Scenario: Два запроса подряд дают один ответ
- **WHEN** каталог запрашивается дважды на неизменившейся витрине
- **THEN** тела ответов совпадают побайтово
### Requirement: Число точки берётся из одного объявленного поля
Система SHALL считать числом точки значение поля `qty`, а при его отсутствии —
значение поля `Avg`, и MUST NOT выводить число из других полей.
Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`, и
без второго кандидата самая важная метрика потока не измерялась бы вовсе.
**Ноль — значение, а не отсутствие.** Правило пустоты, принятое для сравнения
полноты точек, здесь неприменимо: там ноль считается пустотой, чтобы точка без
измерений не вытесняла настоящее измерение, а тут нулевой час обязан дойти до
правила различимости и быть отброшенным им, а не исчезнуть раньше и молча.
Значение, которое не разбирается как конечное число (строка, `null`, объект,
переполнение), считается неприсланным: бесконечность, попавшая в сумму,
отравляет и сумму, и среднее всего часа.
Точка без числа в сумму не входит и число точек часа не увеличивает.
К `Avg` система переходит только при **отсутствующем или `null`** `qty`. `qty`
не того типа означает, что форма точки изменилась, и догадываться о числе не о
чем: точка считается не несущей значения целиком.
#### Scenario: Точка несёт только qty
- **WHEN** точка имеет вид `{"qty":72.5,"date":"…"}`
- **THEN** её число равно `72.5`
#### Scenario: Точка несёт Min/Avg/Max без qty
- **WHEN** точка имеет вид `{"Min":60,"Avg":70,"Max":80,"date":"…"}`
- **THEN** её число равно значению `Avg`
#### Scenario: Нулевое значение остаётся значением
- **WHEN** точка имеет вид `{"qty":0,"date":"…"}`
- **THEN** её число равно нулю, и точка считается несущей значение
#### Scenario: Значение не разбирается как конечное число
- **WHEN** точка несёт `qty` строкой или числом вне диапазона `float64`
- **THEN** точка считается не несущей значения и в сумму не входит
### Requirement: Род агрегации выводится сверкой минутного и часового слоёв
Система SHALL выводить род агрегации метрики (`cumulative` / `instant` /
`unknown`) сравнением её часового слоя с минутным и MUST NOT определять его по
имени метрики, единицам, форме точки или заголовку доставки.
Час **пригоден** для сверки, когда выполнено всё:
- у метрики есть объекты обоих слоёв за этот час;
- час не лежит в будущем — его метка не позже текущего времени плюс запас;
- единицы обоих объектов совпадают;
- часовой объект несёт ровно одну точку, и она несёт значение, а её метка
совпадает с началом часа;
- у минутного объекта не меньше двух точек со значением;
- сумма минутных значений **отличима** от их среднего.
**Горизонт обязателен, и это не защита от вредителя, а условие корректности.**
Час объекта берётся из метки в теле доставки, а тело не наше: одна доставка с
метками в будущем занимает окно целиком и подменяет измеренный род метрики —
построено и прогнано, мгновенная метрика объявлялась накопительной при нуле
противоречащих часов. Запас нужен на расхождение часов телефона и сервера.
Данные, помеченные будущим, MUST порождать предупреждение владельцу: это либо
сбитые часы, либо чужое тело, и оба случая лечатся не кодом.
**Совпадение единиц обязательно.** Мгновенная метрика, приехавшая минутным
слоем в `count/min` и часовым в `count/hour`, даёт в полном часе
`часовое = 60 · среднее = сумма` — то есть **уверенный ложный** `cumulative` при
нуле противоречащих часов. Правило единогласия этот случай не ловит по
построению: противоречия нет, есть молчание.
**Часовой объект несёт ровно одну точку.** Две точки за час описывают разные
интервалы, и какая из них относится к часу целиком — неизвестно; час непригоден
целиком, а не «по той, у которой есть значение».
Требование выравнивания часовой метки закрывает зоны с неполночасовым
смещением: слой выводится по выравниванию метки в исходной зоне, а объект
адресуется часом UTC, поэтому в зоне `+0530` часовая точка описывает не тот
интервал, который покрывают минутные точки того же объекта. Сравнивать их
нельзя, и такой час свидетельства не даёт.
Требование различимости обязательно: в часе, где все значения нули, сумма равна
среднему, и совпадение с любой из гипотез не значит ничего.
Все три сравнения — «сходится с суммой», «сходится со средним», «сумма отличима
от среднего» — MUST выполняться **одним предикатом с одним допуском**:
относительным, величиной `1e-9`. Тогда час, подтверждающий обе гипотезы сразу,
невыразим по построению, и исход не зависит от порядка веток.
Величина названа числом, потому что от неё зависят счётчики основания в ответе:
измерено, что вердикты метрик на живом корпусе одинаковы при допуске от `1e-9`
до `1e-3`, а число согласных часов у `heart_rate` при этом меняется с 29 на 49.
Взято строгое значение: канонизация содержимого округляет числа до 12 значащих
цифр, то есть всё, что крупнее `1e-12`, представлением не объясняется, а
`1e-9` оставляет три порядка запаса и остаётся на шесть порядков строже любого
содержательного расхождения (сумма и среднее при `n ≥ 2` различаются не меньше
чем вдвое).
Абсолютного порога у сравнения нет намеренно: около нуля относительный допуск
вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не
ложный род.
Вердикт пригодного часа: часовое значение сходится с суммой минутных —
`cumulative`, со средним — `instant`, иначе час свидетельства не даёт.
Сумма минутных значений MUST считаться в порядке возрастания метки точки, чтобы
вердикт не зависел от порядка точек внутри объекта.
#### Scenario: Часовое значение равно сумме минутных
- **GIVEN** у метрики есть минутный и часовой объекты за один час
- **WHEN** часовое значение сходится с суммой минутных значений
- **THEN** метрика получает род `cumulative`
#### Scenario: Часовое значение равно среднему минутных
- **WHEN** часовое значение сходится со средним минутных значений
- **THEN** метрика получает род `instant`
#### Scenario: Нулевой час свидетельством не является
- **GIVEN** все минутные значения часа равны нулю, и часовое значение тоже
- **WHEN** измеряется род
- **THEN** этот час непригоден и в подсчёт согласных не идёт
#### Scenario: Час лежит в будущем
- **GIVEN** доставка принесла объекты обоих слоёв с метками позже текущего
времени
- **WHEN** измеряется род
- **THEN** эти часы в окно не входят, род остаётся измеренным по настоящей
истории, и владельцу пишется предупреждение
#### Scenario: Единицы слоёв разошлись
- **GIVEN** минутный объект часа несёт одни единицы, а часовой — другие
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Часовой объект несёт две точки
- **GIVEN** у метрики за час есть часовой объект с двумя точками
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Минутный объект несёт одну точку
- **GIVEN** минутный объект часа несёт единственную точку
- **WHEN** измеряется род
- **THEN** час непригоден: сумма и среднее совпадают, различить гипотезы нечем
#### Scenario: Метка часовой точки не выровнена на начало часа
- **GIVEN** часовая точка стоит на середине часа UTC
- **WHEN** измеряется род
- **THEN** час непригоден и свидетельства не даёт
#### Scenario: Форма точки на исход не влияет
- **WHEN** метрика приходит только с полем `qty`, без `Avg`/`Min`/`Max`
- **THEN** род всё равно измеряется сверкой слоёв, а не выводится из формы
### Requirement: Род объявляется только при единогласном свидетельстве
Система SHALL объявлять род метрики, только если согласных часов не меньше трёх
и ни один час не дал противоположного вердикта. В остальных случаях род MUST
быть `unknown`, и агрегация по такой метрике предлагаться MUST NOT.
Наличие противоречащих часов MUST быть записано чекпоинтом уровня `WARN` с
именем метрики и числами основания, без значений точек: род — свойство, на
котором Read API строит арифметику года, и его смена не имеет права проходить
молча. На живом корпусе противоречащих часов не встретилось ни разу, поэтому
шума правило не создаёт.
Единогласие, а не большинство: противоречащий час означает, что одна из гипотез
для этой метрики ложна, и объявлять род при известном контрпримере нельзя. Порог
в три часа — потому что на этом роде потом суммируют год, а один совпавший час
остаётся свидетельством одного часа.
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
может сменить объявленный род без единой новой доставки за спрошенный период.
Клиент, которому это важно, различает случаи по основанию измерения — оно
отдаётся вместе с родом.
#### Scenario: Свидетельства противоречат
- **GIVEN** у метрики есть часы с вердиктом `cumulative` и часы с вердиктом
`instant`
- **WHEN** измеряется род
- **THEN** род равен `unknown`, число противоречащих часов отдаётся в каталоге,
и пишется `WARN` с именем метрики
#### Scenario: Свидетельств мало
- **WHEN** согласных часов меньше трёх
- **THEN** род равен `unknown`
#### Scenario: Второго слоя нет вовсе
- **WHEN** метрика лежит только в одном слое
- **THEN** род равен `unknown`, а число часов окна равно нулю
### Requirement: Нижний слой в измерении не участвует
Система SHALL измерять род только по слоям `minute` и `hour` и MUST NOT
использовать в сверке слои `raw`, `sample` и `day`.
Нижний слой HAE — не сэмплы, а посекундная развёртка настоящих сэмплов с
инфляцией до 478×: его сумма завышена и в сверке не сходится. Слой `sample`
несёт собственные интервалы сэмплов, и его сверка с часовым слоем — другая
задача, вместе с импортом родного экспорта. Слой `day` — суточная сводка сна,
другая схема под тем же именем, а не разрез часов.
#### Scenario: Метрика есть только в нижнем слое
- **WHEN** у метрики есть объекты только в слое `raw`
- **THEN** род равен `unknown`
#### Scenario: Нижний слой не подменяет минутный
- **GIVEN** у метрики есть слои `raw` и `hour`, но нет `minute`
- **WHEN** измеряется род
- **THEN** сверка не выполняется и род равен `unknown`
#### Scenario: Метрика лежит только в суточном слое
- **WHEN** у метрики есть объекты только слоя `day`
- **THEN** слой объявлен в каталоге, а род равен `unknown`
### Requirement: Каталог отдаёт основание измерения, а не только вывод
Система SHALL отдавать вместе с родом четыре числа и границы окна, и клиент MUST
иметь возможность отличить «свидетельств не было» от «свидетельства
противоречат», не делая второго запроса.
Числа определены так, что их разность осмысленна:
- `hours` — сколько общих часов двух слоёв попало в окно;
- `compared` — сколько из них оказалось **пригодными**;
- `agreeing` — сколько пригодных часов дали **преобладающий** вердикт (при
объявленном роде это он и есть);
- `conflicting` — сколько дали другой.
Разложение одно и то же независимо от того, объявлен род или нет: иначе
`agreeing` пришлось бы толковать по-разному в двух ветках, и клиент читал бы
одно поле двумя способами.
Разность `compared agreeing conflicting` — часы, не сошедшиеся ни с одной
гипотезой; разность `hours compared` — часы, отброшенные проверкой
пригодности. Без этого различения `hours` в одиночку выдавал бы «измерение шло,
данные молчат» там, где ни один час не был пригоден вовсе.
`first_hour` и `last_hour` — границы окна; род объявляется вместе с периодом, на
котором измерен, потому что окно ограничено самыми свежими общими часами, а не
всей историей.
#### Scenario: Род измерен
- **WHEN** метрика получила род `cumulative`
- **THEN** рядом стоят число часов окна, число пригодных, число согласных, ноль
противоречащих и границы окна
#### Scenario: Часы были, но ни один не пригоден
- **WHEN** все часы окна отброшены проверкой пригодности
- **THEN** `hours` больше нуля, `compared` равен нулю, род равен `unknown`
### Requirement: Окно измерения ограничено сорока восемью часами
Система SHALL измерять род по не более чем 48 самым свежим общим часам метрики
и MUST NOT читать ради этого всю историю: стоимость каталога не имеет права
расти вместе с журналом.
Число названо в спеке, а не оставлено реализации, по той же причине, что и
порог согласных часов: от него зависят счётчики основания в ответе.
Измерено, что на живом корпусе окно сохраняет вердикты всех метрик, кроме
редких: у `physical_effort` за всю историю набиралось пять согласных часов, а в
последних сорока восьми — два, и метрика честно уходит в `unknown`. Это не
издержка, а то же правило: свидетельств в свежем окне действительно мало.
Окно ограничено и сверху — часами не позже текущего времени плюс запас, см.
правило пригодности часа.
#### Scenario: История длиннее окна
- **GIVEN** у метрики общих часов больше сорока восьми
- **WHEN** измеряется род
- **THEN** сравниваются только сорок восемь самых свежих, и `hours` равен
сорока восьми
### Requirement: Измеренный род нигде не сохраняется
Система SHALL вычислять род при каждом запросе каталога и MUST NOT хранить его
ни колонкой, ни кешем.
Хранимое значение было бы вторым производным состоянием рядом с витриной: его
пришлось бы пересчитывать после каждой свёртки, переносить или не переносить
пересборкой и объяснять, на каком составе данных оно снято; устаревшее значение
при этом выглядит ровно как свежее. Вычисленный на запрос род есть функция
витрины, а витрина — функция журнала, и устаревать в нём нечему.
#### Scenario: Новая доставка меняет род без перезапуска
- **GIVEN** метрика числится `unknown`, потому что общих часов было мало
- **WHEN** приезжает доставка, добавляющая согласные часы, и каталог
запрашивается снова
- **THEN** ответ отдаёт новый род, и перезапуск сервиса для этого не нужен
### Requirement: Каталог читается одним снимком витрины
Система SHALL собирать ответ каталога из одного снимка базы: разрезы, границы и
объекты окна измерения MUST читаться в одной транзакции чтения.
Приём идёт непрерывно, и фоновая свёртка пишет в витрину во время запроса.
Запросы вне общей транзакции дали бы смесь «разрезы до» и «род после» — ответ,
внутренне противоречивый и неотличимый от обычного свежего.
Число обращений к хранилищу на один запрос каталога MUST быть ограничено
константой на метрику и не зависеть от размера окна: чтение объектов окна по
одному даёт тысячи обращений там, где хватает двух на метрику.
#### Scenario: Доставка приезжает во время сборки каталога
- **GIVEN** каталог собирается, и в этот момент фоновая свёртка пишет объекты
- **WHEN** ответ сформирован
- **THEN** он целиком описывает одно состояние витрины
#### Scenario: Размер окна не умножает число запросов
- **WHEN** окно измерения увеличено
- **THEN** число обращений к хранилищу на метрику не меняется
### Requirement: Каталог доступен по токену чтения
Система SHALL требовать токен чтения на маршруте каталога и MUST NOT принимать
на нём токен приёма. Токен MUST передаваться заголовком `Authorization` со
схемой `Bearer`; значение без этой схемы токеном не считается.
Пустой список токенов чтения означает выключенную проверку, и о выключенной
проверке сервис предупреждает на старте — тем же способом, что о выключенной
проверке приёма. Цена симметрии названа вслух: у приёма открытый контур означает
мусор во входе, у чтения — выгрузку данных о здоровье, поэтому перед выкладкой
наружу список обязан быть непуст. Отвечает за это отдельная задача об управлении
секретами; здесь фиксируется, что предупреждение существует и адресовано
владельцу.
Токен чтения MUST вычищаться из сохраняемых заголовков доставки наравне с
токеном приёма: заголовок с произвольным именем иначе донесёт его до базы.
Контуры раздельны по архитектуре: клиент, читающий данные, писать не может, и
обратное тоже неверно.
#### Scenario: Запрос без токена при заданном списке
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается без заголовка `Authorization`
- **THEN** ответ — 401, и данные не отдаются
#### Scenario: Токен приёма каталога не открывает
- **GIVEN** заданы разные списки токенов приёма и чтения
- **WHEN** каталог запрашивается с токеном приёма
- **THEN** ответ — 401
#### Scenario: Токен без схемы Bearer
- **GIVEN** список токенов чтения непуст
- **WHEN** каталог запрашивается с заголовком `Authorization`, где стоит голое
значение токена без слова `Bearer`
- **THEN** ответ — 401
#### Scenario: Проверка выключена
- **GIVEN** список токенов чтения пуст
- **WHEN** каталог запрашивается без заголовка `Authorization`
- **THEN** каталог отдаётся
#### Scenario: О выключенной проверке предупреждают на старте
- **GIVEN** список токенов чтения пуст
- **WHEN** сервис стартует
- **THEN** в логе появляется предупреждение владельцу
#### Scenario: Токен чтения не оседает в учёте доставки
- **GIVEN** токен чтения послан на маршрут приёма заголовком с произвольным
именем
- **WHEN** доставка учтена
- **THEN** в сохранённых заголовках вместо значения стоит пометка о сокрытии
@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: Перечисление разрезов не читает содержимое объектов
Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
которые у метрики есть объекты сразу в двух слоях.
Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
значения точек, то есть разжать содержимое объектов окна, и требование его не
касается — иначе оно запрещало бы то, ради чего каталог существует.
Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
ответ несёт три десятка строк.
Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.
#### Scenario: Разрезы метрики за длинную историю
- **GIVEN** в витрине объекты за многие месяцы
- **WHEN** запрашиваются слои метрики с границами и числом точек
- **THEN** запрос отвечает по индексу, не читая содержимого объектов
#### Scenario: Общие часы двух слоёв
- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
в минутном, и в часовом слое
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
часов
### Requirement: Объекты перечисленных часов читаются пакетом
Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
метрики **одним запросом**, а не по объекту за раз.
Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
разных снимков витрины под непрерывным приёмом.
#### Scenario: Окно из многих часов
- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
- **WHEN** выполняется выборка
- **THEN** число обращений к базе не зависит от числа часов
@@ -0,0 +1,133 @@
## 1. Схема
- [x] 1.1 Миграция `00009_bucket_catalog.sql` — покрывающий индекс
`bucket(metric, layer, hour_utc, first_ts, last_ts, points, units)`
- [x] 1.2 Обновить `docs/database.md`: индекс и зачем он
## 2. Хранилище
- [x] 2.1 `store.ReadCatalog(ctx, store.CatalogWindow)` — весь вход каталога
**одной транзакцией чтения**: разрезы всех метрик, общие часы пары слоёв
по каждой метрике, объекты окна обоих слоёв
- [x] 2.2 Разрезы — одним запросом `metric, layer, units, min(first_ts),
max(last_ts), sum(points)`, группировка вместе с единицами (расхождение
видно, а не выбирается молча)
- [x] 2.3 Общие часы — самые свежие часы с объектами обоих слоёв, от свежих к
старым, не больше `window`
- [x] 2.4 Объекты окна — **пакетом**, один запрос на метрику на оба слоя
(`hour_utc IN (…)`), а не по объекту за раз
- [x] 2.5 Тест: разрезы и общие часы отвечают по индексу (`EXPLAIN QUERY PLAN`
не содержит обхода таблицы)
- [x] 2.6 Тест: число обращений к базе на метрику не зависит от размера окна
- [x] 2.7 Тест: `CommonHours` отдаёт не больше `window` и именно свежие часы
## 3. Значение точки
- [x] 3.1 `hae.PointValue(raw)` — число точки: `qty`, при его отсутствии `Avg`;
разбор через `*json.Number`, **ноль — значение**, отсутствие ключа и
`null` — нет значения, нечисловое и не влезающее в `float64` (`ErrRange`,
`±Inf`) — нет значения
- [x] 3.2 В док-комментарии сказать, что словарь пустоты `canon` сюда не
применяется, и почему
- [x] 3.3 Тесты на реальных формах точки из `testdata`: `heart_rate` с
`Min`/`Avg`/`Max` без `qty`, `{"qty":0}`, точка без числового поля,
`1e400`
## 4. Измерение рода
- [x] 4.1 Пакет `internal/catalog`: тип рода (`cumulative`/`instant`/`unknown`,
пустое значение невыразимо) и основание измерения
(`hours`/`compared`/`agreeing`/`conflicting`/границы окна)
- [x] 4.2 Именованная константа допуска `1e-9` с измеренной ценой в комментарии
и **один** предикат `close(a, b)`, которым выражены все три сравнения
- [x] 4.3 Пригодность часа: ровно одна точка со значением у часового объекта,
её метка совпадает с началом часа, ≥2 точек со значением у минутного,
сумма отличима от среднего
- [x] 4.4 Сумма минутных значений считается в порядке возрастания метки
- [x] 4.5 Правило метрики: ≥3 согласных и 0 противоречащих, иначе `unknown`
- [x] 4.6 Окно 48 самых свежих общих часов
- [x] 4.7 Сборка каталога: разрезы из снимка + род из измерения; строки слоя,
разошедшиеся единицами, схлопываются в один элемент, множество единиц
метрики отсортировано
- [x] 4.8 Чекпоинт `WARN` при `conflicting > 0`: имя метрики и числа основания,
без значений точек
- [x] 4.9 Тесты: накопительная, мгновенная, нулевой час, двухточечный часовой
объект, одноточечный минутный, невыровненная часовая метка, противоречие,
единственный слой, только слой `day`, история длиннее окна, разошедшиеся
единицы, идемпотентность двух вызовов
## 5. HTTP
- [x] 5.1 Проверка токена **одна на оба контура**, параметризованная списком;
схема строгая (`Bearer`), пустой список = выключено
- [x] 5.2 Предупреждение на старте о выключенной проверке чтения
- [x] 5.3 Редакция сохраняемых заголовков доставки чистит токены **обоих**
контуров
- [x] 5.4 `GET /api/v1/metrics` — форма ответа из дизайна: `style`, `hours`,
`compared`, `agreeing`, `conflicting`, `first_hour`, `last_hour`; срезы
пустые, а не nil; границы окна — указатели; порядок детерминирован
- [x] 5.5 Тесты: 401 без токена, 401 с токеном приёма, 401 с голым значением без
схемы, отдача при выключенной проверке, пустая витрина — **сравнением
байтов** ответа с литералом
- [x] 5.6 `config.example.toml` и `config.docker.toml`: `read_tokens` перестал
быть заделом на будущее, цена пустого списка названа комментарием
## 6. Проверка на живом архиве
- [x] 6.1 Прогон измерения в `internal/replay/archive_test.go`: свойства, а не
числа — конфликтующих свидетельств ноль; накопительные и мгновенные
метрики разошлись по родам; ни одна метрика не измерена по слою `raw`
- [x] 6.2 Печать измеренного рода по метрикам и стоимости каталога в `t.Logf`
- [x] 6.3 `task verify:archive` зелёный, отпечаток витрины не изменился
## 7. Документация и беклог
- [x] 7.1 `docs/architecture.md`: метод измерения, окно, порог, допуск, почему
род не хранится, почему родов два, а не четыре, где нужен `xFilesFactor` и
какой у него подвох с полярностью
- [x] 7.2 `docs/architecture.md`: форма каталога приведена к реализованной
- [x] 7.3 `docs/local-research.md`: находка с результатом измерения на живом
корпусе
- [x] 7.4 Блокер «тай-брейк при равной полноте точек» в беклог, с вариантами,
ценой и рекомендацией
- [x] 7.5 Пометка в `docs/backlog/read-api-tochki.md`: порог неполного ведра,
его полярность и предел размера ответа решаются там
- [x] 7.6 Пометка в `docs/backlog/upravlenie-sekretami.md`: контуров теперь два
- [x] 7.7 Убрать задачу из беклога, обновить индекс
## 8. Дозакрыто по ревью кода
- [x] 8.0 Горизонт окна: часы позже `now + час` в сверку не входят, данные из
будущего пишутся `WARN`
- [x] 8.0 Единицы обеих сторон обязаны совпасть — иначе уверенный ложный род
- [x] 8.0 Содержимое разжимается только у часов, прошедших отбор по учётным
колонкам
- [x] 8.0 Имя метрики в логе обрезано, множество единиц ограничено потолком
- [x] 8.0 Метрика с пустым именем показывается, а не выбрасывается сентинелом
- [x] 8.0 Отмена снаружи не пишется как сбой сервиса
- [x] 8.0 Байтовый тест непустого ответа и повтора запроса
## 9. Приёмочные критерии (рубрика ревью предложения)
- [x] 9.1 Вердикт — чистая функция состояния витрины и окна: не зависит от
порядка строк SQL, порядка точек в объекте и момента вызова
- [x] 9.2 Допуск назван величиной, один предикат на все сравнения, поведение
около нуля объявлено
- [x] 9.3 Вырожденные свидетельства исключены явно, кворум назван числом, ниже
кворума исход — `unknown`, а не умолчание
- [x] 9.4 `unknown` — исход первого класса, и его причины различимы клиентом без
второго запроса
- [x] 9.5 Измерение ничего не пишет и не кешируется скрытно
- [x] 9.6 Стоимость ответа ограничена сверху и по числу запросов, и по числу
прочитанных страниц; не растёт вместе с историей
- [x] 9.7 HTTP-контракт полон: только `GET`, пустая витрина — 200 с пустым
списком, авторизация до работы, токены и значения здоровья не в логах
выше `DEBUG`
- [x] 9.8 Ответ самоописателен: словарь слоёв тот же, что везде; границы
объявляют, что метят; расхождение единиц показано, а не выбрано молча
- [x] 9.9 Каталог отдаёт наблюдаемое, а не досчитанное; деталь хранения наружу
не протекает
- [x] 9.10 Нижний слой в сверке не участвует; `source` в измерение не входит
- [x] 9.11 Смена вердикта наблюдаема чекпоинтом
- [x] 9.12 Правило часа и правило метрики тестируются без БД; на живом архиве
проверяются свойства, а не числа