// 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/research/apple-health.md, находка 30). Двенадцать // цифр отсекают дребезг сериализации и оставляют нетронутым всё, что Apple // реально измеряет: даже доли процента у walking_asymmetry_percentage не // доходят до седьмой значащей цифры. const SignificantDigits = 12 // Form возвращает каноническую форму значения: ключи объектов отсортированы, // числа округлены до SignificantDigits значащих цифр. // // Форма предназначена для сравнения и хеширования, а не для хранения. // // Возвращаемый срез принадлежит вызывающему целиком: буфер, в котором форма // собрана, наружу больше не показывается. func Form(raw []byte) ([]byte, error) { return form(raw) } // Hash возвращает шестнадцатеричный SHA-256 канонической формы. // // Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать // нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий // проход переприсылает неделю, но почти все сравнения сходятся. func Hash(raw []byte) (string, error) { f, err := form(raw) if err != nil { return "", err } return hashOf(f), nil } // FormAndHash отдаёт каноническую форму и её хеш ЗА ОДИН проход. // // Нужен тем, кому требуется и то, и другое: сущность хешируется ради // хеш-детектора и канонизируется ради сравнения полноты, и считать форму дважды // над теми же байтами значит платить дважды за самую дорогую операцию // хранилища (тело 40 МиБ даёт пик кучи 768 МиБ). // // Отдельной функции «хеш по готовой форме» здесь нет намеренно: она вводила бы // контракт очерёдности, в котором передача сырых байт вместо формы даёт // правдоподобный, но неверный хеш, а компилятор такую подмену не ловит. // // Обратной ошибки — «Form считает хеш и выбрасывает» — здесь тоже нет: общий // низ у трёх функций один и хеша не считает. Иначе каждая точка при каждом // слиянии платила бы SHA-256, который никто не смотрит: Form зовётся из SortKey // на каждый кандидат координаты, из HashAll на каждую точку часа и дважды на // каждое сравнение в Equal. func FormAndHash(raw []byte) ([]byte, string, error) { f, err := form(raw) if err != nil { return nil, "", err } return f, hashOf(f), nil } // form — общий низ: каноническая форма и ничего сверх неё. 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 } func hashOf(form []byte) string { sum := sha256.Sum256(form) return hex.EncodeToString(sum[:]) } // 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 } // Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g. // // Отдельно от Relate, и это не дубль. Relate гасит отношение включения до // FullnessEqual, когда значения общих содержательных ключей разошлись, — верно // для точки (надмножество имён при других значениях означает другое // измерение), но неверно для сущности с собственным `id`: у неё вторая версия // есть тот же объект, пересчитанный источником, и значения между версиями // расходятся ВСЕГДА. Приложи Relate к тренировке — и обеднённая версия // получила бы «равенство» и заместила бы сохранённую вместе с маршрутом // (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы // зелёным. // // Условий четыре, все по ВЕРХНЕМУ уровню: // // 1. каждый содержательный ключ g есть у f и содержателен; // // 2. если множества содержательных ключей СОВПАЛИ — каждый ключ g, даже // пустой, есть у f. Тот же второй разряд, что у Relate, и с тем же // условием: иначе ключ с пустым значением исчезает по жребию тай-брейка. // // Условность разряда проверена оракулом, а не выведена. Безусловный // вариант («строже — значит правильнее») оказался хуже: версия с // `totalEnergy: null` и без маршрута запирала законный досчёт навсегда — // приехавшая теряла пустой ключ, сохранённая теряла содержательный // `route`, и пара становилась несравнимой. Маршрут не доезжал НИКОГДА, и // пересборка проигрывала то же поражение. Второй разряд разрешает спор // равных, а не отменяет первый; // // 3. форма значения не вырождается: где у g объект — у f объект, где массив — // массив. Без этого «скелет» (каждый вложенный объект заменён числом) // признаётся равным настоящей тренировке и выигрывает тай-брейк журнала; // // 4. верхнеуровневый массив не теряет ни длины, ни СОДЕРЖАТЕЛЬНЫХ элементов: // усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из // [null,null,null] не теряет и длины. Досчёт ряды удлиняет, поэтому и // укорачивание, и опустошение элементов — законные признаки «приехало // меньше». // // Условия 3 и 4 применяются к ключам, содержательным у g: у пустоты формы нет, // и требовать её сохранения значило бы отличать `[]` от `0` там, где ни то, ни // другое ничего не несёт. // // Содержательность элемента ряда — ТА ЖЕ пустота, что у поля (isEmpty): второй // словарь пустоты дал бы два ответа на один вопрос. Цена названа вслух: ряд // настоящих нулей ([0,0,0]) считается лишённым содержания, поэтому версия с ним // сохранённую не заместит. Ошибка направлена в безопасную сторону — правило // удерживает, а не затирает, и событие видно счётчиком; наблюдённые ряды HAE // состоят из объектов. // // Предел правила назван вслух и не закрывается: сокращение ВНУТРИ элемента ряда // (точка маршрута без altitude при непустом элементе и той же длине) не ловится // ничем, кроме сверки с телом в архиве. Поэлементная сверка содержимого // отвергнута ценой: она разворачивала бы каждый элемент маршрута в дерево // значений на каждое сравнение, а тело 40 МиБ уже даёт 768 МиБ пика. // // Поле `source` в множества не входит (см. Analyze) — исключение придумано для // точек, где оно измерено, и наследуется сущностью молча. Названо здесь потому, // что список исключений живёт в Analyze: правка ради точек изменит и правило // удержания сущностей, а ни один тест сущностей этого не заметит. // // Формы и длины считаются здесь, а не в Analyze: Analyze зовётся на каждый // кандидат слияния точек, и разбор heartbeatSeries на каждой точке стоил бы // дороже самого сравнения. func (f Fields) Covers(g Fields) bool { for k, gv := range g.full { fv, ok := f.full[k] if !ok { return false } if !shapeKept(fv, gv) { return false } } // Первый разряд пройден. Второй включается ТОЛЬКО при равенстве множеств // содержательных ключей: если f несёт содержание сверх g, спор уже решён в // её пользу, и пустой ключ его не отменяет. if len(f.full) != len(g.full) { return true } for k := range g.all { if _, ok := f.all[k]; !ok { return false } } return true } // shapeKept говорит, сохраняет ли значение fv форму и наполнение gv. func shapeKept(fv, gv json.RawMessage) bool { switch literalKind(gv) { case kindObject: return literalKind(fv) == kindObject case kindArray: // Третий возврат смотрится У ОБЕИХ сторон. Неразобравшийся массив у g // дал бы нули, то есть покрывался бы даже пустым `[]`. Из тела HAE это // недостижимо (значения приходят разобранным JSON), но сохранённая // версия приезжает сюда из `payload` базы, а вторым источником сущностей // планируется импорт родного экспорта Apple — там байты формирует другой // код. gTotal, gFull, gok := arrayShape(gv) fTotal, fFull, fok := arrayShape(fv) return gok && fok && fTotal >= gTotal && fFull >= gFull default: // Скаляр покрывается чем угодно: у f может быть и объект — это форма // богаче, а не беднее. return true } } // literalKind — род значения по первому байту литерала, как это делает сам // сканер encoding/json. Материализовать значение ради рода незачем. type literalKindT int const ( kindScalar literalKindT = iota kindObject kindArray ) func literalKind(raw json.RawMessage) literalKindT { lit := bytes.TrimSpace(raw) if len(lit) == 0 { return kindScalar } switch lit[0] { case '{': return kindObject case '[': return kindArray default: return kindScalar } } // arrayShape возвращает число элементов верхнеуровневого массива и число // СОДЕРЖАТЕЛЬНЫХ среди них. Третий возврат — является ли значение массивом. // // Элементы проглатываются в выбрасываемый RawMessage: материализация маршрута в // дерево значений стоила бы того же, от чего отказался разбор тела. Проверка // пустоты идёт по литералу элемента и обхода не добавляет — он уже здесь был // ради счёта. func arrayShape(raw json.RawMessage) (total, contentful int, ok bool) { if literalKind(raw) != kindArray { return 0, 0, false } dec := json.NewDecoder(bytes.NewReader(raw)) if _, err := dec.Token(); err != nil { // открывающая скобка return 0, 0, false } // Буфер объявлен НАД циклом: RawMessage.UnmarshalJSON делает // `append((*m)[0:0], data...)`, то есть переиспользует ёмкость. Объявление // внутри цикла обнуляло бы срез каждый виток и давало аллокацию на элемент — // маршрут в 593 точки стоил бы 593 аллокаций на каждую проверку покрытия, // притом что комментарий выше обещает обратное. var elem json.RawMessage for dec.More() { if err := dec.Decode(&elem); err != nil { return 0, 0, false } total++ if !isEmpty(elem) { contentful++ } } return total, contentful, true } // 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) }