Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
This commit is contained in:
+167
-7
@@ -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`.
|
||||
|
||||
### Форма ответа
|
||||
|
||||
Reference in New Issue
Block a user