Files
healthlog/internal/catalog/catalog.go
T
av 03edf1087d Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
2026-08-02 19:23:59 +03:00

505 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package catalog — каталог разрезов и измеренный род агрегации.
//
// Отвечает на два вопроса потребителя: «что у тебя вообще есть» (метрики,
// единицы, слои с границами и числом точек) и «какая свёртка по этой метрике
// осмысленна» (род агрегации). Оба ответа производны от витрины и ничего в неё
// не пишут.
//
// Род **измеряется**, а не размечается. Форма точки его не выдаёт: `Avg`/`Min`/
// `Max` есть только у `heart_rate`, заведомо мгновенные `walking_speed` и
// `blood_oxygen_saturation` приходят в `qty` ровно так же, как шаги (находка
// 40); заголовок доставки про род молчит; единицы дают процентов девяносто и
// ломаются на краях. Зато витрина содержит собственную сверку: одна метрика
// лежит в минутном и часовом разрезе одновременно, и часовое значение либо
// равно сумме минутных, либо их среднему.
//
// Ни один известный проект род не измеряет — HealthKit зашивает его в тип
// метрики, Home Assistant получает `state_class` от интеграции, Graphite
// выводит регуляркой по имени, Prometheus принимает от отправителя. У всех у
// них есть привилегия, которой нет у нас: поставщик объявляет тип на входе.
package catalog
import (
"context"
"log/slog"
"math"
"sort"
"time"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Style — род агрегации метрики.
//
// Нулевое значение — `unknown`, и это не заглушка: «род не измерен» и есть
// честное состояние по умолчанию, а забытое присваивание не имеет права
// выглядеть как измеренный род.
type Style int
// Роды агрегации. Их два, а не четыре, и это следствие измерения, а не
// упрощения. HealthKit различает `cumulative`, `discreteArithmetic`,
// `discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel`
// (аудиоэкспозиция) — но часовой слой HAE считается арифметически, а не по
// Apple: у `environmental_audio_exposure`, которую Apple усредняет
// логарифмически, часовое значение сошлось с обычным арифметическим средним
// минутных в 59 часах из 62. Стили, которые в наших данных ничем не
// проявляются, можно было бы только разметить руками — то есть вернуться к
// тому, от чего уходит вся конструкция.
const (
Unknown Style = iota
Cumulative
Instant
)
func (s Style) String() string {
switch s {
case Cumulative:
return "cumulative"
case Instant:
return "instant"
default:
return "unknown"
}
}
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
// словаре нет.
func (s Style) MarshalJSON() ([]byte, error) {
return []byte(`"` + s.String() + `"`), nil
}
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
// потому что от них зависят счётчики основания в ответе.
const (
// Window — сколько самых свежих общих часов метрики участвует в сверке.
// Ограничивает стоимость: полный обход рос бы вместе с журналом, а окно
// держит работу в пределах 2×48 объектов на метрику. Измерено, что на живом
// корпусе окно даёт те же вердикты, что и полный обход.
Window = 48
// MinAgreeing — сколько согласных часов нужно, чтобы объявить род. Один
// совпавший час остаётся свидетельством одного часа, а на этом роде потом
// суммируют год. Цена порога измерена: он уводит в `unknown` ровно одну
// метрику корпуса, у которой согласный час был единственным.
MinAgreeing = 3
// coarsePoints и minFinePoints — структурные условия пригодности часа. Они
// же уходят в хранилище предварительным отбором: содержимое заведомо
// непригодного часа разжимать незачем.
coarsePoints = 1
minFinePoints = 2
// horizonSlack — насколько метка часа может опережать текущее время и всё
// ещё считаться свидетельством.
//
// Час объекта берётся из метки в теле доставки, а тело мы не контролируем:
// без верхней границы одна доставка с метками в будущем занимает окно
// целиком и подменяет измеренный род метрики (построено и прогнано:
// мгновенная метрика объявлялась накопительной при нуле противоречащих
// часов). Запас — на расхождение часов телефона и сервера; данные из
// будущего сверх него свидетельством не являются.
horizonSlack = time.Hour
// maxUnitsReported — сколько различных единиц метрики попадает в ответ.
//
// Единицы приходят из тела дословно и ничем не ограничены, а число
// различных значений равно числу объектов метрики: сотня доставок с разными
// строками единиц раздувает одну запись каталога на десятки килобайт.
// Событие невозможное по наблюдениям (единицы не менялись ни разу) и
// поэтому не имеющее естественного потолка — потолок ставится здесь.
maxUnitsReported = 8
// maxMetricInLog — предел длины имени метрики в записи лога. Тот же предел и
// та же причина, что у координат столкновения в хранилище: имя приходит из
// тела дословно при пределе приёма в 64 МиБ, а запись повторяется на каждый
// запрос каталога.
maxMetricInLog = 64
// tolerance — относительный допуск ВСЕХ сравнений измерения.
//
// Величина названа числом, потому что от неё зависят счётчики основания:
// вердикты метрик на живом корпусе одинаковы при допуске от 1e-9 до 1e-3, а
// число согласных часов у `heart_rate` при этом меняется с 29 на 49.
// Взято строгое: канонизация содержимого округляет числа до 12 значащих
// цифр, значит всё крупнее 1e-12 представлением не объясняется; 1e-9
// оставляет три порядка запаса и остаётся на шесть порядков строже любого
// содержательного расхождения — сумма и среднее при n ≥ 2 различаются не
// меньше чем вдвое.
tolerance = 1e-9
)
// closeEnough — ЕДИНСТВЕННЫЙ предикат сравнения чисел в измерении.
//
// Один на все три сравнения намеренно. Напиши «различимость суммы и среднего»
// точным неравенством, а «сходимость с гипотезой» — с допуском, и появится час,
// подтверждающий обе гипотезы сразу; его исход молча определил бы порядок веток
// `if`. При одном предикате такой час невыразим.
//
// Допуск относительный, абсолютного порога нет намеренно: второй константы,
// которую пришлось бы объяснять, задача не заводит. Цена названа вслух: при
// обоих нулях предикат истинен, и от нулевого часа защищает не он, а проверка
// различимости в verdictOf. Полагаться здесь на «около нуля не сходится»
// нельзя — ровно наоборот.
func closeEnough(a, b float64) bool {
if a == b {
return true
}
return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance
}
func clipMetric(metric string) string {
if len(metric) <= maxMetricInLog {
return metric
}
return metric[:maxMetricInLog] + "…"
}
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
// разности были осмысленны: `Hours Compared` — часы, отброшенные проверкой
// пригодности, `Compared Agreeing Conflicting` — часы, не сошедшиеся ни с
// одной гипотезой.
//
// Одного числа не хватало: «часов было 48, а пригодным не оказалось ни одного»
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
// второго запроса.
type Basis struct {
Hours int `json:"hours"`
Compared int `json:"compared"`
Agreeing int `json:"agreeing"`
Conflicting int `json:"conflicting"`
FirstHour *time.Time `json:"first_hour"`
LastHour *time.Time `json:"last_hour"`
}
// Aggregation — род вместе с основанием.
type Aggregation struct {
Style Style `json:"style"`
Basis
}
// LayerRange — разрез метрики в ответе каталога.
//
// Границы — метки первой и последней точки слоя, включительно, и это границы
// ДАННЫХ, а не обещание покрытия: внутри диапазона законно есть дыры. Числа
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
// знает.
type LayerRange struct {
Layer string `json:"layer"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Points int `json:"points"`
}
// Metric — запись каталога.
type Metric struct {
Metric string `json:"metric"`
// Units — множество различных единиц метрики, отсортированное. Массив, а не
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
// для обоих случаев честнее строки, которая при расхождении молча выберет
// одно из двух.
Units []string `json:"units"`
Aggregation Aggregation `json:"aggregation"`
Layers []LayerRange `json:"layers"`
}
// Service собирает каталог по витрине.
type Service struct {
store *store.Store
log *slog.Logger
}
// New собирает сервис каталога.
func New(st *store.Store, log *slog.Logger) *Service {
return &Service{store: st, log: log}
}
// Metrics отдаёт каталог: разрезы всех метрик и измеренный род каждой.
//
// Род нигде не хранится и считается заново на каждый запрос. Хранимое значение
// было бы вторым производным состоянием рядом с витриной — его пришлось бы
// пересчитывать после каждой свёртки, переносить или не переносить пересборкой
// и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит
// ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина —
// функция журнала, и устаревать в нём нечему.
func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
horizon := store.Now().Add(horizonSlack)
snap, err := s.store.ReadCatalog(ctx, store.CatalogWindow{
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
})
if err != nil {
// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
// ответ и второй раз её не пишет.
//
// Отмена снаружи и занятость базы означают «не сделано», а не «не
// выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен
// давать владельцу ERROR — иначе единственный канал, по которому видно
// настоящий сбой хранилища, забивается штатными событиями. Правило и его
// определение живут в store и читаются уже третьим местом.
if store.Transient(err) {
s.log.DebugContext(ctx, "catalog interrupted", "capability", "query", "error", err)
} else {
s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
}
return nil, err
}
out := make([]Metric, 0, len(snap.Layers))
for _, group := range groupLayers(snap.Layers) {
style, basis := Measure(snap.Pairs[group.metric])
// Единственный чекпоинт этой границы: род — свойство, на котором Read
// API строит арифметику года, и его смена не имеет права проходить
// молча. Уровень WARN, потому что адресат — владелец, а лечится это
// настройкой автоматизаций HAE, а не кодом. Значений точек в записи нет:
// данные о здоровье чувствительнее токенов. Имя метрики обрезано — оно
// приходит из тела дословно, а запись повторяется на каждый запрос.
if basis.Conflicting > 0 {
s.log.WarnContext(ctx, "aggregation style conflict",
"capability", "query",
"metric", clipMetric(group.metric),
"hours", basis.Hours,
"compared", basis.Compared,
"agreeing", basis.Agreeing,
"conflicting", basis.Conflicting)
}
// Данные, помеченные будущим, в измерение не попадают вовсе — но молчать
// о них нельзя: это либо сбитые часы телефона, либо чужое тело в приёме,
// и оба случая лечатся не кодом. Видно это по разрезам, а не по окну:
// окно такие часы уже отбросило.
if to := group.latest(); to.After(horizon) {
s.log.WarnContext(ctx, "future data",
"capability", "query",
"metric", clipMetric(group.metric),
"last_ts", store.FormatTime(to),
"horizon", store.FormatTime(horizon))
}
out = append(out, Metric{
Metric: group.metric,
Units: group.units,
Aggregation: Aggregation{Style: style, Basis: basis},
Layers: group.layers,
})
}
return out, nil
}
type metricGroup struct {
metric string
units []string
layers []LayerRange
unitSet map[string]bool
byLayer map[string]int
}
// latest — самая поздняя метка данных метрики по всем её слоям.
func (g metricGroup) latest() time.Time {
var out time.Time
for _, l := range g.layers {
if l.To.After(out) {
out = l.To
}
}
return out
}
// groupLayers схлопывает строки выборки в записи каталога.
//
// Схлопывание поручено явно, потому что выборка группируется ВМЕСТЕ с
// единицами: у метрики, чьи объекты разошлись единицами, на один слой придут две
// строки. Оставь это на самотёк — клиент получит два элемента с одинаковым
// `layer` ровно в тот единственный день, ради которого единицы и сделаны
// множеством. Различие при этом не теряется: оно видно множеством единиц
// метрики.
func groupLayers(rows []store.LayerRange) []metricGroup {
var out []metricGroup
var cur *metricGroup
for _, r := range rows {
// Указатель, а не сравнение имени с пустой строкой: пустое имя — законное
// значение колонки, и сентинел выбрасывал бы такую метрику из каталога
// молча. Каталог отвечает на вопрос «что у тебя вообще есть»; терять на
// нём то, что в витрине лежит, нельзя.
if cur == nil || r.Metric != cur.metric {
if cur != nil {
out = append(out, *cur)
}
cur = &metricGroup{metric: r.Metric, byLayer: map[string]int{}, unitSet: map[string]bool{}}
}
if r.Units != "" {
cur.unitSet[r.Units] = true
}
i, ok := cur.byLayer[r.Layer]
if !ok {
cur.layers = append(cur.layers, LayerRange{
Layer: r.Layer, From: r.From, To: r.To, Points: r.Points,
})
cur.byLayer[r.Layer] = len(cur.layers) - 1
continue
}
l := &cur.layers[i]
if r.From.Before(l.From) {
l.From = r.From
}
if r.To.After(l.To) {
l.To = r.To
}
l.Points += r.Points
}
if cur != nil {
out = append(out, *cur)
}
for i := range out {
out[i].units = sortedKeys(out[i].unitSet)
}
return out
}
// sortedKeys отдаёт множество строк отсортированным и обрезанным по потолку:
// число различных единиц у метрики равно числу её объектов, и без потолка одна
// запись каталога растёт вместе с витриной.
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
if len(out) > maxUnitsReported {
out = out[:maxUnitsReported]
}
return out
}
// Measure выводит род метрики сверкой минутного и часового слоёв.
//
// Час ПРИГОДЕН, когда у часового объекта ровно одна точка со значением и её
// метка совпадает с началом часа, у минутного не меньше двух точек со значением,
// а сумма минутных отличима от их среднего.
//
// Требование выравнивания часовой метки закрывает зоны с неполночасовым
// смещением: слой выводится по выравниванию метки в исходной зоне, а объект
// адресуется часом UTC, поэтому в зоне +0530 часовая точка описывает не тот
// интервал, который покрывают минутные точки того же объекта.
//
// Требование различимости обязательно: в часе, где все значения нули, сумма
// равна среднему, и совпадение с любой из гипотез не значит ничего. Без него
// `walking_asymmetry_percentage` на живом корпусе давала 4 часа «накопительная»
// против 3 «мгновенная» — конфликт из одних нулевых часов.
//
// Вердикт метрики — не меньше трёх согласных часов и НИ ОДНОГО противоречащего.
// Единогласие, а не большинство: противоречащий час означает, что одна из
// гипотез для этой метрики ложна, и объявлять род при известном контрпримере
// нельзя.
func Measure(pairs []store.HourPair) (Style, Basis) {
basis := Basis{Hours: len(pairs)}
if len(pairs) == 0 {
return Unknown, basis
}
// Часы приходят от свежих к старым; границы окна отдаём по возрастанию.
first, last := pairs[len(pairs)-1].Hour, pairs[0].Hour
basis.FirstHour, basis.LastHour = &first, &last
var cumulative, instant int
for _, p := range pairs {
verdict, ok := verdictOf(p)
if !ok {
continue
}
basis.Compared++
switch verdict {
case Cumulative:
cumulative++
case Instant:
instant++
}
}
// Согласные — часы преобладающей гипотезы, противоречащие — часы другой.
// Разложение одно и то же независимо от того, объявлен род или нет: при
// объявленном роде преобладающая гипотеза им и является, а противоречащих
// ноль по определению правила. Иначе `agreeing` пришлось бы толковать
// по-разному в двух ветках, и клиент читал бы одно поле двумя способами.
basis.Agreeing, basis.Conflicting = cumulative, instant
if instant > cumulative {
basis.Agreeing, basis.Conflicting = instant, cumulative
}
if basis.Conflicting > 0 || basis.Agreeing < MinAgreeing {
return Unknown, basis
}
if cumulative > instant {
return Cumulative, basis
}
return Instant, basis
}
// verdictOf оценивает один час. Второй возврат — был ли час пригоден.
func verdictOf(p store.HourPair) (Style, bool) {
// Единицы обеих сторон обязаны совпасть. Иначе сверка сравнивает величины
// разного масштаба: мгновенная метрика, приехавшая в `count/min` минутным
// слоем и в `count/hour` часовым, даёт `часовое = 60 · среднее = сумма` в
// полном часе — то есть УВЕРЕННЫЙ ложный `cumulative` при нуле
// противоречащих часов. Правило единогласия этот случай не ловит по
// построению: противоречия нет, есть молчание.
if p.FineUnits != p.CoarseUnits {
return Unknown, false
}
if len(p.Coarse) != 1 {
return Unknown, false
}
if !p.Coarse[0].Start.Equal(p.Hour) {
return Unknown, false
}
coarse, ok := hae.PointValue(p.Coarse[0].Raw)
if !ok {
return Unknown, false
}
sum, n := sumFine(p.Fine)
if n < 2 {
return Unknown, false
}
mean := sum / float64(n)
if closeEnough(sum, mean) {
return Unknown, false
}
switch {
case closeEnough(coarse, sum):
return Cumulative, true
case closeEnough(coarse, mean):
return Instant, true
default:
return Unknown, true
}
}
// sumFine складывает значения минутных точек в порядке возрастания метки, чтобы
// вердикт не зависел от порядка точек внутри объекта.
func sumFine(points []store.Point) (float64, int) {
ordered := make([]store.Point, len(points))
copy(ordered, points)
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].Start.Before(ordered[j].Start)
})
var sum float64
n := 0
for _, p := range ordered {
v, ok := hae.PointValue(p.Raw)
if !ok {
continue
}
sum += v
n++
}
return sum, n
}