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

- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `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
+526
View File
@@ -0,0 +1,526 @@
# catalog Specification
## Purpose
Отвечает потребителю на два вопроса: «что у тебя вообще есть» — метрики,
единицы, слои с границами данных и числом точек — и «какая свёртка по этой
метрике осмысленна». Второй ответ **измеряется** сверкой минутного слоя с
часовым, а не размечается руками: HAE рода не шлёт, и всё, что можно было бы
объявить, пришлось бы угадать. Род неизвестен — свёртка не предлагается вовсе.
## 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** в сохранённых заголовках вместо значения стоит пометка о сокрытии