// 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) } // Fullness — отношение полноты двух точек: несёт ли одна всё, что несёт // другая, и сверх того. // // Полнота — частичный порядок, а не число. Счётчик значащих полей сравним // всегда и потому отвечает там, где ответа нет: точка с пятью полями без // содержания «полнее» настоящего измерения с двумя. Измерено (находка 49): // настоящих столкновений 0.65% координат, из них 981 различаются набором // полей — это и есть область правила, — а несравнимых наборов ноль. // // Нумерация с единицы: нулевое значение не означает ничего. Незаполненное поле // или ранний возврат не должны выглядеть как «множества равны» — это // сегодняшний исход по умолчанию, и отказ маскировался бы под успех. type Fullness int const ( // FullnessEqual — множества ключей совпадают. FullnessEqual Fullness = iota + 1 // FullnessSuperset — a несёт всё, что b, и сверх того. FullnessSuperset // FullnessSubset — b несёт всё, что a, и сверх того. FullnessSubset // FullnessIncomparable — у каждой точки есть ключ, которого нет у другой. FullnessIncomparable ) func (f Fullness) String() string { switch f { case FullnessEqual: return "equal" case FullnessSuperset: return "superset" case FullnessSubset: return "subset" case FullnessIncomparable: return "incomparable" default: return "unknown(" + strconv.Itoa(int(f)) + ")" } } // Fields — ключи точки, разобранные ОДИН раз: множество всех и значения тех, // что несут содержание. // // Разбор вынесен в отдельный тип не ради красоты. Победитель столкновения // выбирается из множества кандидатов, а не парой (см. store), и сравнений там // квадратично по числу кандидатов. Разбирай их RelateFullness каждый раз — // доставка с сотней точек на одной координате разобрала бы каждую сотню раз. type Fields struct { // full — ключ с непустым значением → его исходные байты. Значения нужны // целиком: полнота требует не только наличия ключа, но и совпадения // содержания (см. Relate). full map[string]json.RawMessage // all — все ключи, включая те, чьё значение пусто. all map[string]struct{} } // Analyze разбирает точку на множества ключей. // // Поле source не входит ни в одно из множеств: оно нестабильно и // переписывается задним числом (находка 36), так что о полноте измерения // ничего не говорит. // // Содержимое, которое не разбирается как JSON-объект, даёт пустые множества: // так оно проигрывает любой точке с содержанием и не загрязняет наблюдение о // несравнимых наборах. func Analyze(raw []byte) Fields { f := Fields{ full: make(map[string]json.RawMessage), all: make(map[string]struct{}), } var obj map[string]json.RawMessage if err := json.Unmarshal(raw, &obj); err != nil { return f } for k, v := range obj { if k == "source" { continue } f.all[k] = struct{}{} if !isEmpty(v) { f.full[k] = v } } return f } // RelateFullness сравнивает полноту двух точек. // // Обёртка над Analyze и Relate для одиночного сравнения; в слиянии разбор // переиспользуется. func RelateFullness(a, b []byte) Fullness { return Analyze(a).Relate(Analyze(b)) } // Relate сравнивает полноту: несёт ли одна точка всё, что несёт другая, и // сверх того. // // Разрядов сравнения два. Сперва ключи с НЕПУСТЫМ значением: точка с // `context: null` не полнее точки без `context`. Если они совпали — все ключи: // иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с // `Max` исчезли бы из витрины по жребию тай-брейка. // // «Несёт всё, что несёт другая» — про СОДЕРЖАНИЕ, а не про имена ключей. Если // значения общих содержательных ключей разошлись, точки несут разные // измерения, и надмножество имён о полноте не говорит ничего: иначе // `{qty: 0.001, p1: null, p2: null}` оказывалось бы полнее `{qty: 72.5}` и // стирало настоящее измерение — ровно то, ради отрицания чего правило и // переписано. Такая пара уходит в тай-брейк как равнополная. // // Несравнимость — исход ТОЛЬКО первого разряда: у каждой точки есть // содержательный ключ, которого нет у другой, и объединять там было бы что. // Во втором разряде лишние ключи заведомо пусты, объединять в них нечего, и // счётчик, ради которого объединение полей отложено, не должен считать это // событие (иначе замер «0 из 2 897», снятый по содержательным ключам, теряет // сопоставимость с тем, что считает код). // // Отношение — частичный порядок: антисимметрично по построению и транзитивно // (если A ⊇ B и B ⊇ C, то ключи C ⊆ ключей B ⊆ ключей A, а согласие значений // переносится через B). На это опирается выбор победителя из множества. func (f Fields) Relate(g Fields) Fullness { rel := relateKeys(keysOf(f.full), keysOf(g.full)) if rel == FullnessIncomparable { return rel } if !agreeOnShared(f.full, g.full) { return FullnessEqual } if rel != FullnessEqual { return rel } if rel2 := relateKeys(f.all, g.all); rel2 == FullnessSuperset || rel2 == FullnessSubset { return rel2 } return FullnessEqual } // agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих // точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда // расхождением не считаются. func agreeOnShared(a, b map[string]json.RawMessage) bool { for k, va := range a { vb, ok := b[k] if !ok { continue } if !Equal(va, vb) { return false } } return true } func keysOf(m map[string]json.RawMessage) map[string]struct{} { out := make(map[string]struct{}, len(m)) for k := range m { out[k] = struct{}{} } return out } // relateKeys сравнивает два множества ключей по включению. func relateKeys(a, b map[string]struct{}) Fullness { aExtra := hasExtra(a, b) bExtra := hasExtra(b, a) switch { case aExtra && bExtra: return FullnessIncomparable case aExtra: return FullnessSuperset case bExtra: return FullnessSubset default: return FullnessEqual } } // hasExtra говорит, есть ли в a ключ, которого нет в b. func hasExtra(a, b map[string]struct{}) bool { for k := range a { if _, ok := b[k]; !ok { return true } } return false } // Less задаёт детерминированный порядок на точках равной полноты. // // Тай-брейк по времени приёма для этого не годится: у сохранённой точки нет // провенанса, сравнивать не с чем, а четверть доставок несёт столкновения // ВНУТРИ себя, где время приёма общее. Порядок канонических форм зависит // только от самих значений, поэтому свёртка по журналу даёт то же состояние, // что приём в реальном времени. // // Порядок ТОТАЛЬНЫЙ, включая вход, который не канонизируется: иначе на паре // из двух неразбираемых значений Less(a,b) и Less(b,a) оба давали бы false, // победителем оказывался бы просто второй аргумент, и пересборка журнала // разошлась бы с живым приёмом. Сегодня такой вход недостижим — hae отсеивает // точки, не разбирающиеся в объект, — но станет достижимым со вторым // источником точек (импорт родного экспорта Apple). func Less(a, b []byte) bool { return bytes.Compare(SortKey(a), SortKey(b)) < 0 } // SortKey возвращает то, по чему точки упорядочиваются: каноническую форму, // а для содержимого, которое не канонизируется, — исходные байты. // // Вынесено наружу, чтобы слияние считало форму один раз на точку, а не по разу // на каждое сравнение. func SortKey(raw []byte) []byte { form, err := Form(raw) if err != nil { return raw } return form } // isEmpty говорит, несёт ли поле содержание. // // Пусто — `null`, пустая строка, число, равное нулю, пустой объект и пустой // массив. Набор совпадает с `omitempty` из encoding/json минус `false` плюс // пустой объект, и оба отклонения сознательны: // // - `false` пустотой НЕ считается: для булева поля это одно из двух значений, // а не отсутствие сведений (`isIndoor: false` — тренировка на улице). // - Ноль считается: точка, где все значения нулевые, не должна вытеснять // настоящее измерение. Цена названа вслух — при столкновении нулевого // значения с ненулевым по одним координатам выиграет ненулевое, хотя ноль // бывает и настоящим измерением. Речь именно о столкновении, где одно из // двух содержимых заведомо неверно; одиночная нулевая точка хранится как // пришла. // // Значение НЕ материализуется: решение принимается по литералу. Разбор // значения целиком стоил бы разворачивания heartbeatSeries в []any на каждое // сравнение — ровно той формы, от которой разбор тела намеренно отказался // (197 МиБ кучи против 54 МиБ на теле 42 МиБ). При этом `0.0`, `0e0`, `-0` и // `{ }` обязаны считаться пустыми, поэтому по байтам сравнивать тоже нельзя: // число проверяется strconv, скобки — на пробельное содержимое. func isEmpty(v json.RawMessage) bool { lit := bytes.TrimSpace(v) if len(lit) == 0 { return true } switch lit[0] { case 'n': // null return bytes.Equal(lit, []byte("null")) case '"': return bytes.Equal(lit, []byte(`""`)) case '{': return emptyBracketed(lit, '{', '}') case '[': return emptyBracketed(lit, '[', ']') case 't', 'f': // Булево — одно из двух значений, а не отсутствие сведений. return false default: f, err := strconv.ParseFloat(string(lit), 64) return err == nil && f == 0 } } // emptyBracketed говорит, что между скобками нет ничего, кроме пробелов. func emptyBracketed(lit []byte, open, close byte) bool { if len(lit) < 2 || lit[0] != open || lit[len(lit)-1] != close { return false } return len(bytes.TrimSpace(lit[1:len(lit)-1])) == 0 } // 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) }