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

- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `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
+504
View File
@@ -0,0 +1,504 @@
// 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
}
+500
View File
@@ -0,0 +1,500 @@
package catalog_test
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func openStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func service(t *testing.T, st *store.Store) *catalog.Service {
t.Helper()
return catalog.New(st, slog.New(slog.DiscardHandler))
}
// logged собирает записи лога: чекпоинт, который никто не перехватывает, ничем
// не удерживается — его перевод на DEBUG или потеря атрибутов пройдут зелёным
// гейтом, а владелец о смене рода не узнает.
type logged struct {
records []slog.Record
}
func (h *logged) Enabled(context.Context, slog.Level) bool { return true }
func (h *logged) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *logged) WithGroup(string) slog.Handler { return h }
func (h *logged) Handle(_ context.Context, r slog.Record) error {
h.records = append(h.records, r.Clone())
return nil
}
func (h *logged) find(msg string) (slog.Record, bool) {
for _, r := range h.records {
if r.Message == msg {
return r, true
}
}
return slog.Record{}, false
}
func attr(r slog.Record, key string) string {
out := ""
r.Attrs(func(a slog.Attr) bool {
if a.Key == key {
out = a.Value.String()
return false
}
return true
})
return out
}
func incoming(metric, layer, units string, at time.Time, v float64) store.IncomingPoint {
return store.IncomingPoint{
Metric: metric,
Layer: layer,
Units: units,
Point: store.Point{Start: at, End: at, Raw: qty(v)},
}
}
func merge(t *testing.T, st *store.Store, points []store.IncomingPoint) {
t.Helper()
_, err := st.Merge(context.Background(), store.Incoming{Points: points},
store.DeliveryRef{ID: "delivery"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
}
// метрику кладём в оба слоя за n часов: часовая точка на границе часа, три
// минутных внутри. `sum` задаёт, будет ли часовое значение суммой или средним.
func fill(t *testing.T, st *store.Store, metric string, hours int, sum bool) {
t.Helper()
var points []store.IncomingPoint
for i := range hours {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
coarse := 2.0
if sum {
coarse = 6.0
}
points = append(points, incoming(metric, "hour", "count", h, coarse))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming(metric, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
}
func find(t *testing.T, metrics []catalog.Metric, name string) catalog.Metric {
t.Helper()
for _, m := range metrics {
if m.Metric == name {
return m
}
}
t.Fatalf("метрики %q в каталоге нет", name)
return catalog.Metric{}
}
func TestКаталогОтдаётРазрезыИИзмеренныйРод(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", 5, true)
fill(t, st, "heart_rate", 5, false)
// Метрика, лежащая только в нижнем слое: слой в каталоге есть, рода нет.
merge(t, st, []store.IncomingPoint{
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
})
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
steps := find(t, metrics, "step_count")
if steps.Aggregation.Style != catalog.Cumulative {
t.Errorf("step_count: род %v", steps.Aggregation.Style)
}
if len(steps.Units) != 1 || steps.Units[0] != "count" {
t.Errorf("step_count: единицы %v", steps.Units)
}
if len(steps.Layers) != 2 {
t.Errorf("step_count: слоёв %d, ждали 2", len(steps.Layers))
}
hr := find(t, metrics, "heart_rate")
if hr.Aggregation.Style != catalog.Instant {
t.Errorf("heart_rate: род %v", hr.Aggregation.Style)
}
sleep := find(t, metrics, "sleep_analysis")
if sleep.Aggregation.Style != catalog.Unknown {
t.Errorf("sleep_analysis: род %v, ждали unknown", sleep.Aggregation.Style)
}
if sleep.Aggregation.Hours != 0 || sleep.Aggregation.FirstHour != nil {
t.Errorf("sleep_analysis: основание %+v — сравнивать было нечего", sleep.Aggregation.Basis)
}
if len(sleep.Layers) != 1 || sleep.Layers[0].Layer != "raw" {
t.Errorf("sleep_analysis: слои %+v", sleep.Layers)
}
}
// Нижний слой в сверке не участвует: у метрики есть `raw` и `hour`, но нет
// `minute` — рода быть не должно, сколько бы данных ни лежало в нижнем слое.
func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing.T) {
t.Parallel()
st := openStore(t)
var points []store.IncomingPoint
for i := range 5 {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
points = append(points, incoming("active_energy", "hour", "kJ", h, 6))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming("active_energy", "raw",
"kJ", h.Add(time.Duration(m)*time.Second), v))
}
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "active_energy")
if m.Aggregation.Style != catalog.Unknown {
t.Errorf("род %v, ждали unknown", m.Aggregation.Style)
}
if m.Aggregation.Hours != 0 {
t.Errorf("часов окна %d — нижний слой не имеет права попадать в сверку", m.Aggregation.Hours)
}
}
func TestКаталогОграничиваетОкно(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", catalog.Window+7, true)
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "step_count")
if m.Aggregation.Hours != catalog.Window {
t.Errorf("часов окна %d, ждали %d", m.Aggregation.Hours, catalog.Window)
}
// Окно берёт САМЫЕ СВЕЖИЕ часы: старейший из сравненных обязан быть позже
// первого часа истории.
first := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
if m.Aggregation.FirstHour == nil || !m.Aggregation.FirstHour.After(first) {
t.Errorf("начало окна %v — окно взяло не свежие часы", m.Aggregation.FirstHour)
}
}
// Единицы, разошедшиеся между объектами, показываются множеством, а слой
// остаётся одним элементом: клиент, читающий слои словарём, иначе потерял бы
// половину диапазона молча.
func TestКаталогПоказываетРасхождениеЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
merge(t, st, []store.IncomingPoint{
incoming("walking_running_distance", "minute", "km", base, 1),
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
})
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "walking_running_distance")
if len(m.Units) != 2 {
t.Errorf("единицы %v, ждали оба значения", m.Units)
}
if len(m.Layers) != 1 {
t.Fatalf("слоёв %d, ждали один элемент на слой: %+v", len(m.Layers), m.Layers)
}
if m.Layers[0].Points != 2 {
t.Errorf("точек в слое %d, ждали 2 — диапазоны обязаны объединиться", m.Layers[0].Points)
}
if !m.Layers[0].From.Equal(base) || !m.Layers[0].To.Equal(base.Add(2*time.Hour)) {
t.Errorf("границы слоя %v..%v", m.Layers[0].From, m.Layers[0].To)
}
}
func TestКаталогПустойВитриныПуст(t *testing.T) {
t.Parallel()
metrics, err := service(t, openStore(t)).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if len(metrics) != 0 {
t.Errorf("метрик %d, ждали ноль", len(metrics))
}
}
// Род нигде не хранится: новая доставка меняет его в следующем же ответе, без
// перезапуска и без пересчёта чего бы то ни было.
func TestКаталогПересчитываетРодНаКаждыйЗапрос(t *testing.T) {
t.Parallel()
st := openStore(t)
svc := service(t, st)
ctx := context.Background()
fill(t, st, "step_count", 2, true)
before, err := svc.Metrics(ctx)
if err != nil {
t.Fatalf("каталог: %v", err)
}
if find(t, before, "step_count").Aggregation.Style != catalog.Unknown {
t.Fatal("двух часов не хватает на вердикт — ждали unknown")
}
fill(t, st, "step_count", 5, true)
after, err := svc.Metrics(ctx)
if err != nil {
t.Fatalf("каталог: %v", err)
}
if got := find(t, after, "step_count").Aggregation.Style; got != catalog.Cumulative {
t.Errorf("после доставки род %v, ждали cumulative", got)
}
}
// Час объекта берётся из метки в теле доставки, а тело не наше. Без верхней
// границы окна одна доставка с метками в будущем вытесняет всю настоящую
// историю метрики и подменяет измеренный род: мгновенная объявлялась
// накопительной при нуле противоречащих часов.
func TestКаталогНеИзмеряетПоБудущимЧасам(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "blood_oxygen_saturation", 6, false) // прошлое: среднее → instant
// Будущее: столько же часов «суммой», сколько влезает в окно целиком.
var future []store.IncomingPoint
base := store.Now().Add(72 * time.Hour).Truncate(time.Hour)
for i := range catalog.Window {
h := base.Add(time.Duration(i) * time.Hour)
future = append(future, incoming("blood_oxygen_saturation", "hour", "count", h, 6))
for m, v := range []float64{1, 2, 3} {
future = append(future, incoming("blood_oxygen_saturation", "minute", "count",
h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, future)
handler := &logged{}
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "blood_oxygen_saturation")
if m.Aggregation.Style != catalog.Instant {
t.Errorf("род %v: часы из будущего заняли окно и подменили измерение", m.Aggregation.Style)
}
if m.Aggregation.LastHour == nil || m.Aggregation.LastHour.After(store.Now().Add(time.Hour)) {
t.Errorf("конец окна %v — окно ушло в будущее", m.Aggregation.LastHour)
}
if _, ok := handler.find("future data"); !ok {
t.Error("данные из будущего есть, а предупреждения владельцу нет")
}
}
// Единицы обеих сторон обязаны совпасть: минутный слой в count/min и часовой в
// count/hour дают «часовое = 60 · среднее = сумма» в полном часе, то есть
// уверенный ложный cumulative при нуле противоречащих часов.
func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
var points []store.IncomingPoint
for i := range 6 {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
points = append(points, incoming("walking_speed", "hour", "count/hour", h, 6))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming("walking_speed", "minute", "count/min",
h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "walking_speed")
if m.Aggregation.Style != catalog.Unknown {
t.Errorf("род %v: единицы слоёв разошлись, сравнивать было нечего", m.Aggregation.Style)
}
if m.Aggregation.Compared != 0 {
t.Errorf("пригодных часов %d, ждали 0", m.Aggregation.Compared)
}
}
// Пустое имя метрики — законное значение колонки. Каталог отвечает на вопрос
// «что у тебя вообще есть», и терять на нём то, что лежит в витрине, нельзя.
func TestКаталогПоказываетМетрикуСПустымИменем(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
merge(t, st, []store.IncomingPoint{
incoming("", "minute", "count", base, 1),
incoming("step_count", "minute", "count", base, 2),
})
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("метрик в каталоге %d, ждали 2: %+v", len(metrics), metrics)
}
if metrics[0].Metric != "" || metrics[0].Layers[0].Points != 1 {
t.Errorf("метрика с пустым именем потерялась: %+v", metrics[0])
}
}
func TestКаталогПишетПредупреждениеОПротиворечии(t *testing.T) {
t.Parallel()
st := openStore(t)
long := strings.Repeat("метрика", 200)
var points []store.IncomingPoint
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
for i := range 6 {
h := base.Add(time.Duration(i) * time.Hour)
coarse := 6.0 // сумма
if i >= 4 {
coarse = 2.0 // среднее — противоречие
}
points = append(points, incoming(long, "hour", "count", h, coarse))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming(long, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
handler := &logged{}
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if find(t, metrics, long).Aggregation.Style != catalog.Unknown {
t.Error("противоречие обязано гасить род")
}
rec, ok := handler.find("aggregation style conflict")
if !ok {
t.Fatal("противоречие есть, а предупреждения владельцу нет")
}
if rec.Level != slog.LevelWarn {
t.Errorf("уровень %v, ждали WARN: адресат записи — владелец", rec.Level)
}
if got := attr(rec, "metric"); len(got) > 128 {
t.Errorf("имя метрики в логе не обрезано: %d байт", len(got))
}
if attr(rec, "conflicting") == "" {
t.Error("в записи нет чисел основания — по ней нечего разбирать")
}
}
// Число различных единиц у метрики равно числу её объектов: без потолка одна
// запись каталога растёт вместе с витриной.
func TestКаталогОграничиваетЧислоЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
var points []store.IncomingPoint
for i := range 40 {
points = append(points, incoming("step_count", "minute",
fmt.Sprintf("unit-%02d", i), base.Add(time.Duration(i)*time.Hour), 1))
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "step_count")
if len(m.Units) == 0 || len(m.Units) > 16 {
t.Errorf("единиц в ответе %d — потолок не работает", len(m.Units))
}
if len(m.Layers) != 1 {
t.Errorf("слоёв %d, ждали один элемент на слой", len(m.Layers))
}
}
// Отказ хранилища логируется доменной границей один раз и уходит наверх: у
// транспорта своей записи нет, он только переводит ошибку в ответ.
func TestКаталогСообщаетОбОтказеХранилища(t *testing.T) {
t.Parallel()
st := openStore(t)
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
handler := &logged{}
if _, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()); err == nil {
t.Fatal("каталог на закрытой базе собрался")
}
rec, ok := handler.find("catalog failed")
if !ok {
t.Fatal("отказ не записан на доменной границе")
}
if rec.Level != slog.LevelError {
t.Errorf("уровень %v, ждали ERROR: сбой хранилища адресован владельцу", rec.Level)
}
}
// Отмена снаружи — «не сделано», а не «не выходит»: она не имеет права давать
// владельцу ERROR, иначе единственный канал настоящих сбоев забивается
// клиентами, оборвавшими запрос по своему тайм-ауту.
func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", 3, true)
ctx, cancel := context.WithCancel(context.Background())
cancel()
handler := &logged{}
if _, err := catalog.New(st, slog.New(handler)).Metrics(ctx); err == nil {
t.Fatal("каталог собрался на отменённом контексте")
}
if _, ok := handler.find("catalog failed"); ok {
t.Error("отмена снаружи записана как сбой сервиса")
}
if _, ok := handler.find("catalog interrupted"); !ok {
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
}
}
+292
View File
@@ -0,0 +1,292 @@
package catalog_test
import (
"encoding/json"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func hour(n int) time.Time {
return time.Date(2026, 8, 1, n, 0, 0, 0, time.UTC).UTC()
}
// pair собирает час: одна часовая точка на границе часа и минутные точки внутри.
func pair(h time.Time, coarse float64, fine ...float64) store.HourPair {
p := store.HourPair{
Hour: h,
Coarse: []store.Point{{Start: h, End: h, Raw: qty(coarse)}},
}
for i, v := range fine {
at := h.Add(time.Duration(i) * time.Minute)
p.Fine = append(p.Fine, store.Point{Start: at, End: at, Raw: qty(v)})
}
return p
}
func qty(v float64) json.RawMessage {
b, err := json.Marshal(map[string]float64{"qty": v})
if err != nil {
panic(err)
}
return b
}
// window собирает окно из одинаковых по устройству часов, от свежих к старым —
// в том же порядке, в каком их отдаёт хранилище.
func window(n int, f func(h time.Time) store.HourPair) []store.HourPair {
out := make([]store.HourPair, 0, n)
for i := n - 1; i >= 0; i-- {
out = append(out, f(hour(i)))
}
return out
}
func TestMeasureНакопительная(t *testing.T) {
t.Parallel()
pairs := window(4, func(h time.Time) store.HourPair {
return pair(h, 6, 1, 2, 3)
})
style, basis := catalog.Measure(pairs)
if style != catalog.Cumulative {
t.Fatalf("род: получили %v, ждали cumulative", style)
}
if basis.Agreeing != 4 || basis.Conflicting != 0 || basis.Compared != 4 || basis.Hours != 4 {
t.Errorf("основание: %+v", basis)
}
if basis.FirstHour == nil || !basis.FirstHour.Equal(hour(0)) {
t.Errorf("начало окна: %v", basis.FirstHour)
}
if basis.LastHour == nil || !basis.LastHour.Equal(hour(3)) {
t.Errorf("конец окна: %v", basis.LastHour)
}
}
func TestMeasureМгновенная(t *testing.T) {
t.Parallel()
pairs := window(4, func(h time.Time) store.HourPair {
return pair(h, 2, 1, 2, 3)
})
style, basis := catalog.Measure(pairs)
if style != catalog.Instant {
t.Fatalf("род: получили %v, ждали instant", style)
}
if basis.Agreeing != 4 || basis.Conflicting != 0 {
t.Errorf("основание: %+v", basis)
}
}
// Нулевой час различить гипотезы не может: сумма равна среднему. Без этого
// фильтра `walking_asymmetry_percentage` на живом корпусе давала конфликт из
// одних нулевых часов.
func TestMeasureНулевойЧасНеСвидетельствует(t *testing.T) {
t.Parallel()
pairs := window(5, func(h time.Time) store.HourPair {
return pair(h, 0, 0, 0, 0)
})
style, basis := catalog.Measure(pairs)
if style != catalog.Unknown {
t.Fatalf("род: получили %v, ждали unknown", style)
}
if basis.Compared != 0 || basis.Hours != 5 {
t.Errorf("основание: %+v — нулевые часы обязаны быть непригодны", basis)
}
}
func TestMeasureПротиворечиеГаситРод(t *testing.T) {
t.Parallel()
pairs := []store.HourPair{
pair(hour(4), 6, 1, 2, 3), // сумма
pair(hour(3), 6, 1, 2, 3), // сумма
pair(hour(2), 6, 1, 2, 3), // сумма
pair(hour(1), 2, 1, 2, 3), // среднее
pair(hour(0), 12, 1, 2, 3), // ни то ни то
}
style, basis := catalog.Measure(pairs)
if style != catalog.Unknown {
t.Fatalf("род: получили %v, ждали unknown при противоречии", style)
}
if basis.Agreeing != 3 || basis.Conflicting != 1 {
t.Errorf("основание: %+v", basis)
}
if basis.Compared != 5 {
t.Errorf("пригодных часов: %d, ждали 5", basis.Compared)
}
}
func TestMeasureМалоСвидетельств(t *testing.T) {
t.Parallel()
pairs := window(2, func(h time.Time) store.HourPair {
return pair(h, 6, 1, 2, 3)
})
style, basis := catalog.Measure(pairs)
if style != catalog.Unknown {
t.Fatalf("род: получили %v, ждали unknown при двух согласных часах", style)
}
if basis.Agreeing != 2 {
t.Errorf("согласных: %d", basis.Agreeing)
}
}
func TestMeasureОбщихЧасовНет(t *testing.T) {
t.Parallel()
style, basis := catalog.Measure(nil)
if style != catalog.Unknown {
t.Fatalf("род: получили %v", style)
}
if basis.Hours != 0 || basis.FirstHour != nil || basis.LastHour != nil {
t.Errorf("основание: %+v — границ окна быть не должно", basis)
}
}
func TestMeasureНепригодныеЧасы(t *testing.T) {
t.Parallel()
двеЧасовые := func(h time.Time) store.HourPair {
p := pair(h, 6, 1, 2, 3)
p.Coarse = append(p.Coarse, store.Point{Start: h, End: h.Add(time.Hour), Raw: qty(6)})
return p
}
однаМинутная := func(h time.Time) store.HourPair {
return pair(h, 1, 1)
}
// Метка часовой точки на середине часа UTC: так выглядит часовой слой в
// зоне с получасовым смещением, и сравнивать его с минутными точками того
// же объекта нельзя — они покрывают другой интервал.
сдвинутая := func(h time.Time) store.HourPair {
p := pair(h, 6, 1, 2, 3)
p.Coarse[0].Start = h.Add(30 * time.Minute)
return p
}
безЧисла := func(h time.Time) store.HourPair {
p := pair(h, 6, 1, 2, 3)
p.Coarse[0].Raw = json.RawMessage(`{"date":"…"}`)
return p
}
cases := map[string]func(time.Time) store.HourPair{
"часовой объект несёт две точки": двеЧасовые,
"минутный объект несёт одну": однаМинутная,
"часовая метка не выровнена": сдвинутая,
"часовая точка без числа": безЧисла,
}
for name, mk := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
style, basis := catalog.Measure(window(5, mk))
if style != catalog.Unknown {
t.Errorf("род: получили %v, ждали unknown", style)
}
if basis.Compared != 0 {
t.Errorf("пригодных часов: %d, ждали 0 (основание %+v)", basis.Compared, basis)
}
if basis.Hours != 5 {
t.Errorf("часов окна: %d, ждали 5", basis.Hours)
}
})
}
}
// Вердикт обязан быть функцией состава, а не порядка точек внутри объекта:
// порядок ключей и элементов в теле HAE нестабилен (находка 2).
func TestMeasureНеЗависитОтПорядкаТочек(t *testing.T) {
t.Parallel()
forward := window(4, func(h time.Time) store.HourPair {
return pair(h, 6, 1, 2, 3)
})
backward := window(4, func(h time.Time) store.HourPair {
p := pair(h, 6, 1, 2, 3)
p.Fine[0], p.Fine[2] = p.Fine[2], p.Fine[0]
return p
})
s1, b1 := catalog.Measure(forward)
s2, b2 := catalog.Measure(backward)
// Границы окна сравниваются значением: в основании они указатели, чтобы
// «окна не было» отличалось от нулевой метки в ответе.
same := s1 == s2 &&
b1.Hours == b2.Hours && b1.Compared == b2.Compared &&
b1.Agreeing == b2.Agreeing && b1.Conflicting == b2.Conflicting &&
b1.FirstHour.Equal(*b2.FirstHour) && b1.LastHour.Equal(*b2.LastHour)
if !same {
t.Errorf("перестановка точек изменила исход: %v %+v против %v %+v", s1, b1, s2, b2)
}
}
// Точка без числа в сумму не входит и числа точек часа не увеличивает: час из
// одной значащей точки и одной пустой различить гипотезы не может.
func TestMeasureТочкаБезЧислаНеСчитается(t *testing.T) {
t.Parallel()
pairs := window(5, func(h time.Time) store.HourPair {
p := pair(h, 1, 1)
p.Fine = append(p.Fine, store.Point{
Start: h.Add(time.Minute), End: h.Add(time.Minute),
Raw: json.RawMessage(`{"source":"часы"}`),
})
return p
})
_, basis := catalog.Measure(pairs)
if basis.Compared != 0 {
t.Errorf("пригодных часов: %d, ждали 0", basis.Compared)
}
}
// Перевес мгновенной гипотезы над накопительной: разложение основания одно на
// обе ветки, и `agreeing` всегда относится к преобладающему вердикту.
func TestMeasureПротиворечиеСПеревесомМгновенной(t *testing.T) {
t.Parallel()
pairs := []store.HourPair{
pair(hour(4), 2, 1, 2, 3),
pair(hour(3), 2, 1, 2, 3),
pair(hour(2), 2, 1, 2, 3),
pair(hour(1), 2, 1, 2, 3),
pair(hour(0), 6, 1, 2, 3),
}
style, basis := catalog.Measure(pairs)
if style != catalog.Unknown {
t.Fatalf("род: получили %v, ждали unknown", style)
}
if basis.Agreeing != 4 || basis.Conflicting != 1 {
t.Errorf("основание: %+v — согласные обязаны быть у преобладающей гипотезы", basis)
}
}
func TestStyleСловарь(t *testing.T) {
t.Parallel()
cases := map[catalog.Style]string{
catalog.Cumulative: `"cumulative"`,
catalog.Instant: `"instant"`,
catalog.Unknown: `"unknown"`,
catalog.Style(42): `"unknown"`,
}
for style, want := range cases {
got, err := json.Marshal(style)
if err != nil {
t.Fatalf("сериализация %v: %v", style, err)
}
if string(got) != want {
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
}
}
}