## 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** в сохранённых заголовках вместо значения стоит пометка о сокрытии
