Files
healthlog/internal/canon/canon.go
T
av 7a7594e3e7 полнота точки — множество ключей, победитель — функция множества точек
- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- Less стал тотальным, isEmpty не материализует значение, имя метрики в
  координате столкновения обрезается, отпечаток витрины включает units и sealed
- на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
2026-08-01 21:05:43 +03:00

480 lines
22 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)
}
// 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)
}