// 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) }