From 01d0de59df26a8892f80f61ef0e2859782dc6c51 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 1 Aug 2026 17:31:41 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20HAE:=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D1=83=D1=80=D1=8B,=20=D0=BA=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=81=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tmp/research/fixtures.py собирает фикстуры из архива, вычищая измерения и сохраняя порядок ключей, форму литералов, выравнивание меток и невидимые символы; шесть фикстур в internal/hae/testdata - internal/canon — общий дом канонической формы, полноты и хеша: числа читаются литералом через json.Number, округление до 12 значащих цифр - internal/hae — разбор секции metrics, вывод слоя по метрике, разделение схем сна, координаты интервалом; recover внутри Parse, фаззинг - миграция bucket и docs/database.md --- docs/database.md | 80 +++ internal/canon/canon.go | 244 +++++++ internal/canon/canon_test.go | 275 ++++++++ internal/hae/hae.go | 429 ++++++++++++ internal/hae/hae_test.go | 546 +++++++++++++++ internal/hae/layer.go | 78 +++ internal/hae/mem_test.go | 84 +++ internal/hae/testdata/README.md | 45 ++ internal/hae/testdata/handmade_edge.json | 78 +++ internal/hae/testdata/heartbeat_series.json | 625 ++++++++++++++++++ internal/hae/testdata/hour.json | 281 ++++++++ internal/hae/testdata/minute.json | 281 ++++++++ internal/hae/testdata/mixed.json | 314 +++++++++ internal/hae/testdata/raw.json | 163 +++++ internal/hae/testdata/sparse_sleep.json | 78 +++ internal/store/migrations/00003_bucket.sql | 58 ++ .../specs/parsing/spec.md | 13 + .../changes/razbor-metrik-v-obekty/tasks.md | 42 +- 18 files changed, 3693 insertions(+), 21 deletions(-) create mode 100644 docs/database.md create mode 100644 internal/canon/canon.go create mode 100644 internal/canon/canon_test.go create mode 100644 internal/hae/hae.go create mode 100644 internal/hae/hae_test.go create mode 100644 internal/hae/layer.go create mode 100644 internal/hae/mem_test.go create mode 100644 internal/hae/testdata/README.md create mode 100644 internal/hae/testdata/handmade_edge.json create mode 100644 internal/hae/testdata/heartbeat_series.json create mode 100644 internal/hae/testdata/hour.json create mode 100644 internal/hae/testdata/minute.json create mode 100644 internal/hae/testdata/mixed.json create mode 100644 internal/hae/testdata/raw.json create mode 100644 internal/hae/testdata/sparse_sleep.json create mode 100644 internal/store/migrations/00003_bucket.sql diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..850c04d --- /dev/null +++ b/docs/database.md @@ -0,0 +1,80 @@ +# Схема базы + +SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, файлы в +`internal/store/migrations`. Время в текстовых колонках всегда **UTC RFC 3339** +с секундной точностью: фиксированная ширина делает лексикографическую +сортировку `TEXT` совпадающей с хронологией. + +**База производна.** Источник истины — сырой архив тел (`data/raw`); состояние +пересобирается свёрткой `import(экспорт Apple) + replay(доставки по received_at)`. +Поэтому терять базу неприятно, но не смертельно, а вот терять архив — смертельно. + +``` +┌────────────────────────────┐ ┌──────────────────────────────┐ +│ delivery │ │ bucket │ +│ ───────────────────────── │ │ ──────────────────────────── │ +│ id TEXT PK │ │ metric TEXT ┐ │ +│ received_at TEXT │ │ layer TEXT ├ PK │ +│ automation_name TEXT │ ┄┄┄┄┄▶ │ hour_utc TEXT ┘ │ +│ automation_id TEXT │ первая │ units TEXT │ +│ aggregation TEXT │ доставка │ payload BLOB │ +│ period TEXT │ часа │ content_hash TEXT │ +│ session_id TEXT │ │ points INTEGER │ +│ bytes INTEGER │ │ first_ts TEXT │ +│ sha256 TEXT │ │ last_ts TEXT │ +│ raw_path TEXT │ │ first_delivery_id TEXT │ +│ parse_status TEXT │ │ sealed INTEGER │ +│ points INTEGER │ │ created_at TEXT │ +│ headers TEXT │ │ updated_at TEXT │ +└────────────────────────────┘ └──────────────────────────────┘ +``` + +Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена** +намеренно: доставки подрезаются ретеншеном до следующего проверенного экспорта, +а объекты живут дольше. Ссылка — провенанс для разбора слияний, а не +целостность. + +## `delivery` — учёт принятых пакетов + +Одна строка на принятое тело. Само тело лежит в архиве по `raw_path` +(относительный путь внутри `data/raw`). + +| Колонка | Смысл | +|---|---| +| `id` | ULID, он же имя файла в архиве | +| `received_at` | время приёма, UTC | +| `automation_name`, `automation_id` | какая автоматизация HAE прислала; `automation_id` нужен выводу слоя — по нему наследуется слой доставки без плотных метрик | +| `aggregation` | заголовок `automation-aggregation`. Режима **не означает**: значение `Default` наблюдалось у посекундного, минутного и часового режимов одновременно | +| `period` | заголовок периода (`Since Last Sync` и прочие) | +| `bytes`, `sha256` | размер и хеш тела; хеш пока только для учёта | +| `parse_status` | `pending` / `parsed` / `failed`. Код ответа приёма от него **не зависит**: сохранили — значит приняли | +| `points` | сколько точек дал разбор | +| `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты | + +Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт +повторов). + +## `bucket` — часовой объект точек + +Единица хранения — час одной метрики в одном слое, а не отдельная точка. + +| Колонка | Смысл | +|---|---| +| `metric` | имя метрики как прислал HAE. Исключение — `sleep_analysis_summary`: под именем `sleep_analysis` приезжают две несовместимые схемы, и они разводятся на разные имена | +| `layer` | `sample` / `raw` / `minute` / `hour` / `day`. Выводится из выравнивания меток | +| `hour_utc` | начало часа, UTC. Час берётся **по началу точки** | +| `units` | единицы метрики. Внутри точки их нет, они живут на уровне метрики | +| `payload` | точки часа: JSON-массив исходных байтов, gzip. Точки упорядочены по началу | +| `content_hash` | хеш канонической формы — **детектор изменений**, не ключ. Совпал — записи нет | +| `points` | сколько точек внутри | +| `first_ts`, `last_ts` | границы содержимого; каталогу разрезов, чтобы не разжимать блоб ради диапазона | +| `first_delivery_id` | доставка, создавшая объект | +| `sealed` | час, в который досчёт не ожидается. Правило перевода пока не определено | + +Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и +лишний уровень косвенности через rowid ни разу не нужен. + +**Идентичность точки внутри объекта** — координаты +`метрика + слой + начало + конец`, у точки-измерения конец равен началу. +`source` в ключ не входит: он нестабилен и переписывается задним числом. При +столкновении выигрывает более полная точка, а не последняя пришедшая. diff --git a/internal/canon/canon.go b/internal/canon/canon.go new file mode 100644 index 0000000..04cd5ed --- /dev/null +++ b/internal/canon/canon.go @@ -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) +} diff --git a/internal/canon/canon_test.go b/internal/canon/canon_test.go new file mode 100644 index 0000000..a456226 --- /dev/null +++ b/internal/canon/canon_test.go @@ -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) + } + }) +} diff --git a/internal/hae/hae.go b/internal/hae/hae.go new file mode 100644 index 0000000..a46c989 --- /dev/null +++ b/internal/hae/hae.go @@ -0,0 +1,429 @@ +// Package hae — разбор тела доставки Health Auto Export в точки. +// +// Отдельно от хранения потому, что у разбора будет второй потребитель +// (пересборка витрины из архива) и второй источник (родной экспорт Apple — +// свой формат поверх того же хранилища). Пакет ничего не знает ни про SQLite, +// ни про архив, и ничего не пишет: `Parse` — чистая функция от тела и +// заголовков. +// +// Правила разбора выведены измерением живого потока, а не спроектированы: +// docs/local-research.md, находки 2, 30, 33, 35, 36, 38, 39, 47. Документация +// HAE местами расходится с тем, что приложение шлёт на самом деле, поэтому +// источник истины по формату — пакеты в testdata. +package hae + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Layer — подробность, в которой метрика приехала. Выводится из выравнивания +// меток времени, а не из заголовка доставки: заголовок `Default` наблюдался +// одновременно у посекундного, минутного и часового режимов (находка 33). +type Layer string + +// Слои хранения. `sample` появится с импортом родного экспорта Apple, `day` +// назначается схемам с фиксированной гранулярностью и не выводится. +const ( + LayerSample Layer = "sample" + LayerRaw Layer = "raw" + LayerMinute Layer = "minute" + LayerHour Layer = "hour" + LayerDay Layer = "day" +) + +// Ошибки разбора. +var ( + // ErrMalformed — тело не разбирается как JSON ожидаемой формы. + ErrMalformed = errors.New("тело не разбирается") + + // ErrLayerUnknown — в доставке есть метрики, но определить их слой нечем: + // плотных метрик нет, у автоматизации нет предыдущего надёжного слоя, а + // заголовок ненадёжен. Точки не сохраняются, тело остаётся в архиве — + // доставку подберёт пересборка, когда слой станет известен. + // + // Молчаливый выбор `raw` здесь недопустим: призрачный разрез поедет в + // каталог и в правило Read API «самый мелкий слой, покрывающий диапазон». + ErrLayerUnknown = errors.New("слой доставки не определяется") +) + +// Порог плотности: метрика, у которой не меньше стольких точек с метками, +// классифицируется по собственному выравниванию. Редкая наследует слой +// доставки — её собственное выравнивание ничего не значит, потому что одна +// метка на часе бывает и у минутного ряда. +const denseThreshold = 10 + +// timeLayout — формат метки точки в секции metrics. Другого там не +// встречается: RFC 3339 живёт только в stateOfMind, а Unix-эпоха — внутри +// heartbeatSeries, и меткой точки не является. +const timeLayout = "2006-01-02 15:04:05 -0700" + +// Point — одна разобранная точка. +type Point struct { + // Metric — имя метрики как прислал HAE. Исключение — sleep_analysis: под + // одним именем приезжают две несовместимые схемы, и они разводятся. + Metric string + Units string + Layer Layer + + // Start и End — координаты точки в UTC. У точки-измерения End равен Start: + // ключ одной формы для всех точек, потому что интервальная и точечная + // формы не встречаются вперемешку внутри метрики одной доставки + // (находка 47). + Start time.Time + End time.Time + + // OffsetSeconds — смещение исходной зоны. Нормализовать время без него + // значит потерять, в каком часовом поясе человек находился. + OffsetSeconds int + + // Raw — содержимое точки исходными байтами, как пришло в теле. Пересборка + // повторной сериализацией теряет литерал (`1.0` → `1`, целые больше 2^53 + // сдвигаются, невалидный UTF-8 → U+FFFD), и потеря не видна тестам на + // фикстурах: они сравнивают разобранное с разобранным. + Raw json.RawMessage +} + +// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в +// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное +// дело, и терять из-за неё остальное нельзя. +type Result struct { + Points []Point + + // Metrics — сколько метрик встретилось в секции. + Metrics int + // SkippedNoTime — точки без разбираемой метки времени. + SkippedNoTime int + // SkippedMalformed — точки, не разобравшиеся как объект JSON. + SkippedMalformed int + + // Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие + // метрики). Пустой, если плотных метрик не было и наследовать было нечего. + // Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации + // могла его унаследовать. + Layer Layer + // LayerMismatch — выведенный слой разошёлся с НАДЁЖНЫМ заголовком. + // Заголовок `Default` в сравнении не участвует: он не означает режима, и + // сравнение с ним давало бы WARN на каждой доставке потока в пять минут. + LayerMismatch bool + // HeaderLayer — слой по заголовку, если заголовок надёжен. + HeaderLayer Layer +} + +// Meta — что доставка рассказала о себе заголовками, плюс память о прошлых +// доставках той же автоматизации. +type Meta struct { + // Aggregation — заголовок `automation-aggregation`. Надёжен только в + // значениях `Minutes` и `Hours`. + Aggregation string + + // FallbackLayer — последний надёжно выведенный слой этой же автоматизации + // (`automation-id`). Нужен доставкам без плотных метрик: измерено 2 такие + // из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий — + // разбор остаётся чистой функцией. + FallbackLayer Layer +} + +// Parse разбирает секцию metrics тела доставки в точки. +// +// Ошибка возвращается только когда точек не будет вовсе: тело не JSON +// (ErrMalformed) или слой не определяется (ErrLayerUnknown). Всё остальное — +// счётчики в Result. Отсутствие секции metrics ошибкой не является: доставки +// с одними тренировками или состоянием разума — норма. +func Parse(body []byte, meta Meta) (res Result, err error) { + // Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не + // имеет права упасть из-за того, что HAE прислал невиданное. Перехват + // стоит здесь, внутри разбора, а не выше: паника из хранилища — настоящий + // дефект, и глушить её нельзя. + defer func() { + if r := recover(); r != nil { + res = Result{} + err = fmt.Errorf("%w: паника разбора: %v", ErrMalformed, r) + } + }() + + metrics, err := decodeMetrics(body) + if err != nil { + return Result{}, err + } + + res.Metrics = len(metrics) + if len(metrics) == 0 { + return res, nil + } + + groups := make([]group, 0, len(metrics)) + for _, m := range metrics { + g := decodeGroup(m, &res) + if g.spillover != nil { + groups = append(groups, *g.spillover) + g.spillover = nil + } + if len(g.points) > 0 { + groups = append(groups, g) + } + } + if len(groups) == 0 { + return res, nil + } + + res.HeaderLayer = headerLayer(meta.Aggregation) + if err := assignLayers(groups, meta, &res); err != nil { + return Result{Metrics: res.Metrics}, err + } + + total := 0 + for _, g := range groups { + total += len(g.points) + } + res.Points = make([]Point, 0, total) + for _, g := range groups { + for _, p := range g.points { + p.Layer = g.layer + res.Points = append(res.Points, p) + } + } + return res, nil +} + +// group — точки одной метрики одной доставки: единица, для которой выводится +// слой. Классификация именно по метрике, а не по доставке: при перенастройке +// автоматизации приезжают смешанные доставки, и отнесение такой доставки к +// одному слою складывает минутные точки с посекундными (находка 33). +type group struct { + metric string + units string + + points []Point + // layer — итоговый слой группы. + layer Layer + // fixed — слой назначен схемой, а не выведен (суточная сводка сна). Такие + // группы не участвуют в определении слоя доставки: сводок бывает больше + // порога плотности, и их полуночные метки назначили бы всей доставке hour. + fixed bool + // alignment — самое мелкое выравнивание среди меток группы. + alignment Layer + dense bool + + // spillover — вторая группа, отделившаяся от этой при разделении схем под + // одним именем метрики. + spillover *group +} + +// envelope — форма тела, ровно настолько подробная, насколько нужно разбору. +// +// Точки держатся сырыми сообщениями и декодируются по одной: разбор тела в +// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой +// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это +// OOM ровно на пике потока, когда терять доставки дороже всего. +type envelope struct { + Data struct { + Metrics []metricEnvelope `json:"metrics"` + } `json:"data"` +} + +type metricEnvelope struct { + Name string `json:"name"` + Units string `json:"units"` + Data []json.RawMessage `json:"data"` +} + +// pointHead — поля точки, нужные разбору. Всё остальное остаётся в Raw и +// хранится дословно: «служебных» полей у точки нет, отбрасывать нечего. +type pointHead struct { + Date string `json:"date"` + Start string `json:"start"` + End string `json:"end"` + + // TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и + // суточную сводку. Общих полей, кроме date и source, у них нет. + TotalSleep *json.RawMessage `json:"totalSleep"` +} + +func decodeMetrics(body []byte) ([]metricEnvelope, error) { + var env envelope + dec := json.NewDecoder(bytes.NewReader(body)) + if err := dec.Decode(&env); err != nil { + return nil, fmt.Errorf("%w: %v", ErrMalformed, err) //nolint:errorlint // причина уходит в лог, наружу не раскрывается + } + return env.Data.Metrics, nil +} + +// decodeGroup разбирает точки одной метрики. Точка без разбираемой метки +// пропускается со счётчиком — ронять из-за неё остальную доставку незачем. +func decodeGroup(m metricEnvelope, res *Result) group { + g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))} + + for _, raw := range m.Data { + var head pointHead + if err := json.Unmarshal(raw, &head); err != nil { + res.SkippedMalformed++ + continue + } + + start, ok := parseTime(head.Date, head.Start) + if !ok { + res.SkippedNoTime++ + continue + } + + end := start + if head.End != "" { + if e, err := time.Parse(timeLayout, head.End); err == nil { + end = e + } + } + + _, offset := start.Zone() + g.points = append(g.points, Point{ + Metric: m.Name, + Units: m.Units, + Start: start.UTC(), + End: end.UTC(), + OffsetSeconds: offset, + Raw: raw, + }) + } + + if len(g.points) == 0 { + return g + } + + // Разделение схем выполняется ДО вывода слоя: у суточной сводки слой + // назначен, и её полуночные метки не должны участвовать в голосовании. + if m.Name == "sleep_analysis" { + splitSleep(&g, m.Data) + } + + g.dense = len(g.points) >= denseThreshold + g.alignment = finestAlignment(g.points) + return g +} + +// splitSleep разводит суточную сводку сна на собственное имя метрики. +// +// Под именем sleep_analysis HAE шлёт две несовместимые схемы: поэпизодную +// (start/end/value/qty) и суточную сводку (totalSleep/core/rem/deep/awake с +// меткой на местной полуночи). Имя sleep_analysis_summary — наше; инвариант +// «форма Apple не транслируется» это не нарушает, потому что поля внутри точки +// не переименовываются, разделяются только имена метрик, под которыми HAE +// смешал две схемы. +// +// Если в группе оказались обе схемы, сводки переезжают в отдельную группу, +// возвращаемую через g.spillover. Живьём смеси не наблюдалось, но полагаться +// на это нельзя: HAE меняется между версиями приложения. +func splitSleep(g *group, raws []json.RawMessage) { + summaries := make([]Point, 0) + episodes := make([]Point, 0, len(g.points)) + + for i, p := range g.points { + if i < len(raws) && isSleepSummary(raws[i]) { + p.Metric = sleepSummaryMetric + summaries = append(summaries, p) + continue + } + episodes = append(episodes, p) + } + + if len(summaries) == 0 { + return + } + g.spillover = &group{ + metric: sleepSummaryMetric, + units: g.units, + points: summaries, + layer: LayerDay, + fixed: true, + } + g.points = episodes +} + +const sleepSummaryMetric = "sleep_analysis_summary" + +func isSleepSummary(raw json.RawMessage) bool { + var head pointHead + if err := json.Unmarshal(raw, &head); err != nil { + return false + } + return head.TotalSleep != nil +} + +// parseTime разбирает метку точки. Начало берётся из start, а при его +// отсутствии — из date. Измерено: start, когда он есть, всегда совпадает с +// date, поэтому правило не вводит второго источника метки — оно закрывает +// случай, когда HAE перестанет их дублировать. +func parseTime(date, start string) (time.Time, bool) { + s := start + if s == "" { + s = date + } + if s == "" { + return time.Time{}, false + } + + t, err := time.Parse(timeLayout, s) + if err != nil { + return time.Time{}, false + } + return t, true +} + +// finestAlignment возвращает самое мелкое выравнивание среди меток. +// +// Именно самое мелкое, а не преобладающее: у плотных метрик выравнивания +// перемешаны (active_energy — 1320 минутных меток и 21 часовая), потому что +// метка ровно на часе одновременно является и минутной. Метрика, у которой +// хоть одна метка стоит на середине часа, часовой не является. +func finestAlignment(points []Point) Layer { + finest := LayerHour + for _, p := range points { + switch { + case p.Start.Second() != 0 || p.Start.Nanosecond() != 0: + return LayerRaw + case p.Start.Minute() != 0: + finest = LayerMinute + } + } + return finest +} + +// headerLayer переводит заголовок в слой, но только когда заголовок надёжен. +// `Default` соответствует трём разным режимам выгрузки и не означает ничего. +func headerLayer(aggregation string) Layer { + switch aggregation { + case "Minutes": + return LayerMinute + case "Hours": + return LayerHour + default: + return "" + } +} + +// finer возвращает более мелкий из двух слоёв. +func finer(a, b Layer) Layer { + if rank(a) < rank(b) { + return a + } + return b +} + +func rank(l Layer) int { + switch l { + case LayerSample: + return 0 + case LayerRaw: + return 1 + case LayerMinute: + return 2 + case LayerHour: + return 3 + case LayerDay: + return 4 + default: + return 5 + } +} diff --git a/internal/hae/hae_test.go b/internal/hae/hae_test.go new file mode 100644 index 0000000..f690148 --- /dev/null +++ b/internal/hae/hae_test.go @@ -0,0 +1,546 @@ +package hae_test + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/hae" +) + +func load(t *testing.T, name string) []byte { + t.Helper() + + body, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("фикстура %s: %v", name, err) + } + return body +} + +// layersOf собирает слой по имени метрики: слой обязан выводиться ПО МЕТРИКЕ, +// а не по доставке, поэтому сверять его надо тоже по метрике. +func layersOf(points []hae.Point) map[string]hae.Layer { + out := make(map[string]hae.Layer) + for _, p := range points { + out[p.Metric] = p.Layer + } + return out +} + +func countsOf(points []hae.Point) map[string]int { + out := make(map[string]int) + for _, p := range points { + out[p.Metric]++ + } + return out +} + +// Фикстуры собраны из живого архива с вычищенными значениями: выравнивание +// меток, порядок ключей и форма литералов сохранены (см. testdata/README.md). +func TestParseФикстурыЖивогоПотока(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + file string + meta hae.Meta + layer hae.Layer + metrics map[string]hae.Layer + }{ + { + name: "минутная доставка", + file: "minute.json", + meta: hae.Meta{Aggregation: "Minutes"}, + layer: hae.LayerMinute, + metrics: map[string]hae.Layer{ + "active_energy": hae.LayerMinute, + "heart_rate_variability": hae.LayerMinute, + "apple_stand_hour": hae.LayerHour, // плотная и своя + "sleep_analysis_summary": hae.LayerDay, // слой назначен схемой + "six_minute_walking_test_distance": hae.LayerMinute, // редкая наследует + }, + }, + { + name: "часовая доставка", + file: "hour.json", + meta: hae.Meta{Aggregation: "Hours"}, + layer: hae.LayerHour, + metrics: map[string]hae.Layer{ + "active_energy": hae.LayerHour, + "apple_stand_hour": hae.LayerHour, + "sleep_analysis_summary": hae.LayerDay, + }, + }, + { + name: "посекундная доставка", + file: "raw.json", + meta: hae.Meta{Aggregation: "Default"}, + layer: hae.LayerRaw, + metrics: map[string]hae.Layer{ + "heart_rate": hae.LayerRaw, + "resting_heart_rate": hae.LayerRaw, + }, + }, + { + // Перенастройка автоматизации: в одной доставке минутные, + // посекундные и часовые метрики. Отнесение доставки к одному слою + // сложило бы минутные точки с посекундными. + name: "смешанная доставка", + file: "mixed.json", + meta: hae.Meta{Aggregation: "Default"}, + layer: hae.LayerRaw, + metrics: map[string]hae.Layer{ + "heart_rate": hae.LayerRaw, + "active_energy": hae.LayerMinute, + "apple_stand_hour": hae.LayerHour, + "sleep_analysis_summary": hae.LayerDay, + "resting_heart_rate": hae.LayerRaw, // редкая наследует слой доставки + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(load(t, c.file), c.meta) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if res.Layer != c.layer { + t.Errorf("слой доставки = %q, ожидался %q", res.Layer, c.layer) + } + + got := layersOf(res.Points) + for metric, want := range c.metrics { + if got[metric] != want { + t.Errorf("%s: слой %q, ожидался %q", metric, got[metric], want) + } + } + if res.SkippedMalformed != 0 { + t.Errorf("живая фикстура дала %d неразобранных точек", res.SkippedMalformed) + } + }) + } +} + +// Под именем sleep_analysis приезжают две несовместимые схемы. Разделение +// выполняется ДО вывода слоя: сводок бывает больше порога плотности, и их +// полуночные метки иначе назначили бы всей доставке слой hour. +func TestParseРазводитДвеСхемыСна(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(load(t, "minute.json"), hae.Meta{Aggregation: "Minutes"}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + counts := countsOf(res.Points) + if counts["sleep_analysis_summary"] == 0 { + t.Fatal("суточная сводка сна не выделена в собственную метрику") + } + if counts["sleep_analysis"] != 0 { + t.Errorf("под именем sleep_analysis осталось %d точек сводки", counts["sleep_analysis"]) + } + + for _, p := range res.Points { + if p.Metric == "sleep_analysis_summary" && p.Layer != hae.LayerDay { + t.Errorf("слой сводки = %q, ожидался day", p.Layer) + } + } +} + +// Ключ по метке схлопнул бы эти три записи в одну: у них общий date и разные +// интервалы. Живьём такое встречается в 22 доставках из 94 (находка 47). +func TestParseСохраняетЗаписиСОднойМеткой(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + var intervals []hae.Point + for _, p := range res.Points { + if p.Metric == "interval_points" { + intervals = append(intervals, p) + } + } + if len(intervals) != 3 { + t.Fatalf("точек-интервалов %d, ожидалось 3", len(intervals)) + } + + starts := map[time.Time]int{} + ends := map[time.Time]int{} + for _, p := range intervals { + starts[p.Start]++ + ends[p.End]++ + if !p.End.After(p.Start) { + t.Errorf("конец %v не позже начала %v", p.End, p.Start) + } + } + if len(starts) != 1 { + t.Errorf("начал %d, ожидалось одно общее", len(starts)) + } + if len(ends) != 3 { + t.Errorf("концов %d, ожидалось три разных", len(ends)) + } +} + +// Точка без end адресуется вырожденным интервалом: ключ одной формы для всех +// точек, без ветвления по «классу метрики». +func TestParseТочкаБезКонцаВырожденнаяВИнтервал(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + found := false + for _, p := range res.Points { + if p.Metric != "mixed_alignment" { + continue + } + found = true + if !p.End.Equal(p.Start) { + t.Fatalf("конец %v не равен началу %v у точки-измерения", p.End, p.Start) + } + } + if !found { + t.Fatal("метрика mixed_alignment не разобрана") + } +} + +// Метка ровно на часе одновременно является и минутной, поэтому слой метрики — +// самое мелкое встретившееся выравнивание, а не преобладающее. +func TestParseСлойПоСамомуМелкомуВыравниванию(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + if got := layersOf(res.Points)["mixed_alignment"]; got != hae.LayerMinute { + t.Errorf("слой = %q, ожидался minute: десять меток часовые, одна минутная", got) + } +} + +// Незнакомое поле — норма: HAE меняется между версиями, и точка обязана +// доехать до хранилища дословно, вместе с тем, чего разбор не понимает. +func TestParseСохраняетТочкуДословно(t *testing.T) { + t.Parallel() + + body := load(t, "handmade_edge.json") + res, err := hae.Parse(body, hae.Meta{}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + var raw string + for _, p := range res.Points { + if p.Metric == "unknown_shape" { + raw = string(p.Raw) + } + } + if raw == "" { + t.Fatal("метрика unknown_shape не разобрана") + } + + for _, want := range []string{ + `"inventedField"`, // незнакомое поле целиком + `"nested"`, // вместе с вложенной структурой + `1.0`, // литерал, который round-trip превратил бы в 1 + `9007199254740993`, // целое за пределами точности double + `0.095231829624713534`, // дробь длиннее двенадцати значащих цифр + } { + if !strings.Contains(raw, want) { + t.Errorf("в сохранённой точке нет %s:\n%s", want, raw) + } + } +} + +// Серия ударов — 93% объёма метрики heart_rate_variability и третий формат +// времени. Разворачивать её в точки нельзя, преобразовывать эпоху — тоже: +// time.Unix и обратно не гарантирует дословности. +func TestParseНеТрогаетСериюУдаров(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(load(t, "heartbeat_series.json"), hae.Meta{FallbackLayer: hae.LayerRaw}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + if len(res.Points) != 2 { + t.Fatalf("точек %d, ожидалось 2: серия не должна разворачиваться", len(res.Points)) + } + + var series []struct { + Date json.Number `json:"date"` + } + var point struct { + Series json.RawMessage `json:"heartbeatSeries"` + } + if err := json.Unmarshal(res.Points[0].Raw, &point); err != nil { + t.Fatalf("точка не разбирается: %v", err) + } + if len(point.Series) == 0 { + t.Fatal("в точке нет heartbeatSeries") + } + dec := json.NewDecoder(strings.NewReader(string(point.Series))) + dec.UseNumber() + if err := dec.Decode(&series); err != nil { + t.Fatalf("серия не разбирается: %v", err) + } + if len(series) == 0 { + t.Fatal("серия пуста") + } + // Эпоха внутри серии осталась дробным числом, а не превратилась в строку + // или в целое: разбор её не касался. + if !strings.Contains(series[0].Date.String(), ".") { + t.Errorf("эпоха внутри серии изменена: %s", series[0].Date) + } +} + +// Доставка без плотных метрик: измерено 2 такие из 89, обе с заголовком +// Default. Молчаливый выбор raw создал бы призрачный разрез, который поедет в +// каталог и в правило Read API «самый мелкий слой, покрывающий диапазон». +func TestParseДоставкаБезПлотныхМетрик(t *testing.T) { + t.Parallel() + + body := load(t, "sparse_sleep.json") + + t.Run("наследовать нечего и заголовок ненадёжен", func(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"}) + if !errors.Is(err, hae.ErrLayerUnknown) { + t.Fatalf("ошибка %v, ожидалась ErrLayerUnknown", err) + } + if len(res.Points) != 0 { + t.Errorf("сохранено %d точек при неизвестном слое", len(res.Points)) + } + }) + + t.Run("наследует слой автоматизации", func(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(body, hae.Meta{Aggregation: "Default", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if res.Layer != hae.LayerMinute { + t.Errorf("слой = %q, ожидался унаследованный minute", res.Layer) + } + }) + + t.Run("надёжный заголовок как последнее средство", func(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(body, hae.Meta{Aggregation: "Hours"}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if res.Layer != hae.LayerHour { + t.Errorf("слой = %q, ожидался hour из заголовка", res.Layer) + } + }) +} + +// Заголовок Default не означает режима, поэтому расхождение с ним не +// фиксируется: сигнал утонул бы в собственном шуме на потоке в пять минут. +func TestParseРасхождениеСлояТолькоПротивНадёжногоЗаголовка(t *testing.T) { + t.Parallel() + + body := load(t, "raw.json") // выводится raw + + res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if res.LayerMismatch { + t.Error("расхождение зафиксировано против заголовка Default") + } + + res, err = hae.Parse(body, hae.Meta{Aggregation: "Hours"}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if !res.LayerMismatch { + t.Error("расхождение с надёжным заголовком Hours не зафиксировано") + } + if res.Layer != hae.LayerRaw { + t.Errorf("слой = %q: сохранять надо по выведенному, а не по заголовку", res.Layer) + } +} + +// Частичный исход — счётчиками, а не ошибкой: пакет, у которого не разобралась +// часть точек, доезжает остальным содержимым. +func TestParseПропускаетНеразобранныеТочки(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + if res.SkippedNoTime != 3 { + t.Errorf("точек без метки %d, ожидалось 3", res.SkippedNoTime) + } + if res.SkippedMalformed != 1 { + t.Errorf("неразобранных точек %d, ожидалась 1", res.SkippedMalformed) + } + if countsOf(res.Points)["broken_timestamps"] != 0 { + t.Error("точка с непонятной меткой попала в хранилище") + } +} + +// Время нормализовано в UTC, но зона не потеряна: без офсета неизвестно, в +// каком поясе человек находился. +func TestParseСохраняетСмещениеЗоны(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + for _, p := range res.Points { + if p.Metric != "mixed_alignment" { + continue + } + if p.Start.Location() != time.UTC { + t.Errorf("время не в UTC: %v", p.Start) + } + if p.OffsetSeconds != 3*3600 { + t.Errorf("смещение %d секунд, ожидалось %d", p.OffsetSeconds, 3*3600) + } + return + } + t.Fatal("метрика mixed_alignment не разобрана") +} + +// Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не имеет +// права упасть из-за того, что HAE прислал невиданное. +func TestParseВраждебныйВход(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + body string + wantErr error + }{ + {"усечённое тело", `{"data":{"metrics":[`, hae.ErrMalformed}, + {"не JSON вовсе", `не json`, hae.ErrMalformed}, + {"пустое тело", ``, hae.ErrMalformed}, + {"null вместо объекта", `null`, nil}, + {"массив вместо data", `{"data":[1,2,3]}`, hae.ErrMalformed}, + {"массив вместо metrics", `{"data":{"metrics":{}}}`, hae.ErrMalformed}, + {"метрики отсутствуют", `{"data":{"workouts":[]}}`, nil}, + {"точка не объект", `{"data":{"metrics":[{"name":"m","data":[1,2]}]}}`, nil}, + {"число вместо строки даты", `{"data":{"metrics":[{"name":"m","data":[{"date":1}]}]}}`, nil}, + {"дата не того формата", `{"data":{"metrics":[{"name":"m","data":[{"date":"2025-06-05T10:00:00Z"}]}]}}`, nil}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + _, err := hae.Parse([]byte(c.body), hae.Meta{}) + if c.wantErr == nil { + if err != nil && !errors.Is(err, hae.ErrLayerUnknown) { + t.Fatalf("неожиданная ошибка: %v", err) + } + return + } + if !errors.Is(err, c.wantErr) { + t.Fatalf("ошибка %v, ожидалась %v", err, c.wantErr) + } + }) + } +} + +// Разбор — чистая функция: ни времени, ни генерации идентификаторов. Два +// вызова на одном входе обязаны совпасть, иначе пересборка витрины из архива +// даст не то состояние, что живой приём. +func TestParseДетерминирован(t *testing.T) { + t.Parallel() + + body := load(t, "mixed.json") + meta := hae.Meta{Aggregation: "Default"} + + a, err := hae.Parse(body, meta) + if err != nil { + t.Fatalf("первый разбор: %v", err) + } + b, err := hae.Parse(body, meta) + if err != nil { + t.Fatalf("второй разбор: %v", err) + } + + if len(a.Points) != len(b.Points) { + t.Fatalf("точек %d против %d", len(a.Points), len(b.Points)) + } + for i := range a.Points { + if a.Points[i].Metric != b.Points[i].Metric || + !a.Points[i].Start.Equal(b.Points[i].Start) || + !a.Points[i].End.Equal(b.Points[i].End) || + a.Points[i].Layer != b.Points[i].Layer || + string(a.Points[i].Raw) != string(b.Points[i].Raw) { + t.Fatalf("точка %d разошлась между вызовами", i) + } + } +} + +// Тело не должно портиться разбором: оно уже лежит в архиве, и пересборка +// прочитает те же байты. +func TestParseНеПортитТело(t *testing.T) { + t.Parallel() + + body := load(t, "mixed.json") + before := string(body) + + if _, err := hae.Parse(body, hae.Meta{Aggregation: "Default"}); err != nil { + t.Fatalf("разбор: %v", err) + } + if string(body) != before { + t.Error("разбор изменил тело доставки") + } +} + +func FuzzParse(f *testing.F) { + for _, name := range []string{"minute.json", "sparse_sleep.json", "handmade_edge.json"} { + body, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + f.Fatalf("фикстура %s: %v", name, err) + } + f.Add(body) + } + f.Add([]byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[{"date":"2025-06-05 10:00:00 +0300"}]}]}}`)) + + f.Fuzz(func(t *testing.T, body []byte) { + // Единственное требование: разбор произвольного входа не роняет + // процесс и не отдаёт точку без координат. + res, err := hae.Parse(body, hae.Meta{}) + if err != nil { + return + } + for _, p := range res.Points { + if p.Start.IsZero() { + t.Fatal("точка без метки времени доехала до хранилища") + } + if p.End.Before(p.Start) { + t.Fatalf("конец раньше начала: %v < %v", p.End, p.Start) + } + if p.Layer == "" { + t.Fatal("точка без слоя доехала до хранилища") + } + } + }) +} diff --git a/internal/hae/layer.go b/internal/hae/layer.go new file mode 100644 index 0000000..fb1541e --- /dev/null +++ b/internal/hae/layer.go @@ -0,0 +1,78 @@ +package hae + +// assignLayers проставляет слой каждой группе точек. +// +// Правило проверено на всей истории (находка 33) и дважды ломалось на живых +// данных при более простых формулировках: классификация доставки целиком +// сложила минутные точки с посекундными и удвоила сумму за час, а +// классификация каждой метрики по отдельности растащила редкие метрики по трём +// слоям. +// +// Работающая формулировка в три шага: +// +// 1. плотная метрика (не меньше denseThreshold точек) классифицируется по +// собственному выравниванию; +// 2. редкая наследует самый мелкий слой среди плотных метрик этой доставки — +// её собственное выравнивание ничего не значит, одна метка на часе бывает +// и у минутного ряда; +// 3. доставка без плотных метрик вовсе наследует последний надёжно выведенный +// слой той же автоматизации, а если и его нет — берёт слой из НАДЁЖНОГО +// заголовка. +// +// Третий шаг не роскошь: измерено 2 такие доставки из 89, обе с заголовком +// `Default`. Заменить его заголовком нельзя — `Default` не означает режима. +func assignLayers(groups []group, meta Meta, res *Result) error { + delivery := deliveryLayer(groups) + + if delivery == "" { + // Плотных метрик нет: наследуем от прошлых доставок этой автоматизации, + // иначе — от надёжного заголовка. + delivery = meta.FallbackLayer + if delivery == "" { + delivery = res.HeaderLayer + } + if delivery == "" { + return ErrLayerUnknown + } + } + + res.Layer = delivery + // Расхождение сверяется только с надёжным заголовком: `Default` не + // означает режима, и сравнение с ним давало бы WARN на каждой доставке + // потока в пять минут — сигнал утонул бы в собственном шуме. + res.LayerMismatch = res.HeaderLayer != "" && delivery != res.HeaderLayer + + for i := range groups { + g := &groups[i] + switch { + case g.fixed: + // Слой назначен схемой, выводить нечего. + case g.dense: + g.layer = g.alignment + default: + g.layer = delivery + } + } + return nil +} + +// deliveryLayer — самый мелкий слой среди плотных метрик доставки. Пустой, +// если плотных метрик нет. +// +// Группы с назначенным слоем в голосовании не участвуют: суточных сводок сна +// бывает больше порога плотности, и их полуночные метки иначе назначили бы +// всей доставке слой hour. +func deliveryLayer(groups []group) Layer { + var out Layer + for _, g := range groups { + if g.fixed || !g.dense { + continue + } + if out == "" { + out = g.alignment + continue + } + out = finer(out, g.alignment) + } + return out +} diff --git a/internal/hae/mem_test.go b/internal/hae/mem_test.go new file mode 100644 index 0000000..f113d0d --- /dev/null +++ b/internal/hae/mem_test.go @@ -0,0 +1,84 @@ +package hae_test + +import ( + "fmt" + "runtime" + "strings" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/hae" +) + +// Тела доходили до 42 МиБ, и стратегия декодирования — часть контракта, а не +// деталь: разбор такого тела в map[string]any удерживает 197 МиБ кучи против +// 54 МиБ у формы с json.RawMessage. Вместе с самим телом и удвоением в чтении +// пик доходил бы до ~300 МиБ на доставку — это OOM ровно на пике потока, когда +// терять доставки дороже всего. +// +// Тест сторожит именно порядок величины, а не точное число: он обязан падать +// при возврате к map[string]any и не падать от смены версии Go. +func TestParseУдержаниеКучи(t *testing.T) { + if testing.Short() { + t.Skip("измерение кучи: не для -short") + } + + body := largeBody(40, 4000) + t.Logf("тело %d МиБ", len(body)>>20) + + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + + res, err := hae.Parse(body, hae.Meta{Aggregation: "Minutes"}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + runtime.GC() + runtime.ReadMemStats(&after) + + retained := int64(after.HeapAlloc) - int64(before.HeapAlloc) + limit := int64(len(body)) * 4 + + t.Logf("точек %d, удержано %d МиБ при теле %d МиБ", + len(res.Points), retained>>20, len(body)>>20) + + if retained > limit { + t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+ + "похоже, точки снова разбираются в map[string]any", + retained>>20, len(body)>>20) + } + + // Результат обязан дожить до замера, иначе мерить нечего. + runtime.KeepAlive(res) + runtime.KeepAlive(body) +} + +// largeBody собирает тело, похожее на настоящее: те же поля, тот же формат +// метки, минутное выравнивание. +func largeBody(metrics, points int) []byte { + var b strings.Builder + b.WriteString(`{"data":{"metrics":[`) + + base := time.Date(2025, 6, 5, 0, 0, 0, 0, time.FixedZone("", 3*3600)) + for m := range metrics { + if m > 0 { + b.WriteByte(',') + } + fmt.Fprintf(&b, `{"name":"metric_%d","units":"count","data":[`, m) + for p := range points { + if p > 0 { + b.WriteByte(',') + } + ts := base.Add(time.Duration(p) * time.Minute).Format("2006-01-02 15:04:05 -0700") + fmt.Fprintf(&b, + `{"date":"%s","qty":%d.09523182962471353,"source":"Device A|Device B"}`, + ts, p%97) + } + b.WriteString(`]}`) + } + + b.WriteString(`]}}`) + return []byte(b.String()) +} diff --git a/internal/hae/testdata/README.md b/internal/hae/testdata/README.md new file mode 100644 index 0000000..4f80b8f --- /dev/null +++ b/internal/hae/testdata/README.md @@ -0,0 +1,45 @@ +# Фикстуры разбора Health Auto Export + +Конвенция требует тестов на **реальных** пакетах: документация формата тонкая и +местами расходится с тем, что приложение шлёт на самом деле. Инвариант при этом +запрещает данным о здоровье попадать под контроль версий. Поэтому фикстуры +собраны из живого архива скриптом `tmp/research/fixtures.py`, который сохраняет +всё, на чём работает разбор, и уничтожает всё, что является измерением. + +Сохранено дословно: порядок ключей, форма числового литерала, выравнивание +меток по секундам и минутам, смещение зоны, длительности интервалов, невидимые +символы в именах устройств, категориальные значения. Заменено: числа (цифры +переставлены с сохранением формы), даты (сдвинуты на постоянную величину), +имена устройств. + +| Файл | Что проверяет | +|---|---| +| `minute.json` | минутная доставка; плотные метрики минутные, одна (`apple_stand_hour`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна | +| `hour.json` | часовая доставка целиком | +| `raw.json` | посекундная доставка | +| `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации | +| `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами | +| `sparse_sleep.json` | доставка **без плотных метрик** (заголовок `Default` не спасает) и поэпизодный сон с задвоенной меткой — интервальная идентичность | +| `handmade_edge.json` | случаи, которых живой поток не даёт (см. ниже) | + +`handmade_edge.json` собран руками, скриптом не воспроизводится: + +- `mixed_alignment` — плотная метрика, где 10 меток часовые и одна минутная: + слой обязан выйти минутным, потому что слой метрики — **самое мелкое** + встретившееся выравнивание; +- `unknown_shape` — незнакомое поле точки (в том числе вложенное), `1.0`, целое + больше 2⁵³ и дробь длиннее двенадцати значащих цифр: всё это обязано пережить + хранение дословно; +- `broken_timestamps` — RFC 3339 вместо формата HAE, пустая строка, отсутствие + `date` и число вместо строки: каждая точка пропускается со счётчиком, разбор + не падает; +- `interval_points` — три записи с одной меткой и разными интервалами: ключ по + метке схлопнул бы их в одну. + +Обновить набор: + +``` +python3 tmp/research/fixtures.py --list +python3 tmp/research/fixtures.py <файл из data/raw> --metrics a,b --limit 14 \ + --out internal/hae/testdata/<имя>.json +``` diff --git a/internal/hae/testdata/handmade_edge.json b/internal/hae/testdata/handmade_edge.json new file mode 100644 index 0000000..1b75adb --- /dev/null +++ b/internal/hae/testdata/handmade_edge.json @@ -0,0 +1,78 @@ +{ + "_comment": "Рукотворная фикстура: случаи, которых живой поток не даёт. Скриптом tmp/research/fixtures.py не порождается. Значения выдуманы целиком.", + "data": { + "metrics": [ + { + "name": "mixed_alignment", + "units": "count", + "data": [ + { "date": "2025-06-05 10:00:00 +0300", "qty": 1, "source": "Device A" }, + { "date": "2025-06-05 11:00:00 +0300", "qty": 2, "source": "Device A" }, + { "date": "2025-06-05 12:00:00 +0300", "qty": 3, "source": "Device A" }, + { "date": "2025-06-05 13:00:00 +0300", "qty": 4, "source": "Device A" }, + { "date": "2025-06-05 14:00:00 +0300", "qty": 5, "source": "Device A" }, + { "date": "2025-06-05 15:00:00 +0300", "qty": 6, "source": "Device A" }, + { "date": "2025-06-05 16:00:00 +0300", "qty": 7, "source": "Device A" }, + { "date": "2025-06-05 17:00:00 +0300", "qty": 8, "source": "Device A" }, + { "date": "2025-06-05 18:00:00 +0300", "qty": 9, "source": "Device A" }, + { "date": "2025-06-05 19:00:00 +0300", "qty": 10, "source": "Device A" }, + { "date": "2025-06-05 19:30:00 +0300", "qty": 11, "source": "Device A" } + ] + }, + { + "name": "unknown_shape", + "units": "widget", + "data": [ + { + "date": "2025-06-05 10:00:00 +0300", + "qty": 1.0, + "hugeInteger": 9007199254740993, + "longFraction": 0.095231829624713534, + "inventedField": { "nested": ["значение", 42], "flag": true }, + "source": "Device A" + } + ] + }, + { + "name": "broken_timestamps", + "units": "count", + "data": [ + { "date": "2025-06-05T10:00:00Z", "qty": 1, "source": "Device A" }, + { "date": "", "qty": 2, "source": "Device A" }, + { "qty": 3, "source": "Device A" }, + { "date": 1749110400, "qty": 4, "source": "Device A" } + ] + }, + { + "name": "interval_points", + "units": "hr", + "data": [ + { + "date": "2025-06-05 22:04:00 +0300", + "start": "2025-06-05 22:04:00 +0300", + "end": "2025-06-05 22:16:00 +0300", + "qty": 0.2, + "value": "Во сне", + "source": "Device A" + }, + { + "date": "2025-06-05 22:04:00 +0300", + "start": "2025-06-05 22:04:00 +0300", + "end": "2025-06-06 02:21:00 +0300", + "qty": 4.28, + "value": "В кровати", + "source": "Device A" + }, + { + "date": "2025-06-05 22:04:00 +0300", + "start": "2025-06-05 22:04:00 +0300", + "end": "2025-06-06 07:51:00 +0300", + "qty": 9.78, + "value": "В кровати", + "source": "Device A" + } + ] + } + ] + } +} diff --git a/internal/hae/testdata/heartbeat_series.json b/internal/hae/testdata/heartbeat_series.json new file mode 100644 index 0000000..5ed3bb5 --- /dev/null +++ b/internal/hae/testdata/heartbeat_series.json @@ -0,0 +1,625 @@ +{ + "data": { + "metrics": [ + { + "data": [ + { + "source": "Device A  ", + "qty": 23.750315211285474, + "heartbeatSeries": [ + { + "timeSinceStart": 0.30703959892431944, + "date": 4664261259.9741159, + "precededByGap": true + }, + { + "timeSinceStart": 7.2309957839083619, + "precededByGap": false, + "date": 4041665251.900013 + }, + { + "date": 8876178853.342975, + "timeSinceStart": 1.4527448217879887, + "precededByGap": false + }, + { + "timeSinceStart": 1.4165263660769081, + "precededByGap": false, + "date": 1000535638.3966900 + }, + { + "timeSinceStart": 9.0521978531957290, + "date": 9957382189.7722167, + "precededByGap": false + }, + { + "precededByGap": false, + "timeSinceStart": 5.1930893136228618, + "date": 6117853154.6013721 + }, + { + "timeSinceStart": 6.168269553311381, + "precededByGap": false, + "date": 1745725940.9978662 + }, + { + "date": 1976712978.903725, + "precededByGap": false, + "timeSinceStart": 2.3069475267343171 + }, + { + "precededByGap": false, + "timeSinceStart": 5.8297908527491859, + "date": 3587622577.7499014 + }, + { + "date": 9819293385.3179276, + "precededByGap": false, + "timeSinceStart": 15.188011048333195 + }, + { + "timeSinceStart": 95.21863539994910, + "date": 1308788664.0329858, + "precededByGap": false + }, + { + "timeSinceStart": 64.894156680251802, + "date": 5505628959.0309993, + "precededByGap": false + }, + { + "timeSinceStart": 21.161691194935153, + "precededByGap": false, + "date": 6015674224.9529920 + }, + { + "precededByGap": false, + "timeSinceStart": 55.60822150782505, + "date": 1400661582.8317718 + }, + { + "date": 3224841469.3113241, + "timeSinceStart": 66.474073083266903, + "precededByGap": false + }, + { + "date": 1057590720.8567, + "timeSinceStart": 74.506280415883408, + "precededByGap": false + }, + { + "timeSinceStart": 51.567387739757723, + "date": 3963510291.5702180, + "precededByGap": false + }, + { + "date": 7082814560.4384724, + "precededByGap": false, + "timeSinceStart": 83.299392816278859 + }, + { + "date": 6757810417.418241, + "precededByGap": false, + "timeSinceStart": 75.088533409617035 + }, + { + "precededByGap": false, + "date": 3540114945.8209328, + "timeSinceStart": 56.255259710811021 + }, + { + "date": 9959500053.3602796, + "timeSinceStart": 43.067500980343614, + "precededByGap": false + }, + { + "date": 4426461214.9080886, + "precededByGap": false, + "timeSinceStart": 19.646576005446991 + }, + { + "timeSinceStart": 81.124503678836476, + "date": 6699644671.7938884, + "precededByGap": false + }, + { + "date": 7749707811.2428946, + "precededByGap": false, + "timeSinceStart": 61.227798246118771 + }, + { + "timeSinceStart": 67.676748577463995, + "date": 6210596381.331694, + "precededByGap": false + }, + { + "timeSinceStart": 70.159292660068567, + "date": 7715645840.5534507, + "precededByGap": false + }, + { + "timeSinceStart": 92.172251351930605, + "date": 2323554989.0717421, + "precededByGap": false + }, + { + "precededByGap": false, + "date": 3886410707.7568370, + "timeSinceStart": 38.86637603495670 + }, + { + "date": 2657848907.7568171, + "timeSinceStart": 50.485827093291539, + "precededByGap": false + }, + { + "date": 2069101105.1355647, + "timeSinceStart": 60.047066360528097, + "precededByGap": false + }, + { + "date": 4262931019.7365472, + "precededByGap": false, + "timeSinceStart": 56.760770648554205 + }, + { + "date": 4457320713.1803937, + "precededByGap": false, + "timeSinceStart": 41.802607769366120 + }, + { + "precededByGap": false, + "date": 1533078800.0423091, + "timeSinceStart": 49.601596154020277 + }, + { + "timeSinceStart": 18.441498943378064, + "date": 5980986523.3220126, + "precededByGap": false + }, + { + "precededByGap": false, + "date": 7712336863.5983489, + "timeSinceStart": 71.414598926762732 + }, + { + "precededByGap": false, + "timeSinceStart": 92.4437, + "date": 3514512985.6868826 + }, + { + "date": 8322478175.1114594, + "precededByGap": false, + "timeSinceStart": 88.6024 + }, + { + "precededByGap": false, + "timeSinceStart": 29.248195440085157, + "date": 1168676776.9662425 + }, + { + "timeSinceStart": 79.433379869332866, + "date": 5035752878.088219, + "precededByGap": false + }, + { + "date": 1816622784.7747694, + "precededByGap": false, + "timeSinceStart": 75.952011843074849 + }, + { + "date": 4080028792.2903372, + "timeSinceStart": 53.348069560324092, + "precededByGap": false + }, + { + "precededByGap": false, + "date": 1811194281.9697089, + "timeSinceStart": 35.933995598712666 + }, + { + "date": 8111403588.2629364, + "precededByGap": false, + "timeSinceStart": 40.745484764163 + }, + { + "timeSinceStart": 72.730211831269887, + "date": 3169688989.3112253, + "precededByGap": false + }, + { + "timeSinceStart": 40.986504143982092, + "date": 6527999135.5416463, + "precededByGap": false + }, + { + "precededByGap": false, + "timeSinceStart": 62.331864539823858, + "date": 7385945563.3943794 + }, + { + "precededByGap": false, + "date": 9921005072.0964865, + "timeSinceStart": 30.064845423262511 + }, + { + "timeSinceStart": 11.478003936471302, + "date": 1508595739.6221127, + "precededByGap": false + }, + { + "date": 5875305221.5406955, + "timeSinceStart": 94.512920953956073, + "precededByGap": false + }, + { + "timeSinceStart": 62.149608758072446, + "date": 1457298229.9513884, + "precededByGap": false + }, + { + "timeSinceStart": 62.618941135915397, + "precededByGap": false, + "date": 5303487004.6047872 + } + ], + "date": "2025-06-06 00:16:29 +0300", + "start": "2025-06-06 00:16:29 +0300", + "end": "2025-06-06 00:17:28 +0300" + }, + { + "end": "2025-06-06 11:31:35 +0300", + "qty": 90.14058990512054, + "heartbeatSeries": [ + { + "precededByGap": true, + "date": 7083484890.61297, + "timeSinceStart": 0.407468 + }, + { + "date": 1474906531.5871756, + "precededByGap": false, + "timeSinceStart": 5.004982856308269 + }, + { + "date": 8658903263.1018850, + "timeSinceStart": 1.4807963559238753, + "precededByGap": false + }, + { + "timeSinceStart": 1.024701, + "date": 4526622271.20697, + "precededByGap": true + }, + { + "timeSinceStart": 2.5091503502811948, + "date": 5019070240.3891780, + "precededByGap": false + }, + { + "timeSinceStart": 1.4947476825708789, + "precededByGap": true, + "date": 2436626670.6771267 + }, + { + "timeSinceStart": 4.2381164797072304, + "date": 7859156077.2136691, + "precededByGap": false + }, + { + "timeSinceStart": 1.4840301800760543, + "precededByGap": false, + "date": 3350788867.9988499 + }, + { + "timeSinceStart": 1.6174706338581461, + "date": 2201781030.1790726, + "precededByGap": false + }, + { + "timeSinceStart": 76.362114876750040, + "date": 4956007120.482461, + "precededByGap": false + }, + { + "timeSinceStart": 53.631480432828275, + "precededByGap": false, + "date": 9149621715.2385838 + }, + { + "date": 3715855703.0157515, + "timeSinceStart": 72.497390832000272, + "precededByGap": false + }, + { + "precededByGap": false, + "timeSinceStart": 86.018403315174563, + "date": 4736441058.57521 + }, + { + "date": 9000957328.1726289, + "precededByGap": false, + "timeSinceStart": 85.838039628007138 + }, + { + "date": 6576796906.3091040, + "precededByGap": false, + "timeSinceStart": 48.492219341044194 + }, + { + "timeSinceStart": 10.361132650103484, + "date": 6256615706.247128, + "precededByGap": false + }, + { + "timeSinceStart": 29.87069792740100, + "date": 1294004322.690807, + "precededByGap": false + }, + { + "date": 1001961220.510529, + "timeSinceStart": 61.39134, + "precededByGap": false + }, + { + "precededByGap": false, + "timeSinceStart": 51.680727012238700, + "date": 1839323959.7726563 + }, + { + "precededByGap": false, + "date": 3472361417.096725, + "timeSinceStart": 91 + }, + { + "date": 4826990425.3176300, + "timeSinceStart": 83.619765240667191, + "precededByGap": false + }, + { + "timeSinceStart": 10.497349, + "date": 6106959041.51327, + "precededByGap": false + }, + { + "timeSinceStart": 71.683393, + "date": 5914109094.15767, + "precededByGap": false + }, + { + "date": 8535185762.4824187, + "precededByGap": false, + "timeSinceStart": 77.757778295471486 + }, + { + "date": 1951867913.0849229, + "timeSinceStart": 67.24866615699993, + "precededByGap": false + }, + { + "date": 3290122044.1324209, + "timeSinceStart": 36.193907888310981, + "precededByGap": false + }, + { + "timeSinceStart": 41.853869154744907, + "date": 7553108736.6595391, + "precededByGap": true + }, + { + "precededByGap": false, + "timeSinceStart": 54.278271967966989, + "date": 3397429066.0191995 + }, + { + "date": 3383964606.0910157, + "timeSinceStart": 15.374542569216173, + "precededByGap": false + }, + { + "precededByGap": false, + "date": 9453909931.5302071, + "timeSinceStart": 70.688961451284457 + }, + { + "timeSinceStart": 85.5376113915716, + "date": 8527903185.5201554, + "precededByGap": false + }, + { + "date": 3833532726.0466381, + "timeSinceStart": 39.325074723044682, + "precededByGap": false + }, + { + "timeSinceStart": 51.677835663423346, + "precededByGap": false, + "date": 7096040758.6256057 + }, + { + "date": 6685143704.3116423, + "precededByGap": false, + "timeSinceStart": 61.04785026 + }, + { + "date": 9765989645.7654644, + "precededByGap": false, + "timeSinceStart": 15.919857383265833 + }, + { + "precededByGap": false, + "timeSinceStart": 30.709081717827611, + "date": 2123404262.6558111 + }, + { + "timeSinceStart": 69.688839940091103, + "date": 7413445159.6479758, + "precededByGap": false + }, + { + "date": 5814969011.2566160, + "timeSinceStart": 57.667422028031522, + "precededByGap": false + }, + { + "date": 6592553346.0727878, + "precededByGap": false, + "timeSinceStart": 60.47162189527939 + }, + { + "date": 5587039604.0316581, + "precededByGap": false, + "timeSinceStart": 19.900839099832010 + }, + { + "date": 4614951959.348113, + "precededByGap": false, + "timeSinceStart": 83.612189839618083 + }, + { + "precededByGap": false, + "timeSinceStart": 60.161461940965297, + "date": 8112355943.0298539 + }, + { + "timeSinceStart": 83.782742593829671, + "date": 1484454505.5246212, + "precededByGap": false + }, + { + "date": 2959518110.4921864, + "precededByGap": false, + "timeSinceStart": 24.841421745176644 + }, + { + "precededByGap": false, + "date": 1314440885.0832479, + "timeSinceStart": 64.915260287690918 + }, + { + "timeSinceStart": 74.170133735782600, + "date": 4031188734.4970767, + "precededByGap": false + }, + { + "date": 9123404825.219476, + "precededByGap": false, + "timeSinceStart": 29.865935408801001 + }, + { + "precededByGap": false, + "timeSinceStart": 38.166543961036381, + "date": 2727966353.3850555 + }, + { + "date": 2706441390.2806612, + "timeSinceStart": 33.591223332904774, + "precededByGap": false + }, + { + "date": 5914992824.6840509, + "timeSinceStart": 83.388875928168685, + "precededByGap": false + }, + { + "timeSinceStart": 71.83816912598245, + "date": 1492585088.0453538, + "precededByGap": false + }, + { + "timeSinceStart": 69.466728703019153, + "precededByGap": false, + "date": 5894821074.7681045 + }, + { + "date": 3287835300.1858831, + "timeSinceStart": 22.908710811229499, + "precededByGap": false + }, + { + "timeSinceStart": 85.360101931423561, + "date": 5640034746.7509943, + "precededByGap": false + }, + { + "precededByGap": false, + "timeSinceStart": 73.9419, + "date": 1636291248.305578 + }, + { + "date": 2119560477.3399937, + "precededByGap": false, + "timeSinceStart": 94.668323626933766 + }, + { + "date": 3271617698.7128734, + "precededByGap": false, + "timeSinceStart": 64.9421751881103 + }, + { + "date": 1940624563.8133917, + "precededByGap": false, + "timeSinceStart": 88.635802724250809 + }, + { + "date": 9840541026.4970818, + "precededByGap": false, + "timeSinceStart": 78.314590572727896 + }, + { + "date": 5491053006.9390733, + "timeSinceStart": 85.769540859478425, + "precededByGap": false + }, + { + "precededByGap": false, + "timeSinceStart": 93.473139781209616, + "date": 6535063885.8336494 + }, + { + "timeSinceStart": 65.146412902364838, + "precededByGap": false, + "date": 2347718058.8244127 + }, + { + "precededByGap": false, + "timeSinceStart": 11.611609, + "date": 1267561134.54121 + }, + { + "date": 1195742041.5845607, + "timeSinceStart": 28.889132122802926, + "precededByGap": false + }, + { + "date": 1569940739.6020094, + "timeSinceStart": 23.652990950531513, + "precededByGap": false + }, + { + "timeSinceStart": 97.519180657346742, + "precededByGap": false, + "date": 7714417592.5918662 + }, + { + "timeSinceStart": 17.331792299665982, + "precededByGap": false, + "date": 5509625875.398436 + }, + { + "precededByGap": false, + "timeSinceStart": 63.379430537893649, + "date": 2848110736.6401262 + } + ], + "start": "2025-06-06 11:30:35 +0300", + "source": "Device A  ", + "date": "2025-06-06 11:30:35 +0300" + } + ], + "name": "heart_rate_variability", + "units": "ms" + } + ] + } +} diff --git a/internal/hae/testdata/hour.json b/internal/hae/testdata/hour.json new file mode 100644 index 0000000..3327a2e --- /dev/null +++ b/internal/hae/testdata/hour.json @@ -0,0 +1,281 @@ +{ + "data": { + "metrics": [ + { + "name": "active_energy", + "units": "kJ", + "data": [ + { + "qty": 9.0964610089950502, + "date": "2025-06-05 00:00:00 +0300", + "source": "Device A  " + }, + { + "date": "2025-06-05 01:00:00 +0300", + "qty": 1.8011831544351500, + "source": "Device A  " + }, + { + "qty": 8.3677756683275249, + "source": "Device A  ", + "date": "2025-06-05 02:00:00 +0300" + }, + { + "source": "Device A  ", + "qty": 3.3985452404807405, + "date": "2025-06-05 03:00:00 +0300" + }, + { + "qty": 12.618722903999360, + "date": "2025-06-05 04:00:00 +0300", + "source": "Device A  " + }, + { + "source": "Device A  ", + "date": "2025-06-05 05:00:00 +0300", + "qty": 40.351034546189656 + }, + { + "source": "Device A  ", + "qty": 6.507009541624532, + "date": "2025-06-05 06:00:00 +0300" + }, + { + "date": "2025-06-06 06:00:00 +0300", + "qty": 55.821611193078514, + "source": "Device A  " + }, + { + "qty": 2.295029103054565, + "date": "2025-06-06 07:00:00 +0300", + "source": "Device A  " + }, + { + "date": "2025-06-06 08:00:00 +0300", + "qty": 322.8983115506521, + "source": "Device A  " + }, + { + "qty": 91.588676831471213, + "date": "2025-06-06 09:00:00 +0300", + "source": "Device A  " + }, + { + "qty": 462.61760657433137, + "source": "Device A  ", + "date": "2025-06-06 10:00:00 +0300" + }, + { + "date": "2025-06-06 11:00:00 +0300", + "qty": 958.07555299152843, + "source": "Device A  " + }, + { + "qty": 67.473524552265953, + "source": "Device A  ", + "date": "2025-06-06 12:00:00 +0300" + } + ] + }, + { + "units": "hr", + "data": [ + { + "core": 6.2052788381114499, + "inBedEnd": "2025-06-05 05:22:26 +0300", + "date": "2025-06-05 00:00:00 +0300", + "inBed": 0, + "rem": 0.89195271872064281, + "sleepEnd": "2025-06-05 05:22:26 +0300", + "source": "Device A  ", + "totalSleep": 4.0716939170300220, + "inBedStart": "2025-06-04 22:34:34 +0300", + "asleep": 0, + "awake": 4.3396901109717361, + "sleepStart": "2025-06-04 22:34:34 +0300", + "deep": 0.68835541253715609 + }, + { + "awake": 9.638151247778914, + "inBed": 0, + "deep": 0.38341974910703597, + "inBedStart": "2025-06-05 22:43:20 +0300", + "rem": 0.43532011302795238, + "sleepEnd": "2025-06-06 07:57:39 +0300", + "date": "2025-06-06 00:00:00 +0300", + "core": 5.152708504451071, + "source": "Device A  ", + "asleep": 0, + "sleepStart": "2025-06-05 22:43:20 +0300", + "inBedEnd": "2025-06-06 07:57:39 +0300", + "totalSleep": 4.4003075324079272 + } + ], + "name": "sleep_analysis" + }, + { + "name": "apple_stand_hour", + "data": [ + { + "qty": 8, + "source": "Device A", + "date": "2025-06-05 07:00:00 +0300" + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-05 08:00:00 +0300" + }, + { + "date": "2025-06-05 09:00:00 +0300", + "qty": 8, + "source": "Device A" + }, + { + "source": "Device A", + "date": "2025-06-05 10:00:00 +0300", + "qty": 8 + }, + { + "source": "Device A", + "date": "2025-06-05 12:00:00 +0300", + "qty": 8 + }, + { + "qty": 8, + "source": "Device A", + "date": "2025-06-05 13:00:00 +0300" + }, + { + "qty": 8, + "source": "Device A", + "date": "2025-06-05 15:00:00 +0300" + }, + { + "qty": 8, + "date": "2025-06-05 20:00:00 +0300", + "source": "Device A" + }, + { + "date": "2025-06-05 21:00:00 +0300", + "qty": 8, + "source": "Device A" + }, + { + "date": "2025-06-06 02:00:00 +0300", + "source": "Device A", + "qty": 8 + }, + { + "qty": 8, + "source": "Device A", + "date": "2025-06-06 08:00:00 +0300" + }, + { + "date": "2025-06-06 09:00:00 +0300", + "source": "Device A", + "qty": 8 + }, + { + "date": "2025-06-06 10:00:00 +0300", + "qty": 8, + "source": "Device A" + }, + { + "source": "Device A", + "date": "2025-06-06 11:00:00 +0300", + "qty": 8 + } + ], + "units": "count" + }, + { + "units": "ms", + "data": [ + { + "date": "2025-06-05 00:00:00 +0300", + "qty": 51.209189787471513, + "source": "Device A  " + }, + { + "date": "2025-06-05 02:00:00 +0300", + "source": "Device A  ", + "qty": 62.091594303847203 + }, + { + "date": "2025-06-05 04:00:00 +0300", + "qty": 496.11595771813635, + "source": "Device A  " + }, + { + "qty": 89.502763665053950, + "source": "Device A  ", + "date": "2025-06-05 06:00:00 +0300" + }, + { + "source": "Device A  ", + "qty": 15.145830153758666, + "date": "2025-06-05 08:00:00 +0300" + }, + { + "source": "Device A  ", + "date": "2025-06-05 10:00:00 +0300", + "qty": 96.442927435831922 + }, + { + "date": "2025-06-05 12:00:00 +0300", + "qty": 16.490675484303301, + "source": "Device A  " + }, + { + "date": "2025-06-05 22:00:00 +0300", + "source": "Device A  ", + "qty": 25.801867024959922 + }, + { + "date": "2025-06-06 00:00:00 +0300", + "qty": 23.750315211285474, + "source": "Device A  " + }, + { + "source": "Device A  ", + "date": "2025-06-06 02:00:00 +0300", + "qty": 21.676280750344913 + }, + { + "qty": 108.159159427689, + "date": "2025-06-06 04:00:00 +0300", + "source": "Device A  " + }, + { + "source": "Device A  ", + "date": "2025-06-06 06:00:00 +0300", + "qty": 14.548300046808019 + }, + { + "qty": 22.843350035832434, + "date": "2025-06-06 08:00:00 +0300", + "source": "Device A  " + }, + { + "date": "2025-06-06 11:00:00 +0300", + "qty": 90.14058990512054, + "source": "Device A  " + } + ], + "name": "heart_rate_variability" + }, + { + "data": [ + { + "qty": 745, + "source": "Device A", + "date": "2025-06-05 05:00:00 +0300" + } + ], + "units": "m", + "name": "six_minute_walking_test_distance" + } + ] + } +} diff --git a/internal/hae/testdata/minute.json b/internal/hae/testdata/minute.json new file mode 100644 index 0000000..b12540a --- /dev/null +++ b/internal/hae/testdata/minute.json @@ -0,0 +1,281 @@ +{ + "data": { + "metrics": [ + { + "units": "hr", + "data": [ + { + "rem": 0.89195271872064281, + "inBed": 0, + "awake": 4.3396901109717361, + "deep": 0.68835541253715609, + "asleep": 0, + "totalSleep": 4.0716939170300220, + "source": "Device A  ", + "core": 6.2052788381114499, + "inBedStart": "2025-06-04 22:34:34 +0300", + "sleepEnd": "2025-06-05 05:22:26 +0300", + "inBedEnd": "2025-06-05 05:22:26 +0300", + "date": "2025-06-05 00:00:00 +0300", + "sleepStart": "2025-06-04 22:34:34 +0300" + }, + { + "inBedStart": "2025-06-05 22:43:20 +0300", + "deep": 0.38341974910703597, + "rem": 0.43532011302795238, + "core": 5.152708504451071, + "date": "2025-06-06 00:00:00 +0300", + "inBed": 0, + "inBedEnd": "2025-06-06 07:57:39 +0300", + "asleep": 0, + "source": "Device A  ", + "sleepStart": "2025-06-05 22:43:20 +0300", + "awake": 9.638151247778914, + "sleepEnd": "2025-06-06 07:57:39 +0300", + "totalSleep": 4.4003075324079272 + } + ], + "name": "sleep_analysis" + }, + { + "name": "apple_stand_hour", + "data": [ + { + "source": "Device A", + "date": "2025-06-05 07:00:00 +0300", + "qty": 8 + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-05 08:00:00 +0300" + }, + { + "qty": 8, + "date": "2025-06-05 09:00:00 +0300", + "source": "Device A" + }, + { + "qty": 8, + "source": "Device A", + "date": "2025-06-05 10:00:00 +0300" + }, + { + "source": "Device A", + "date": "2025-06-05 12:00:00 +0300", + "qty": 8 + }, + { + "source": "Device A", + "date": "2025-06-05 13:00:00 +0300", + "qty": 8 + }, + { + "date": "2025-06-05 15:00:00 +0300", + "qty": 8, + "source": "Device A" + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-05 19:00:00 +0300" + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-05 20:00:00 +0300" + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-05 21:00:00 +0300" + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-06 02:00:00 +0300" + }, + { + "source": "Device A", + "date": "2025-06-06 08:00:00 +0300", + "qty": 8 + }, + { + "qty": 8, + "date": "2025-06-06 09:00:00 +0300", + "source": "Device A" + }, + { + "source": "Device A", + "date": "2025-06-06 10:00:00 +0300", + "qty": 8 + } + ], + "units": "count" + }, + { + "units": "kJ", + "name": "active_energy", + "data": [ + { + "qty": 0.94633004162661996, + "source": "Device A  ", + "date": "2025-06-05 00:24:00 +0300" + }, + { + "source": "Device A  ", + "date": "2025-06-05 00:25:00 +0300", + "qty": 0.22385813552875930 + }, + { + "source": "Device A  ", + "date": "2025-06-05 00:26:00 +0300", + "qty": 0.22385813552875930 + }, + { + "source": "Device A  ", + "date": "2025-06-05 00:27:00 +0300", + "qty": 0.27046667301884517 + }, + { + "qty": 0.23659841288096517, + "date": "2025-06-05 00:28:00 +0300", + "source": "Device A  " + }, + { + "qty": 0.23659841288096517, + "source": "Device A  ", + "date": "2025-06-05 00:29:00 +0300" + }, + { + "source": "Device A  ", + "qty": 0.23659841288096517, + "date": "2025-06-05 00:30:00 +0300" + }, + { + "qty": 4.3538077885409284, + "source": "Device A  ", + "date": "2025-06-06 11:47:00 +0300" + }, + { + "date": "2025-06-06 11:48:00 +0300", + "qty": 4.3538077885409284, + "source": "Device A  " + }, + { + "date": "2025-06-06 11:49:00 +0300", + "qty": 4.3538077885409284, + "source": "Device A  " + }, + { + "date": "2025-06-06 11:50:00 +0300", + "source": "Device A  ", + "qty": 2.1847867270334114 + }, + { + "date": "2025-06-06 11:51:00 +0300", + "qty": 2.1847867270334114, + "source": "Device A  " + }, + { + "source": "Device A  ", + "date": "2025-06-06 11:52:00 +0300", + "qty": 3.3347070079576802 + }, + { + "source": "Device A  ", + "date": "2025-06-06 11:53:00 +0300", + "qty": 0.96620370889283594 + } + ] + }, + { + "data": [ + { + "qty": 51.209189787471513, + "source": "Device A  ", + "date": "2025-06-05 00:16:00 +0300" + }, + { + "date": "2025-06-05 02:16:00 +0300", + "source": "Device A  ", + "qty": 62.091594303847203 + }, + { + "qty": 496.11595771813635, + "date": "2025-06-05 04:16:00 +0300", + "source": "Device A  " + }, + { + "date": "2025-06-05 06:16:00 +0300", + "qty": 89.502763665053950, + "source": "Device A  " + }, + { + "date": "2025-06-05 08:16:00 +0300", + "source": "Device A  ", + "qty": 15.145830153758666 + }, + { + "date": "2025-06-05 10:16:00 +0300", + "source": "Device A  ", + "qty": 96.442927435831922 + }, + { + "date": "2025-06-05 12:20:00 +0300", + "qty": 16.490675484303301, + "source": "Device A  " + }, + { + "date": "2025-06-05 22:19:00 +0300", + "qty": 25.801867024959922, + "source": "Device A  " + }, + { + "qty": 23.750315211285474, + "source": "Device A  ", + "date": "2025-06-06 00:16:00 +0300" + }, + { + "qty": 21.676280750344913, + "date": "2025-06-06 02:16:00 +0300", + "source": "Device A  " + }, + { + "date": "2025-06-06 04:16:00 +0300", + "source": "Device A  ", + "qty": 108.159159427689 + }, + { + "date": "2025-06-06 06:16:00 +0300", + "qty": 14.548300046808019, + "source": "Device A  " + }, + { + "source": "Device A  ", + "qty": 22.843350035832434, + "date": "2025-06-06 08:16:00 +0300" + }, + { + "date": "2025-06-06 11:30:00 +0300", + "source": "Device A  ", + "qty": 90.14058990512054 + } + ], + "units": "ms", + "name": "heart_rate_variability" + }, + { + "name": "six_minute_walking_test_distance", + "units": "m", + "data": [ + { + "date": "2025-06-05 05:46:00 +0300", + "source": "Device A", + "qty": 745 + } + ] + } + ] + } +} diff --git a/internal/hae/testdata/mixed.json b/internal/hae/testdata/mixed.json new file mode 100644 index 0000000..b3f9b28 --- /dev/null +++ b/internal/hae/testdata/mixed.json @@ -0,0 +1,314 @@ +{ + "data": { + "metrics": [ + { + "name": "apple_stand_hour", + "units": "count", + "data": [ + { + "qty": 8, + "source": "Device A", + "date": "2025-06-05 07:00:00 +0300" + }, + { + "source": "Device A", + "date": "2025-06-05 08:00:00 +0300", + "qty": 8 + }, + { + "date": "2025-06-05 09:00:00 +0300", + "source": "Device A", + "qty": 8 + }, + { + "date": "2025-06-05 10:00:00 +0300", + "qty": 8, + "source": "Device A" + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-05 12:00:00 +0300" + }, + { + "qty": 8, + "date": "2025-06-05 13:00:00 +0300", + "source": "Device A" + }, + { + "qty": 8, + "date": "2025-06-05 15:00:00 +0300", + "source": "Device A" + }, + { + "qty": 8, + "date": "2025-06-05 19:00:00 +0300", + "source": "Device A" + }, + { + "qty": 8, + "source": "Device A", + "date": "2025-06-05 20:00:00 +0300" + }, + { + "date": "2025-06-05 21:00:00 +0300", + "source": "Device A", + "qty": 8 + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-06 02:00:00 +0300" + }, + { + "date": "2025-06-06 08:00:00 +0300", + "source": "Device A", + "qty": 8 + }, + { + "date": "2025-06-06 09:00:00 +0300", + "qty": 8, + "source": "Device A" + }, + { + "source": "Device A", + "qty": 8, + "date": "2025-06-06 10:00:00 +0300" + } + ] + }, + { + "units": "hr", + "data": [ + { + "sleepEnd": "2025-06-05 05:22:26 +0300", + "inBedStart": "2025-06-04 22:34:34 +0300", + "core": 6.2052788381114499, + "rem": 0.89195271872064281, + "date": "2025-06-05 00:00:00 +0300", + "totalSleep": 4.0716939170300220, + "sleepStart": "2025-06-04 22:34:34 +0300", + "awake": 4.3396901109717361, + "inBed": 0, + "deep": 0.68835541253715609, + "source": "Device A  ", + "inBedEnd": "2025-06-05 05:22:26 +0300", + "asleep": 0 + }, + { + "source": "Device A  ", + "date": "2025-06-06 00:00:00 +0300", + "asleep": 0, + "core": 5.152708504451071, + "inBed": 0, + "totalSleep": 4.4003075324079272, + "rem": 0.43532011302795238, + "inBedEnd": "2025-06-06 07:57:39 +0300", + "sleepStart": "2025-06-05 22:43:20 +0300", + "awake": 9.638151247778914, + "deep": 0.38341974910703597, + "sleepEnd": "2025-06-06 07:57:39 +0300", + "inBedStart": "2025-06-05 22:43:20 +0300" + } + ], + "name": "sleep_analysis" + }, + { + "units": "kJ", + "name": "active_energy", + "data": [ + { + "source": "Device A  ", + "qty": 0.94633004162661996, + "date": "2025-06-05 00:24:00 +0300" + }, + { + "date": "2025-06-05 00:25:00 +0300", + "source": "Device A  ", + "qty": 0.22385813552875930 + }, + { + "source": "Device A  ", + "date": "2025-06-05 00:26:00 +0300", + "qty": 0.22385813552875930 + }, + { + "qty": 0.27046667301884517, + "source": "Device A  ", + "date": "2025-06-05 00:27:00 +0300" + }, + { + "date": "2025-06-05 00:28:00 +0300", + "qty": 0.23659841288096517, + "source": "Device A  " + }, + { + "qty": 0.23659841288096517, + "date": "2025-06-05 00:29:00 +0300", + "source": "Device A  " + }, + { + "source": "Device A  ", + "date": "2025-06-05 00:30:00 +0300", + "qty": 0.23659841288096517 + }, + { + "date": "2025-06-06 11:47:00 +0300", + "source": "Device A  ", + "qty": 4.3538077885409284 + }, + { + "source": "Device A  ", + "date": "2025-06-06 11:48:00 +0300", + "qty": 4.3538077885409284 + }, + { + "date": "2025-06-06 11:49:00 +0300", + "qty": 4.3538077885409284, + "source": "Device A  " + }, + { + "date": "2025-06-06 11:50:00 +0300", + "source": "Device A  ", + "qty": 2.1847867270334114 + }, + { + "source": "Device A  ", + "qty": 2.1847867270334114, + "date": "2025-06-06 11:51:00 +0300" + }, + { + "date": "2025-06-06 11:52:00 +0300", + "source": "Device A  ", + "qty": 3.3347070079576802 + }, + { + "qty": 0.96620370889283594, + "date": "2025-06-06 11:53:00 +0300", + "source": "Device A  " + } + ] + }, + { + "data": [ + { + "date": "2025-06-05 00:00:00 +0300", + "qty": 96, + "source": "Device A  " + }, + { + "source": "Device A  ", + "date": "2025-06-06 00:02:00 +0300", + "qty": 60 + } + ], + "name": "resting_heart_rate", + "units": "count/min" + }, + { + "units": "count/min", + "data": [ + { + "Min": 67, + "date": "2025-06-05 00:00:53 +0300", + "source": "Device A  ", + "Avg": 67, + "Max": 67 + }, + { + "source": "Device A  ", + "Avg": 18, + "Min": 18, + "date": "2025-06-05 00:02:37 +0300", + "Max": 18 + }, + { + "Max": 18, + "date": "2025-06-05 00:05:25 +0300", + "source": "Device A  |Device B", + "Min": 18, + "Avg": 18 + }, + { + "date": "2025-06-05 00:11:01 +0300", + "source": "Device A  |Device B", + "Max": 18, + "Min": 18, + "Avg": 18 + }, + { + "date": "2025-06-05 00:12:30 +0300", + "Min": 18, + "Avg": 18, + "source": "Device A  |Device B", + "Max": 18 + }, + { + "Max": 82.669907989920684, + "Min": 82.669907989920684, + "Avg": 82.669907989920684, + "source": "Device A  |Device B", + "date": "2025-06-05 00:16:36 +0300" + }, + { + "Avg": 67, + "source": "Device A  |Device B", + "Max": 67, + "date": "2025-06-05 00:18:54 +0300", + "Min": 67 + }, + { + "date": "2025-06-06 11:30:12 +0300", + "Max": 33, + "Min": 33, + "source": "Device A  |Device B", + "Avg": 33 + }, + { + "date": "2025-06-06 11:30:20 +0300", + "source": "Device A  |Device B", + "Max": 88, + "Min": 88, + "Avg": 88 + }, + { + "Avg": 37.044306705474884, + "date": "2025-06-06 11:30:35 +0300", + "source": "Device A  |Device B", + "Max": 37.044306705474884, + "Min": 37.044306705474884 + }, + { + "date": "2025-06-06 11:33:33 +0300", + "Min": 33, + "source": "Device A  |Device B", + "Max": 33, + "Avg": 33 + }, + { + "source": "Device A  |Device B", + "Avg": 16, + "date": "2025-06-06 11:36:55 +0300", + "Min": 16, + "Max": 16 + }, + { + "Min": 45, + "date": "2025-06-06 11:44:45 +0300", + "source": "Device A  |Device B", + "Max": 45, + "Avg": 45 + }, + { + "Avg": 64, + "date": "2025-06-06 11:51:40 +0300", + "Max": 64, + "source": "Device A  |Device B", + "Min": 64 + } + ], + "name": "heart_rate" + } + ] + } +} diff --git a/internal/hae/testdata/raw.json b/internal/hae/testdata/raw.json new file mode 100644 index 0000000..1299d40 --- /dev/null +++ b/internal/hae/testdata/raw.json @@ -0,0 +1,163 @@ +{ + "data": { + "metrics": [ + { + "data": [ + { + "source": "Device A  ", + "qty": 60, + "date": "2025-06-06 00:02:07 +0300", + "start": "2025-06-06 00:02:07 +0300", + "end": "2025-06-06 11:51:40 +0300" + } + ], + "name": "resting_heart_rate", + "units": "count/min" + }, + { + "name": "heart_rate", + "units": "count/min", + "data": [ + { + "Min": 71, + "source": "Device A  ", + "Avg": 71, + "date": "2025-06-06 00:02:07 +0300", + "start": "2025-06-06 00:02:07 +0300", + "context": "Не задано", + "Max": 71, + "end": "2025-06-06 00:02:07 +0300" + }, + { + "start": "2025-06-06 00:05:48 +0300", + "date": "2025-06-06 00:05:48 +0300", + "end": "2025-06-06 00:05:48 +0300", + "Max": 31, + "source": "Device A", + "Min": 31, + "Avg": 31 + }, + { + "source": "Device A  ", + "Min": 18, + "Max": 18, + "end": "2025-06-06 00:06:54 +0300", + "start": "2025-06-06 00:06:54 +0300", + "Avg": 18, + "date": "2025-06-06 00:06:54 +0300", + "context": "Не задано" + }, + { + "context": "Не задано", + "Avg": 67, + "Max": 67, + "start": "2025-06-06 00:13:52 +0300", + "end": "2025-06-06 00:13:52 +0300", + "Min": 67, + "source": "Device A  ", + "date": "2025-06-06 00:13:52 +0300" + }, + { + "source": "Device A  ", + "start": "2025-06-06 00:16:29 +0300", + "Min": 57.120105773170690, + "context": "Не задано", + "Max": 57.120105773170690, + "Avg": 57.120105773170690, + "date": "2025-06-06 00:16:29 +0300", + "end": "2025-06-06 00:16:29 +0300" + }, + { + "Avg": 67, + "start": "2025-06-06 00:18:17 +0300", + "Min": 67, + "date": "2025-06-06 00:18:17 +0300", + "Max": 67, + "source": "Device A  ", + "end": "2025-06-06 00:18:17 +0300", + "context": "Не задано" + }, + { + "Avg": 18, + "end": "2025-06-06 00:26:32 +0300", + "date": "2025-06-06 00:26:32 +0300", + "context": "Не задано", + "start": "2025-06-06 00:26:32 +0300", + "source": "Device A  ", + "Max": 18, + "Min": 18 + }, + { + "Avg": 33, + "Max": 33, + "Min": 33, + "context": "Сидячий образ жизни", + "date": "2025-06-06 11:30:12 +0300", + "start": "2025-06-06 11:30:12 +0300", + "source": "Device A  ", + "end": "2025-06-06 11:30:12 +0300" + }, + { + "start": "2025-06-06 11:30:20 +0300", + "source": "Device A", + "Min": 88, + "Avg": 88, + "end": "2025-06-06 11:30:20 +0300", + "Max": 88, + "date": "2025-06-06 11:30:20 +0300" + }, + { + "date": "2025-06-06 11:30:35 +0300", + "end": "2025-06-06 11:30:35 +0300", + "Avg": 37.044306705474884, + "Max": 37.044306705474884, + "start": "2025-06-06 11:30:35 +0300", + "source": "Device A  ", + "context": "Не задано", + "Min": 37.044306705474884 + }, + { + "source": "Device A  ", + "context": "Сидячий образ жизни", + "date": "2025-06-06 11:33:33 +0300", + "Max": 33, + "Min": 33, + "end": "2025-06-06 11:33:33 +0300", + "Avg": 33, + "start": "2025-06-06 11:33:33 +0300" + }, + { + "date": "2025-06-06 11:36:55 +0300", + "start": "2025-06-06 11:36:55 +0300", + "Avg": 16, + "Max": 16, + "Min": 16, + "context": "Сидячий образ жизни", + "source": "Device A  ", + "end": "2025-06-06 11:36:55 +0300" + }, + { + "Min": 45, + "Avg": 45, + "context": "Сидячий образ жизни", + "source": "Device A  ", + "Max": 45, + "start": "2025-06-06 11:44:45 +0300", + "end": "2025-06-06 11:44:45 +0300", + "date": "2025-06-06 11:44:45 +0300" + }, + { + "source": "Device A  ", + "Avg": 64, + "context": "Сидячий образ жизни", + "Max": 64, + "Min": 64, + "end": "2025-06-06 11:51:40 +0300", + "start": "2025-06-06 11:51:40 +0300", + "date": "2025-06-06 11:51:40 +0300" + } + ] + } + ] + } +} diff --git a/internal/hae/testdata/sparse_sleep.json b/internal/hae/testdata/sparse_sleep.json new file mode 100644 index 0000000..481cdd9 --- /dev/null +++ b/internal/hae/testdata/sparse_sleep.json @@ -0,0 +1,78 @@ +{ + "data": { + "metrics": [ + { + "data": [ + { + "qty": 0.48052029446490479, + "value": "Во сне", + "source": "Device A", + "start": "2025-06-05 22:04:00 +0300", + "startDate": "2025-06-05 22:04:00 +0300", + "endDate": "2025-06-05 22:16:00 +0300", + "end": "2025-06-05 22:16:00 +0300", + "date": "2025-06-05 22:04:00 +0300" + }, + { + "endDate": "2025-06-06 02:21:00 +0300", + "qty": 1.5130355381745861, + "startDate": "2025-06-05 22:04:00 +0300", + "date": "2025-06-05 22:04:00 +0300", + "value": "В кровати", + "source": "Device A", + "end": "2025-06-06 02:21:00 +0300", + "start": "2025-06-05 22:04:00 +0300" + }, + { + "qty": 9.1550074802731359, + "source": "Device A", + "end": "2025-06-06 01:26:00 +0300", + "value": "Во сне", + "start": "2025-06-05 22:40:00 +0300", + "startDate": "2025-06-05 22:40:00 +0300", + "date": "2025-06-05 22:40:00 +0300", + "endDate": "2025-06-06 01:26:00 +0300" + }, + { + "date": "2025-06-06 01:27:00 +0300", + "endDate": "2025-06-06 02:21:00 +0300", + "qty": 0.78349523542270867, + "end": "2025-06-06 02:21:00 +0300", + "source": "Device A", + "value": "Во сне", + "start": "2025-06-06 01:27:00 +0300", + "startDate": "2025-06-06 01:27:00 +0300" + } + ], + "units": "hr", + "name": "sleep_analysis" + }, + { + "name": "heart_rate", + "units": "count/min", + "data": [ + { + "context": "Не задано", + "Max": 31, + "date": "2025-06-06 03:00:36 +0300", + "start": "2025-06-06 03:00:36 +0300", + "Avg": 31, + "source": "Device A  ", + "Min": 31, + "end": "2025-06-06 03:00:36 +0300" + }, + { + "Min": 12, + "source": "Device A  ", + "start": "2025-06-06 03:04:20 +0300", + "date": "2025-06-06 03:04:20 +0300", + "Max": 12, + "Avg": 12, + "context": "Не задано", + "end": "2025-06-06 03:04:20 +0300" + } + ] + } + ] + } +} diff --git a/internal/store/migrations/00003_bucket.sql b/internal/store/migrations/00003_bucket.sql new file mode 100644 index 0000000..3a38372 --- /dev/null +++ b/internal/store/migrations/00003_bucket.sql @@ -0,0 +1,58 @@ +-- +goose Up +-- Часовой объект — единица хранения разобранных точек. Строка на точку дала бы +-- десятки миллионов строк в год; час даёт ~260 тысяч на слой независимо от +-- плотности потока. +-- +-- Внутрь payload средствами SQL не заглянуть — это осознанная плата. Границы +-- содержимого и счётчик вынесены колонками, чтобы каталог разрезов не разжимал +-- каждый блоб ради диапазона. +CREATE TABLE bucket ( + metric TEXT NOT NULL, + -- Слой выводится из выравнивания меток, а не из заголовка доставки: + -- sample / raw / minute / hour / day. + layer TEXT NOT NULL, + -- Начало часа в UTC, RFC 3339. Час берётся по НАЧАЛУ точки: интервал + -- пересекает границы часов, и любой другой выбор сделал бы принадлежность + -- объекту зависящей от длительности. + hour_utc TEXT NOT NULL, + + -- Единицы измерения живут на уровне метрики, внутри точки их нет (проверено + -- на 89 доставках: поле units не встретилось ни разу). Без этой колонки + -- единицы метрики, переставшей приходить, теряются навсегда. + units TEXT NOT NULL DEFAULT '', + + -- Точки часа: JSON-массив исходных байтов точек, сжатый gzip. Точки + -- упорядочены по времени начала. + payload BLOB NOT NULL, + -- Хеш канонической формы содержимого: детектор изменений, а не ключ. + -- Совпал — записи не происходит, и широкие проходы синхронизации + -- обходятся даром. + content_hash TEXT NOT NULL, + + points INTEGER NOT NULL DEFAULT 0, + -- Границы содержимого: первая и последняя метка начала, UTC RFC 3339. + first_ts TEXT NOT NULL, + last_ts TEXT NOT NULL, + + -- Провенанс: доставка, создавшая объект. Нужен, чтобы разбирать слияния; + -- у отдельной точки провенанса нет и не будет. + first_delivery_id TEXT NOT NULL DEFAULT '', + + -- Час, в который досчёт уже не ожидается. Правило перевода в sealed пока + -- не определено — порог ставится по наблюдениям, которых нет; до тех пор + -- признак остаётся невыставленным. + sealed INTEGER NOT NULL DEFAULT 0, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + PRIMARY KEY (metric, layer, hour_utc) +) WITHOUT ROWID; + +-- Каталог разрезов и Read API ходят «дай метрику за период»: метрика и слой +-- уже в первичном ключе, но выборка по времени поверх всех метрик нужна +-- ретеншену и проверке сходимости. +CREATE INDEX bucket_hour_utc ON bucket (hour_utc); + +-- +goose Down +DROP TABLE bucket; diff --git a/openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md b/openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md index 2a101b3..ee208ce 100644 --- a/openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md +++ b/openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md @@ -45,6 +45,13 @@ но **не выводится** — он назначается схемам с фиксированной гранулярностью (см. «Разделение схем под одним именем метрики»). +Слоем метрики SHALL становиться **самое мелкое** выравнивание среди её меток, а +не преобладающее. Измерено: у плотных метрик выравнивания перемешаны +(`active_energy` — 1320 минутных меток и 21 часовая, `heart_rate` — 654 +посекундных и 10 минутных), потому что метка ровно на часе одновременно +является и минутной. Метрика, у которой хоть одна метка стоит на середине часа, +часовой не является. + Разделение схем под одним именем выполняется **до** вывода слоя, и точки с назначенным слоем в определении преобладающего слоя доставки не участвуют: суточных сводок сна бывает больше порога плотности, и их полуночные метки @@ -60,6 +67,12 @@ - **WHEN** в доставке у метрики не меньше десяти точек с метками - **THEN** слой определяется выравниванием её собственных меток +#### Scenario: Одна метка на середине часа делает метрику минутной + +- **WHEN** у плотной метрики десять меток стоят ровно на часе, а одна — на + середине часа +- **THEN** слой метрики `minute`, а не `hour` + #### Scenario: Редкая метрика наследует преобладающий слой - **WHEN** в доставке у метрики меньше десяти точек diff --git a/openspec/changes/razbor-metrik-v-obekty/tasks.md b/openspec/changes/razbor-metrik-v-obekty/tasks.md index ba240e4..d2e83a9 100644 --- a/openspec/changes/razbor-metrik-v-obekty/tasks.md +++ b/openspec/changes/razbor-metrik-v-obekty/tasks.md @@ -1,32 +1,32 @@ ## 1. Фикстуры и схема -- [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени -- [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default` -- [ ] 1.3a Фикстура с эпизодами сна: три точки с одной меткой `date` и разными парами `start`/`end`, включая эпизод, пересекающий границу часа (в архиве такие есть — вычистить значения, интервалы сохранить) -- [ ] 1.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста -- [ ] 1.4 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `units`, `payload` BLOB, `content_hash`, `points`, `first_ts`, `last_ts`, `first_delivery_id`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)` -- [ ] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций) +- [x] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени +- [x] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default` +- [x] 1.3a Фикстура с эпизодами сна: три точки с одной меткой `date` и разными парами `start`/`end`, включая эпизод, пересекающий границу часа (в архиве такие есть — вычистить значения, интервалы сохранить) +- [x] 1.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста +- [x] 1.4 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `units`, `payload` BLOB, `content_hash`, `points`, `first_ts`, `last_ts`, `first_delivery_id`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)` +- [x] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций) ## 2. Канонизация — общий дом -- [ ] 2.1 Пакет `internal/canon`: каноническая форма (числа читаются литералом через `json.Number`, округление до 12 значащих цифр — именованная константа со ссылкой на находку 30), полнота точки, хеш -- [ ] 2.2 Сортировку ключей **не писать** — её делает `encoding/json` -- [ ] 2.3 Тест канонизации на настоящих парах чисел из находки 30: все три схлопываются -- [ ] 2.4 Тест: значение с `1.0`, целым больше 2^53 и невалидным UTF-8 переживает хранение дословно +- [x] 2.1 Пакет `internal/canon`: каноническая форма (числа читаются литералом через `json.Number`, округление до 12 значащих цифр — именованная константа со ссылкой на находку 30), полнота точки, хеш +- [x] 2.2 Сортировку ключей **не писать** — её делает `encoding/json` +- [x] 2.3 Тест канонизации на настоящих парах чисел из находки 30: все три схлопываются +- [x] 2.4 Тест: значение с `1.0`, целым больше 2^53 и невалидным UTF-8 переживает хранение дословно ## 3. Разбор — пакет `internal/hae` -- [ ] 3.1 Декодирование конверта в структуру с `Data []json.RawMessage`; точка декодируется по одной. Тест удержания кучи на теле в десятки МиБ -- [ ] 3.2 Разбор метки `2006-01-02 15:04:05 -0700` в UTC с сохранением офсета; неразобранная метка — пропуск точки со счётчиком, не паника -- [ ] 3.3 Эпоха внутри `heartbeatSeries` **не разбирается**: элементы проходят исходными байтами -- [ ] 3.4 Вывод слоя: плотная метрика (≥10 точек) сама, редкая наследует преобладающий, доставка без плотных — наследует последний слой автоматизации по `automation-id` -- [ ] 3.5 Наследовать нечего и заголовок `Default` — точки не сохраняются, `WARN` и счётчик -- [ ] 3.6 `WARN` о расхождении слоя — только против `Minutes`/`Hours`; `Default` в сравнении не участвует -- [ ] 3.7 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`; выполняется **до** вывода слоя, точки с назначенным слоем в голосовании не участвуют -- [ ] 3.8 `recover` **внутри** `hae.Parse` — превращает панику разбора в ошибку пакета; паника из `store` наверх не перехватывается -- [ ] 3.9 Контракт: `error` ненулевая только когда точек нет вовсе; частичные исходы — счётчиками в результате -- [ ] 3.10 `FuzzParse` и таблица враждебных входов: усечённое тело, `null` вместо объекта, массив вместо `data`, число вместо строки даты -- [ ] 3.11 Тесты разбора на фикстурах 1.2–1.3, включая смешанную доставку и обе схемы сна +- [x] 3.1 Декодирование конверта в структуру с `Data []json.RawMessage`; точка декодируется по одной. Тест удержания кучи на теле в десятки МиБ +- [x] 3.2 Разбор метки `2006-01-02 15:04:05 -0700` в UTC с сохранением офсета; неразобранная метка — пропуск точки со счётчиком, не паника +- [x] 3.3 Эпоха внутри `heartbeatSeries` **не разбирается**: элементы проходят исходными байтами +- [x] 3.4 Вывод слоя: плотная метрика (≥10 точек) сама, редкая наследует преобладающий, доставка без плотных — наследует последний слой автоматизации по `automation-id` +- [x] 3.5 Наследовать нечего и заголовок `Default` — точки не сохраняются, `WARN` и счётчик +- [x] 3.6 `WARN` о расхождении слоя — только против `Minutes`/`Hours`; `Default` в сравнении не участвует +- [x] 3.7 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`; выполняется **до** вывода слоя, точки с назначенным слоем в голосовании не участвуют +- [x] 3.8 `recover` **внутри** `hae.Parse` — превращает панику разбора в ошибку пакета; паника из `store` наверх не перехватывается +- [x] 3.9 Контракт: `error` ненулевая только когда точек нет вовсе; частичные исходы — счётчиками в результате +- [x] 3.10 `FuzzParse` и таблица враждебных входов: усечённое тело, `null` вместо объекта, массив вместо `data`, число вместо строки даты +- [x] 3.11 Тесты разбора на фикстурах 1.2–1.3, включая смешанную доставку и обе схемы сна ## 4. Хранение — часовые объекты