разбор метрик HAE: фикстуры, канонизация, парсер

- tmp/research/fixtures.py собирает фикстуры из архива, вычищая измерения и
  сохраняя порядок ключей, форму литералов, выравнивание меток и невидимые
  символы; шесть фикстур в internal/hae/testdata
- internal/canon — общий дом канонической формы, полноты и хеша: числа читаются
  литералом через json.Number, округление до 12 значащих цифр
- internal/hae — разбор секции metrics, вывод слоя по метрике, разделение схем
  сна, координаты интервалом; recover внутри Parse, фаззинг
- миграция bucket и docs/database.md
This commit is contained in:
av
2026-08-01 17:31:41 +03:00
parent 3e93dd95b4
commit 01d0de59df
18 changed files with 3693 additions and 21 deletions
+244
View File
@@ -0,0 +1,244 @@
// Package canon — каноническая форма содержимого точки: то, по чему точки
// сравниваются и хешируются.
//
// Пакет общий для разбора и хранения намеренно. Слияние точек живёт в store,
// хеш пересчитывается там же, а сравнивать приходится то, что приехало из hae.
// Положи канонизацию в hae — store станет знать про формат HAE; положи в store
// — импорт родного экспорта Apple потребует второй реализации. Две реализации
// разошлись бы на дребезге последнего разряда, и хеш-детектор превратился бы в
// перезапись недели каждым глубоким проходом синхронизации.
//
// Каноническая форма существует только в момент сравнения. Хранится всегда
// исходные байты точки: обход через разобранные значения теряет литерал
// (`1.0` становится `1`, целые больше 2^53 сдвигаются, невалидный UTF-8
// заменяется на U+FFFD), и потеря не видна тестам на фикстурах — они
// сравнивают разобранное с разобранным.
package canon
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
)
// SignificantDigits — до скольки значащих цифр округляется число в
// канонической форме.
//
// Без округления сравнение бесполезно: 45 507 из 71 730 повторно приехавших
// точек различались последним разрядом double при одинаковом измерении — 63%
// повторов выглядели новыми (docs/local-research.md, находка 30). Двенадцать
// цифр отсекают дребезг сериализации и оставляют нетронутым всё, что Apple
// реально измеряет: даже доли процента у walking_asymmetry_percentage не
// доходят до седьмой значащей цифры.
const SignificantDigits = 12
// Form возвращает каноническую форму значения: ключи объектов отсортированы,
// числа округлены до SignificantDigits значащих цифр.
//
// Форма предназначена для сравнения и хеширования, а не для хранения.
func Form(raw []byte) ([]byte, error) {
v, err := decode(raw)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := write(&buf, v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Hash возвращает шестнадцатеричный SHA-256 канонической формы.
//
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
// проход переприсылает неделю, но почти все сравнения сходятся.
func Hash(raw []byte) (string, error) {
form, err := Form(raw)
if err != nil {
return "", err
}
sum := sha256.Sum256(form)
return hex.EncodeToString(sum[:]), nil
}
// HashAll возвращает хеш канонической формы последовательности значений —
// содержимого часового объекта целиком.
func HashAll(raws [][]byte) (string, error) {
h := sha256.New()
for _, raw := range raws {
form, err := Form(raw)
if err != nil {
return "", err
}
// Разделитель нужен, чтобы склейка соседних значений не давала тот же
// хеш, что другое их разбиение.
_, _ = h.Write(form)
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// Completeness — мера полноты точки: сколько значащих полей она несёт.
//
// Нужна правилу разрешения столкновений: по одним координатам приезжают точки
// с разным НАБОРОМ полей при одинаковом значении (0.66% координат), и правило
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не
// несёт.
//
// Поля с пустым значением не считаются: точка с `context: null` не полнее
// точки без `context`. Поле source в счёт не идёт — оно нестабильно и
// переписывается задним числом, так что его наличие ничего не говорит о
// полноте измерения.
func Completeness(raw []byte) int {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return 0
}
n := 0
for k, v := range obj {
if k == "source" {
continue
}
if isEmpty(v) {
continue
}
n++
}
return n
}
// Less задаёт детерминированный порядок на точках равной полноты.
//
// Тай-брейк по времени приёма для этого не годится: у сохранённой точки нет
// провенанса, сравнивать не с чем, а четверть доставок несёт столкновения
// ВНУТРИ себя, где время приёма общее. Порядок канонических форм зависит
// только от самих значений, поэтому свёртка по журналу даёт то же состояние,
// что приём в реальном времени.
func Less(a, b []byte) bool {
fa, err := Form(a)
if err != nil {
return false
}
fb, err := Form(b)
if err != nil {
return true
}
return bytes.Compare(fa, fb) < 0
}
func isEmpty(v json.RawMessage) bool {
t := bytes.TrimSpace(v)
switch {
case len(t) == 0, bytes.Equal(t, []byte("null")):
return true
case bytes.Equal(t, []byte(`""`)):
return true
default:
return false
}
}
// decode разбирает значение с числами в виде json.Number: строковый литерал
// вместо float64. Без этого округление применялось бы к уже испорченному
// значению — round-trip через float64 сам по себе меняет литерал.
func decode(raw []byte) (any, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("canon: разбор значения: %w", err)
}
return v, nil
}
// write пишет каноническую форму значения.
//
// Сортировку ключей объекта делает encoding/json сам (json.Marshal для map
// сортирует ключи), но здесь она выполняется явно: значения приходится
// обходить всё равно — ради чисел, — и второй проход через json.Marshal
// означал бы round-trip числа через float64.
func write(buf *bytes.Buffer, v any) error {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
key, err := json.Marshal(k)
if err != nil {
return fmt.Errorf("canon: ключ %q: %w", k, err)
}
buf.Write(key)
buf.WriteByte(':')
if err := write(buf, t[k]); err != nil {
return err
}
}
buf.WriteByte('}')
case []any:
buf.WriteByte('[')
for i, e := range t {
if i > 0 {
buf.WriteByte(',')
}
if err := write(buf, e); err != nil {
return err
}
}
buf.WriteByte(']')
case json.Number:
buf.WriteString(roundNumber(t.String()))
default:
// Строки, bool и null: json.Marshal даёт для них ту же форму, что
// пришла, и своей реализации не требует.
b, err := json.Marshal(t)
if err != nil {
return fmt.Errorf("canon: значение: %w", err)
}
buf.Write(b)
}
return nil
}
// roundNumber округляет числовой литерал до SignificantDigits значащих цифр.
//
// Целые остаются как есть: у них дребезга сериализации не бывает, а округление
// сдвинуло бы большие идентификаторы. Литерал, не разбирающийся как число,
// возвращается дословно — канонизация не место, где решается судьба
// непонятного входа.
func roundNumber(lit string) string {
if !bytes.ContainsAny([]byte(lit), ".eE") {
return lit
}
f, err := strconv.ParseFloat(lit, 64)
if err != nil {
return lit
}
if math.IsInf(f, 0) || math.IsNaN(f) {
return lit
}
// %g с точностью в значащих цифрах — ровно то, что нужно: экспонента
// выбирается сама, хвост за пределами точности отбрасывается.
return strconv.FormatFloat(f, 'g', SignificantDigits, 64)
}
+275
View File
@@ -0,0 +1,275 @@
package canon_test
import (
"encoding/json"
"testing"
"git.vakhrushev.me/av/healthlog/internal/canon"
)
// Пары взяты с живого потока (docs/local-research.md, находка 30): те же
// измерения в двух выгрузках, разошедшиеся последним разрядом double. Без
// округления 63% повторов считались бы новыми точками.
func TestFormСхлопываетДребезгПоследнегоРазряда(t *testing.T) {
t.Parallel()
pairs := []struct {
name string
a, b string
}{
{
name: "basal_energy_burned",
a: `{"qty":0.09523182962471353}`,
b: `{"qty":0.09523182962471352}`,
},
{
name: "active_energy",
a: `{"qty":0.0074754192155406605}`,
b: `{"qty":0.00747541921554066}`,
},
{
// Тот же случай, вынесенный за пределы двенадцатой значащей цифры
// явно: граница округления должна работать и на круглых числах.
name: "граница округления",
a: `{"qty":123.4567890123456}`,
b: `{"qty":123.4567890123499}`,
},
}
for _, p := range pairs {
t.Run(p.name, func(t *testing.T) {
t.Parallel()
ha, err := canon.Hash([]byte(p.a))
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash([]byte(p.b))
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha != hb {
fa, _ := canon.Form([]byte(p.a))
fb, _ := canon.Form([]byte(p.b))
t.Errorf("дребезг не схлопнулся:\n %s\n %s", fa, fb)
}
})
}
}
// Обратная сторона округления: настоящее различие обязано выжить. Без этого
// теста округление можно было бы «улучшить» до полной бесполезности.
func TestFormСохраняетНастоящееРазличие(t *testing.T) {
t.Parallel()
a := []byte(`{"qty":0.09523182962}`)
b := []byte(`{"qty":0.09523182963}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha == hb {
t.Error("различие в одиннадцатой значащей цифре съедено округлением")
}
}
// Порядок ключей в JSON от HAE нестабилен (находка 2): та же точка приезжает с
// разной раскладкой. Идентичность не имеет права от этого зависеть.
func TestFormНеЗависитОтПорядкаКлючей(t *testing.T) {
t.Parallel()
a := []byte(`{"date":"2025-06-05 10:00:00 +0300","qty":1.5,"source":"Device A"}`)
b := []byte(`{"source":"Device A","qty":1.5,"date":"2025-06-05 10:00:00 +0300"}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha != hb {
t.Error("перестановка ключей изменила идентичность точки")
}
}
// Канонизация не имеет права быть путём, по которому значение попадает в
// хранилище: она читает байты и ничего не отдаёт обратно. Тест фиксирует
// именно это — литералы, которые не переживают round-trip через разобранные
// значения, обрабатываются без паники и дают устойчивый хеш, а сами байты
// остаются нетронутыми у вызывающего.
func TestHashНеПортитИсходныеБайты(t *testing.T) {
t.Parallel()
// `1.0` теряет ноль, целое больше 2^53 сдвигается, невалидный UTF-8
// заменяется на U+FFFD — всё это происходит с КОПИЕЙ внутри canon.
raw := []byte("{\"exact\":1.0,\"huge\":9007199254740993,\"broken\":\"a\xff\xfeb\"}")
before := string(raw)
h1, err := canon.Hash(raw)
if err != nil {
t.Fatalf("хеш: %v", err)
}
h2, err := canon.Hash(raw)
if err != nil {
t.Fatalf("повторный хеш: %v", err)
}
if string(raw) != before {
t.Errorf("исходные байты изменены:\n было %q\n стало %q", before, raw)
}
if h1 != h2 {
t.Error("хеш одного и того же значения не воспроизводится")
}
}
// Целое больше 2^53 не должно проходить через float64 даже внутри
// канонизации: округление сдвинуло бы его, и две разные точки стали бы одной.
func TestFormНеСдвигаетБольшиеЦелые(t *testing.T) {
t.Parallel()
a := []byte(`{"id":9007199254740993}`)
b := []byte(`{"id":9007199254740992}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha == hb {
t.Error("соседние целые за пределами точности double схлопнулись")
}
form, err := canon.Form(a)
if err != nil {
t.Fatalf("форма: %v", err)
}
if want := `{"id":9007199254740993}`; string(form) != want {
t.Errorf("целое переписано:\n получено %s\n ожидалось %s", form, want)
}
}
func TestCompleteness(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want int
}{
{"пустой объект", `{}`, 0},
{"только qty", `{"qty":1}`, 1},
{"qty и границы", `{"qty":1,"start":"a","end":"b"}`, 3},
{
// source нестабилен и переписывается задним числом, поэтому его
// наличие ничего не говорит о полноте измерения.
name: "source не считается",
raw: `{"qty":1,"source":"Device A"}`,
want: 1,
},
{
// Точка с пустым полем не полнее точки без него — иначе бедная
// доставка выиграла бы столкновение одним лишь наличием ключа.
name: "пустые значения не считаются",
raw: `{"qty":1,"context":null,"note":""}`,
want: 1,
},
{"не объект", `[1,2,3]`, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := canon.Completeness([]byte(c.raw)); got != c.want {
t.Errorf("полнота %s = %d, ожидалось %d", c.raw, got, c.want)
}
})
}
}
// Тай-брейк обязан зависеть только от значений: свёртка по журналу должна
// давать то же состояние, что приём в реальном времени, а внутри одной
// доставки время приёма у столкнувшихся точек общее.
func TestLessДетерминирован(t *testing.T) {
t.Parallel()
a := []byte(`{"qty":1,"value":"Во сне"}`)
b := []byte(`{"value":"В кровати","qty":2}`)
ab := canon.Less(a, b)
ba := canon.Less(b, a)
if ab == ba {
t.Fatal("порядок не строгий: обе точки считаются меньшими")
}
for range 10 {
if canon.Less(a, b) != ab {
t.Fatal("порядок не воспроизводится между вызовами")
}
}
}
// Каноническая форма — вход хеша, поэтому она обязана оставаться разбираемым
// JSON: иначе отладка столкновения сведётся к чтению байтов глазами.
func TestFormОстаётсяJSON(t *testing.T) {
t.Parallel()
raw := []byte(`{"b":[1,2.50,{"z":null,"a":true}],"a":"строка"}`)
form, err := canon.Form(raw)
if err != nil {
t.Fatalf("форма: %v", err)
}
var v any
if err := json.Unmarshal(form, &v); err != nil {
t.Fatalf("каноническая форма не разбирается: %v\n%s", err, form)
}
if want := `{"a":"строка","b":[1,2.5,{"a":true,"z":null}]}`; string(form) != want {
t.Errorf("форма:\n получено %s\n ожидалось %s", form, want)
}
}
func TestFormОшибкаНаНеJSON(t *testing.T) {
t.Parallel()
if _, err := canon.Form([]byte(`{"qty":`)); err == nil {
t.Error("усечённый JSON принят за корректный")
}
}
func FuzzForm(f *testing.F) {
f.Add(`{"qty":1.5}`)
f.Add(`{"a":{"b":[1,2,3]}}`)
f.Add(`[]`)
f.Add(`null`)
f.Add(`1e400`)
f.Add("{\"s\":\"\xff\"}")
f.Fuzz(func(t *testing.T, raw string) {
// Единственное требование: разбор произвольного входа не роняет
// процесс. Разбор чужого формата обязан отвечать ошибкой, а не паникой.
form, err := canon.Form([]byte(raw))
if err != nil {
return
}
// Каноническая форма корректного входа обязана быть устойчивой:
// канонизация канонической формы даёт её же.
again, err := canon.Form(form)
if err != nil {
t.Fatalf("каноническая форма не канонизируется повторно: %v\n%s", err, form)
}
if string(again) != string(form) {
t.Fatalf("канонизация не идемпотентна:\n %s\n %s", form, again)
}
})
}