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

- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- 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: строковый литерал
+190 -16
View File
@@ -1,7 +1,9 @@
package canon_test
import (
"bytes"
"encoding/json"
"fmt"
"testing"
"git.vakhrushev.me/av/healthlog/internal/canon"
@@ -158,44 +160,216 @@ func TestFormНеСдвигаетБольшиеЦелые(t *testing.T) {
}
}
func TestCompleteness(t *testing.T) {
func TestRelateFullness(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want int
a string
b string
want canon.Fullness
}{
{"пустой объект", `{}`, 0},
{"только qty", `{"qty":1}`, 1},
{"qty и границы", `{"qty":1,"start":"a","end":"b"}`, 3},
{"одинаковые", `{"qty":1}`, `{"qty":1}`, canon.FullnessEqual},
{
"надмножество", `{"qty":1,"context":"x"}`, `{"qty":1}`,
canon.FullnessSuperset,
},
{
"подмножество", `{"qty":1}`, `{"qty":1,"context":"x"}`,
canon.FullnessSubset,
},
{
// Тот случай, ради которого правило и переписано: числом ключей он
// не выражается вовсе.
name: "несравнимые", a: `{"qty":1}`, b: `{"context":"x"}`,
want: canon.FullnessIncomparable,
},
{
// source нестабилен и переписывается задним числом, поэтому его
// наличие ничего не говорит о полноте измерения.
name: "source не считается",
raw: `{"qty":1,"source":"Device A"}`,
want: 1,
a: `{"qty":1,"source":"Device A"}`, b: `{"qty":1}`,
want: canon.FullnessEqual,
},
{
// Точка с пустым полем не полнее точки без него — иначе бедная
// доставка выиграла бы столкновение одним лишь наличием ключа.
name: "пустые значения не считаются",
raw: `{"qty":1,"context":null,"note":""}`,
want: 1,
// Регрессия задачи: пять полей без содержания против настоящего
// измерения. Счётчик давал 5 против 2 и стирал измерение.
name: "поля без содержания не добавляют полноты",
a: `{"qty":0,"a":0,"b":0,"c":{},"d":[]}`,
b: `{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`,
want: canon.FullnessSubset,
},
{"не объект", `[1,2,3]`, 0},
{
// Второй разряд: содержательные ключи те же, но нулевые поля
// теряться не должны.
// Второй разряд работает именно при РАВНОМ содержании: qty один и
// тот же, лишние ключи пусты — терять их незачем.
name: "при равном содержании выигрывает набор со всеми ключами",
a: `{"date":"d","qty":10,"Min":0,"Max":0}`,
b: `{"date":"d","qty":10}`,
want: canon.FullnessSuperset,
},
{
// А вот при РАЗНОМ содержании лишние пустые ключи полноты не дают:
// точки несут разные измерения, и надмножество имён об этом ничего
// не говорит. Иначе точка, где ни одно значение не измерение,
// вытесняла бы настоящее измерение.
name: "разное содержание не перебивается пустыми ключами",
a: `{"date":"d","qty":10,"Min":0,"Max":0}`,
b: `{"date":"d","qty":12}`,
want: canon.FullnessEqual,
},
{
// Тот же дефект в самой опасной форме: падинг из null. Множества
// содержательных ключей равны, поэтому раньше решал второй разряд —
// и настоящее измерение проигрывало точке, не несущей измерения.
name: "падинг из null не полнее измерения",
a: `{"date":"d","qty":0.001,"p1":null,"p2":null,"p3":null}`,
b: `{"date":"d","qty":72.5}`,
want: canon.FullnessEqual,
},
{
// Надмножество содержательных ключей тоже обязано СОГЛАСОВЫВАТЬСЯ
// по общим значениям, иначе `qty:false` побеждало бы `qty:72.5`
// одним лишь наличием соседних полей.
name: "надмножество с чужим значением полноты не даёт",
a: `{"date":"d","qty":false,"Min":false,"Max":false}`,
b: `{"date":"d","qty":72.5}`,
want: canon.FullnessEqual,
},
{
// Настоящее надмножество: общее значение совпадает, поля добавлены.
name: "надмножество с тем же значением полнее",
a: `{"date":"d","qty":72.5,"Min":70,"Max":75}`,
b: `{"date":"d","qty":72.5}`,
want: canon.FullnessSuperset,
},
{
// false — одно из двух значений булева поля, а не отсутствие
// сведений: `isIndoor: false` это тренировка на улице.
name: "false содержателен",
a: `{"qty":1,"isIndoor":false}`, b: `{"qty":1}`,
want: canon.FullnessSuperset,
},
{
// Пустота считается по разобранному значению: будь она побайтовой,
// эти поля прошли бы как содержательные и набор стал бы несравнимым.
name: "запись нуля и пустоты роли не играет",
a: `{"qty":1,"a":0.0,"b":-0,"c":0e0,"d":{ },"e":[ ],"f":""}`,
b: `{"qty":1,"context":"x"}`,
want: canon.FullnessSubset,
},
{
// Ключ без содержания всё же лучше его отсутствия — но только когда
// содержательные множества уже сравнялись.
name: "лишний пустой ключ решает вторым разрядом",
a: `{"qty":1,"Min":0}`, b: `{"qty":1}`,
want: canon.FullnessSuperset,
},
{
// Граница пустоты проведена по содержанию, а не по «похоже на
// пустое»: пробел, строка "0" и контейнер с элементом — содержание.
name: "похожее на пустоту содержательно",
a: `{"qty":1,"a":" ","b":"0","c":[null],"d":{"x":null}}`,
b: `{"qty":1}`,
want: canon.FullnessSuperset,
},
{"не объект против точки", `[1,2,3]`, `{"qty":1}`, canon.FullnessSubset},
{"оба не объекты", `[1,2,3]`, `"строка"`, canon.FullnessEqual},
{"невалидный JSON", `{"qty":`, `{"qty":1}`, canon.FullnessSubset},
{"пустой вход", ``, ``, canon.FullnessEqual},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := canon.Completeness([]byte(c.raw)); got != c.want {
t.Errorf("полнота %s = %d, ожидалось %d", c.raw, got, c.want)
got := canon.RelateFullness([]byte(c.a), []byte(c.b))
if got != c.want {
t.Errorf("полнота %s против %s = %s, ожидалось %s",
c.a, c.b, got, c.want)
}
// Отношение обязано быть симметричным: свёртка по журналу не знает,
// какая из точек «первая».
want := mirror(c.want)
if got := canon.RelateFullness([]byte(c.b), []byte(c.a)); got != want {
t.Errorf("обратный порядок = %s, ожидалось %s", got, want)
}
})
}
}
func mirror(f canon.Fullness) canon.Fullness {
switch f {
case canon.FullnessSuperset:
return canon.FullnessSubset
case canon.FullnessSubset:
return canon.FullnessSuperset
default:
return f
}
}
// Полнота смотрит на наличие содержания, а не на форму записи: значения,
// различающиеся дребезгом последнего разряда, обязаны давать равные множества.
func TestRelateFullnessУстойчивКФормеЗаписи(t *testing.T) {
t.Parallel()
a := []byte(`{"qty":0.09523182962471353,"date":"d"}`)
b := []byte(` { "date" : "d" , "qty" : 0.09523182962471352 } `)
if got := canon.RelateFullness(a, b); got != canon.FullnessEqual {
t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessEqual)
}
if !canon.Equal(a, b) {
t.Error("канонические формы разошлись — тест проверяет не то")
}
}
// Нулевое значение типа не должно совпадать ни с одним исходом: забытое поле
// или ранний возврат не выглядят как «множества равны».
func TestFullnessНулевоеЗначениеНеИсход(t *testing.T) {
t.Parallel()
var zero canon.Fullness
for _, f := range []canon.Fullness{
canon.FullnessEqual, canon.FullnessSuperset,
canon.FullnessSubset, canon.FullnessIncomparable,
} {
if f == zero {
t.Errorf("исход %s совпал с нулевым значением", f)
}
if f.String() == zero.String() {
t.Errorf("имя исхода %s совпало с именем нулевого значения", f)
}
}
}
// Точка HAE несёт до нескольких десятков полей, но правило обязано быть
// тотальным и на неправдоподобном входе: паника здесь остановила бы разбор
// доставки целиком.
func TestRelateFullnessНеПаникуетНаБольшомВходе(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
buf.WriteByte('{')
for i := range 5000 {
if i > 0 {
buf.WriteByte(',')
}
fmt.Fprintf(&buf, `"k%d":%d`, i, i)
}
buf.WriteByte('}')
if got := canon.RelateFullness(buf.Bytes(), []byte(`{"k1":1}`)); got != canon.FullnessSuperset {
t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessSuperset)
}
if got := canon.RelateFullness(nil, nil); got != canon.FullnessEqual {
t.Errorf("полнота на nil = %s, ожидалось %s", got, canon.FullnessEqual)
}
}
// Тай-брейк обязан зависеть только от значений: свёртка по журналу должна
// давать то же состояние, что приём в реальном времени, а внутри одной
// доставки время приёма у столкнувшихся точек общее.