Files
healthlog/internal/canon/canon.go
T
av cd7a4c1493 отработаны находки ревью кода
Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера,
4 «сейчас», 2 развилки. Все закрыты регрессионными тестами.

- схема точки сна определяется по самой точке, а не по индексу в исходном
  массиве: одна пропущенная точка меняла эпизод и сводку местами
- доставка сворачивается одной транзакцией: частичное состояние было
  недетерминированным (восемь прогонов — семь состояний)
- граница размера на распакованном теле: 400 КиБ gzip разворачивались в
  400 МиБ мимо max_body_mb
- столкновение — расхождение канонических форм, а не байтов; WARN с
  координатами объекта; payload без HTML-экранирования
- выравнивание по местной метке: получасовые зоны уводили часовую выгрузку
  в minute
- доставка из одних суточных сводок больше не отвергается целиком
- единицы не переписываются молча; счётчик считает сохранённые точки
- все выходы Fold логируются, исход пишется на переживающем отмену контексте

Четыре развилки вынесены блокерами в беклог.
2026-08-01 19:02:05 +03:00

267 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package 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
}
// Equal говорит, одинаковы ли значения с точностью до канонической формы:
// порядка ключей и дребезга последнего разряда.
//
// Именно это, а не побайтовое равенство, является отношением «то же самое» в
// хранилище. Байты нестабильны: порядок ключей в JSON от HAE меняется между
// доставками, а числа расходятся последним разрядом double при одинаковом
// измерении.
func Equal(a, b []byte) bool {
if bytes.Equal(a, b) {
return true
}
fa, err := Form(a)
if err != nil {
return false
}
fb, err := Form(b)
if err != nil {
return false
}
return bytes.Equal(fa, fb)
}
// 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)
}