полнота точки — множество ключей, победитель — функция множества точек

- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- Less стал тотальным, isEmpty не материализует значение, имя метрики в
  координате столкновения обрезается, отпечаток витрины включает units и sealed
- на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
av
2026-08-01 21:05:43 +03:00
parent 349a227ab1
commit 7a7594e3e7
22 changed files with 1818 additions and 176 deletions
+242 -29
View File
@@ -107,34 +107,194 @@ func Equal(a, b []byte) bool {
return bytes.Equal(fa, fb)
}
// Completeness — мера полноты точки: сколько значащих полей она несёт.
// Fullness — отношение полноты двух точек: несёт ли одна всё, что несёт
// другая, и сверх того.
//
// Нужна правилу разрешения столкновений: по одним координатам приезжают точки
// с разным НАБОРОМ полей при одинаковом значении (0.66% координат), и правило
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не
// несёт.
// Полнота — частичный порядок, а не число. Счётчик значащих полей сравним
// всегда и потому отвечает там, где ответа нет: точка с пятью полями без
// содержания «полнее» настоящего измерения с двумя. Измерено (находка 49):
// настоящих столкновений 0.65% координат, из них 981 различаются набором
// полей — это и есть область правила, — а несравнимых наборов ноль.
//
// Поля с пустым значением не считаются: точка с `context: null` не полнее
// точки без `context`. Поле source в счёт не идёт — оно нестабильно и
// переписывается задним числом, так что его наличие ничего не говорит о
// полноте измерения.
func Completeness(raw []byte) int {
// Нумерация с единицы: нулевое значение не означает ничего. Незаполненное поле
// или ранний возврат не должны выглядеть как «множества равны» — это
// сегодняшний исход по умолчанию, и отказ маскировался бы под успех.
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 0
return f
}
n := 0
for k, v := range obj {
if k == "source" {
continue
}
if isEmpty(v) {
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
}
n++
if !Equal(va, vb) {
return false
}
}
return n
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 задаёт детерминированный порядок на точках равной полноты.
@@ -144,28 +304,81 @@ func Completeness(raw []byte) int {
// ВНУТРИ себя, где время приёма общее. Порядок канонических форм зависит
// только от самих значений, поэтому свёртка по журналу даёт то же состояние,
// что приём в реальном времени.
//
// Порядок ТОТАЛЬНЫЙ, включая вход, который не канонизируется: иначе на паре
// из двух неразбираемых значений Less(a,b) и Less(b,a) оба давали бы false,
// победителем оказывался бы просто второй аргумент, и пересборка журнала
// разошлась бы с живым приёмом. Сегодня такой вход недостижим — hae отсеивает
// точки, не разбирающиеся в объект, — но станет достижимым со вторым
// источником точек (импорт родного экспорта Apple).
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
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 {
t := bytes.TrimSpace(v)
switch {
case len(t) == 0, bytes.Equal(t, []byte("null")):
return true
case bytes.Equal(t, []byte(`""`)):
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: строковый литерал