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

- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `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
+167 -7
View File
@@ -215,6 +215,7 @@ HRV); у накопительных — только `date`. Поэтому то
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
| `fold` | свёртка одной доставки в часовые объекты |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
| `catalog` | каталог разрезов и измерение рода агрегации |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
| `httpapi` | приём и read API |
@@ -616,7 +617,8 @@ hour метки выровнены на час heart_rate 00:00:00
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
агрегация по метрике не предлагается вовсе.
агрегация по метрике не предлагается вовсе. Правило целиком — ниже, «Измерение
рода агрегации».
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
@@ -798,6 +800,140 @@ hour метки выровнены на час heart_rate 00:00:00
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
значит он и станет известен точно, вместо того чтобы быть угаданным.
### Измерение рода агрегации
Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой
минутного слоя с часовым. Правило целиком:
```
час пригоден, если
у метрики есть объекты обоих слоёв за этот час
час не позже текущего времени плюс час
единицы обоих объектов совпадают
часовой объект несёт ровно одну точку, и она несёт значение
метка этой точки совпадает с началом часа
у минутного объекта не меньше двух точек со значением
сумма минутных отличима от их среднего
вердикт пригодного часа
часовое ≈ сумма минутных → cumulative
часовое ≈ среднее минутных → instant
иначе → свидетельства нет
вердикт метрики
≥3 согласных часа и ни одного противоречащего → род
иначе → unknown
```
Измерено на живом архиве (123 доставки, 31 метрика): 7 накопительных,
9 мгновенных, 15 неизвестных, **противоречащих часов ноль**. Каталог собирается
за 68 мс.
**Горизонт обязателен, и это условие корректности, а не защита от вредителя.**
Час объекта берётся из метки в теле доставки, а тело не наше: без верхней
границы одна доставка с метками в будущем занимает окно целиком и подменяет
измеренный род — путь построен и прогнан, мгновенная метрика объявлялась
накопительной при нуле противоречащих часов. Данные, помеченные будущим, пишутся
`WARN`: сбитые часы телефона и чужое тело в приёме лечатся не кодом.
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
минутным слоем и в `count/hour` часовым даёт в полном часе
`часовое = 60 · среднее = сумма`, то есть уверенный ложный `cumulative`.
Единогласие такого случая не ловит: противоречия нет, есть молчание.
Каждая часть правила стоит своей причины.
**Различимость суммы и среднего — не украшение.** В часе, где все значения нули,
сумма равна среднему, и «сходится с суммой» выполняется тождественно: без этого
условия `walking_asymmetry_percentage` давала 4 часа «накопительная» против
3 «мгновенная», причём конфликт целиком состоял из нулевых часов.
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
который покрывают минутные точки того же объекта.
**Единогласие, а не большинство.** Противоречащий час означает, что одна из
гипотез для метрики ложна; большинство голосов объявляло бы род при известном
контрпримере. Измеренная цена — ноль. Наличие противоречащих часов пишется
`WARN`: род — свойство, на котором Read API строит арифметику года.
**Порог в три часа** — потому что один совпавший час остаётся свидетельством
одного часа. Цена измерена: порог уводит в `unknown` метрики с единственным
согласным часом.
**Допуск сравнения — относительный, `1e-9`, и один на все три сравнения.**
Разные допуски у «сходимости» и «различимости» породили бы час, подтверждающий
обе гипотезы, и его исход определил бы порядок веток кода. Величина названа
числом, потому что от неё зависят счётчики основания в ответе: вердикты
одинаковы при допуске от `1e-9` до `1e-3`, а число согласных часов у
`heart_rate` при этом меняется вдвое. Абсолютного порога нет: около нуля
относительное сравнение вырождается в сторону «не сходится», то есть даёт
«свидетельства нет», а не ложный род.
**Родов два, а не четыре.** HealthKit различает `cumulative`,
`discreteArithmetic`, `discreteTemporallyWeighted` (пульс) и
`discreteEquivalentContinuousLevel` (аудиоэкспозиция). Взять весь словарь
напрашивалось и отвергнуто измерением: часовой слой HAE считается
арифметически, а не по Apple. Прямое свидетельство — `environmental_audio_exposure`,
которую Apple усредняет логарифмически: её часовое значение сходится с обычным
арифметическим средним минутных. Стили, которые в наших данных ничем не
проявляются, можно было бы только разметить руками — то есть вернуться к тому,
от чего уходит вся конструкция.
**Род нигде не хранится**, а считается на запрос по окну в 48 самых свежих
общих часов. Хранимое значение было бы вторым производным состоянием рядом с
витриной: его пришлось бы пересчитывать после каждой свёртки, вносить в перечень
непереносимого пересборкой и объяснять, на каком составе данных оно снято, —
причём устаревшее выглядело бы ровно как свежее. Вычисленный на запрос род есть
функция витрины, а витрина — функция журнала.
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
может сменить объявленный род без единой новой доставки за спрошенный период.
Поэтому каталог отдаёт род **вместе с основанием** — сколько часов сравнено,
сколько пригодно, сколько согласны и противоречат, на каких границах окна.
**Второй предел названный вслух: окно измеряется в общих часах, а не в часах
календаря.** Выключи минутную автоматизацию — множество общих часов перестаёт
пополняться, и окно замирает на последних сорока восьми, когда она ещё работала.
Род продолжает объявляться, и единственный след этого — `last_hour` в ответе.
Календарного ограничения нет намеренно: оно уводило бы в `unknown` редкие
метрики, у которых общие часы копятся месяцами, — то есть лечило бы честный
случай ценой другого честного.
#### Как это решают другие и почему не подошло
Prior art здесь обширный, и весь он про **объявление** рода, а не про измерение.
- **HealthKit** зашивает `HKQuantityAggregationStyle` в тип метрики, а
`HKStatistics` возвращает `nil` на свёртку, не отвечающую стилю. Второе взято
как принцип («род не тот — свёртки нет»), первое неприменимо: HAE тип не шлёт.
- **Home Assistant** получает `state_class` от интеграции и при его смене
требует **удалить** долгосрочную статистику вручную. Взято признание, что
смена рода — событие, а не уточнение поля; отвергнуто объявление: объявить
некому.
- **Graphite** выводит `aggregationMethod` регуляркой по имени метрики.
Отвергнуто: противоречит инварианту «форма Apple не транслируется» и не
работает на именах HAE вовсе.
- **Prometheus и остальные** принимают тип от отправителя; заголовок HAE врёт
уже про слой, оснований верить ему про род нет. Детекция сброса счётчика
(`rate`, `total_increasing`) отвечает на другой вопрос — «был ли рестарт у
известного счётчика», — и к данным Apple неприменима: монотонного накопителя в
них нет.
- **`xFilesFactor`** (Graphite) и **`xff`** (RRDtool) — доля заполненности, ниже
которой свёртка не делается. Измерению порог не нужен: у него две
конкурирующие гипотезы, и неполный час не сходится ни с одной сам собой (у
`step_count` 41 час пригоден и 24 дали вердикт — остальные и есть неполные).
Свёртке в ответе порог понадобится, и вместе с ним выбор полярности: Graphite
задаёт долю **обязательно известных** (0.5 при роллапе и 0 при рендере),
RRDtool — долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины
выглядят как «0.5», означая противоположное. Решение принимает задача Read API.
Готовой практики вывода рода **из данных** не нашлось ни одной: у всех
перечисленных есть привилегия, которой нет у нас — поставщик объявляет тип на
входе, — и все за неё платят (Prometheus теряет тип на remote write, Home
Assistant требует ручного удаления статистики). Мы платим измерением.
### Категориальные значения
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
@@ -1085,15 +1221,38 @@ GET /healthz
за какой период:
```json
{"metric": "heart_rate", "units": "count/min", "aggregation": "instant",
{"metric": "heart_rate", "units": ["count/min"],
"aggregation": {"style": "instant", "hours": 48, "compared": 48,
"agreeing": 20, "conflicting": 0,
"first_hour": "2026-07-31T09:00:00Z",
"last_hour": "2026-08-02T14:00:00Z"},
"layers": [
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078},
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203}
{"layer": "minute", "from": "2026-07-25T00:01:00Z", "to": "2026-08-01T23:59:00Z", "points": 14203},
{"layer": "raw", "from": "2026-07-30T00:00:07Z", "to": "2026-08-01T23:59:58Z", "points": 2078}
]}
```
`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него
зависит, что вообще можно спросить.
`aggregation.style` — измеренный род (`cumulative` / `instant` / `unknown`), от
него зависит, что вообще можно спросить. Рядом лежит **основание**: сколько
общих часов попало в окно, сколько из них оказалось пригодными, сколько дали
преобладающий вердикт и сколько противоречили. Одного числа не хватало —
«часов было 48, а пригодным не оказалось ни одного» и «часов не было вовсе»
разные события, и различать их клиент обязан без второго запроса. Поле названо
`style`, а не `kind`: `kind` в проекте уже занят родом секции записи.
`units` — множество: единицы на живом потоке не менялись ни разу (находка 48),
но одна форма поля для обоих случаев честнее строки, которая при расхождении
молча выберет одно из двух. На слой при этом приходится ровно один элемент
`layers`.
Границы слоя — метки **первой и последней точки**, включительно; `first_hour` и
`last_hour`**ярлыки часов** окна измерения. Имена разные потому, что разная
семантика: одно имя для двух смыслов в одном ответе стоило бы клиенту ошибки на
час, заметной только расхождением сумм.
Границы слоя — это границы **данных, а не обещание покрытия**: внутри диапазона
законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты
запрошенного диапазона, а не на каталожную пару границ.
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
@@ -1126,7 +1285,8 @@ GET /healthz
Свёртка применяет род из каталога: `cumulative` — сумма, `instant`
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются
`bucket` отвергается ошибкой. Порог заполненности ведра (`xFilesFactor`) и его
полярность выбирает эта же задача — см. «Измерение рода агрегации». Накопительные метрики никогда не сворачиваются
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
### Форма ответа