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

- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `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
+72
View File
@@ -6,12 +6,16 @@ import (
"context"
"flag"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
@@ -132,6 +136,8 @@ func TestReplayЖивогоАрхива(t *testing.T) {
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)
measureStyles(t, dst)
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
// `date` лежит до трёх записей (docs/local-research.md, находка 47).
//
@@ -151,6 +157,72 @@ func TestReplayЖивогоАрхива(t *testing.T) {
t.Logf("координат сна %d, различных меток %d", coords, labels)
}
// measureStyles прогоняет измерение рода агрегации на витрине, собранной из
// живого архива, — единственное место, где правило проверяется на настоящем
// потоке, а не на фикстурах.
//
// Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон
// живого архива в гейт не входит, так что константа, производная от размера
// корпуса, покраснела бы молча (docs/review-journal.md, 2026-08-02). Измеренные
// числа печатаются.
func measureStyles(t *testing.T, st *store.Store) {
t.Helper()
started := time.Now()
metrics, err := catalog.New(st, slog.New(slog.DiscardHandler)).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
elapsed := time.Since(started)
byStyle := map[catalog.Style]int{}
for _, m := range metrics {
byStyle[m.Aggregation.Style]++
// Главное свойство правила: свидетельства единогласны. Противоречие —
// событие для разбора, а не отказ прогона, поэтому оно печатается с
// координатами и валит тест: пока его нет, посылка «род измерим» верна.
if m.Aggregation.Conflicting > 0 {
t.Errorf("%s: противоречащих часов %d при %d согласных — свидетельства разошлись",
m.Metric, m.Aggregation.Conflicting, m.Aggregation.Agreeing)
}
// Род измеряется только сверкой минутного слоя с часовым. Метрика с
// объявленным родом обязана иметь оба слоя: иначе он выведен из
// нижнего, а нижний слой HAE — посекундная развёртка, и его сумма
// завышена.
if m.Aggregation.Style == catalog.Unknown {
continue
}
var hasMinute, hasHour bool
for _, l := range m.Layers {
hasMinute = hasMinute || l.Layer == string(hae.LayerMinute)
hasHour = hasHour || l.Layer == string(hae.LayerHour)
}
if !hasMinute || !hasHour {
t.Errorf("%s: род %v при слоях %+v — измерять было нечем", m.Metric, m.Aggregation.Style, m.Layers)
}
}
// Роды разошлись: правило различает накопительные и мгновенные, а не
// сваливает всё в одну кучу и не отвечает `unknown` на весь корпус.
if byStyle[catalog.Cumulative] == 0 {
t.Error("накопительных метрик не нашлось — правило не различает роды")
}
if byStyle[catalog.Instant] == 0 {
t.Error("мгновенных метрик не нашлось — правило не различает роды")
}
t.Logf("каталог: метрик %d за %v; накопительных %d, мгновенных %d, неизвестных %d",
len(metrics), elapsed.Round(time.Millisecond),
byStyle[catalog.Cumulative], byStyle[catalog.Instant], byStyle[catalog.Unknown])
for _, m := range metrics {
t.Logf(" %-38s %-10s часов %d, пригодных %d, согласных %d",
m.Metric, m.Aggregation.Style, m.Aggregation.Hours,
m.Aggregation.Compared, m.Aggregation.Agreeing)
}
}
// countSleepKeys возвращает число различных координат записей сна и число
// различных меток начала. Разница между ними и есть то, что теряет ключ по
// метке.