- отношение победы было нетранзитивным: полнота (частичный порядок) плюс тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна и та же доставка меняла содержимое объекта при каждой пересборке - надмножество побеждает только при совпадении значений общих содержательных ключей: иначе точка без единого измерения вытесняла измерение - Less стал тотальным, isEmpty не материализует значение, имя метрики в координате столкновения обрезается, отпечаток витрины включает units и sealed - на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
480 lines
22 KiB
Go
480 lines
22 KiB
Go
// 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)
|
||
}
|