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

- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- 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
+13
View File
@@ -72,6 +72,7 @@ type Stats struct {
Buckets int
Unchanged int
Overwrites int
Incomparable int
SealedHits int
UnitsConflicts int
SkippedNoTime int
@@ -80,6 +81,7 @@ type Stats struct {
Layer string
LayerMismatch bool
Collisions []store.Collision
IncomparableAt []store.Collision
}
// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
@@ -138,9 +140,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
stats.Buckets = merge.Buckets
stats.Unchanged = merge.Unchanged
stats.Overwrites = merge.Overwrites
stats.Incomparable = merge.Incomparable
stats.SealedHits = merge.SealedHits
stats.UnitsConflicts = merge.UnitsConflicts
stats.Collisions = merge.Collisions
stats.IncomparableAt = merge.IncomparableAt
if err := s.finish(ctx, deliveryID, store.ParseDone, int64(stats.Points), stats.Layer); err != nil {
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
@@ -173,6 +177,7 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"buckets", st.Buckets,
"unchanged", st.Unchanged,
"overwrites", st.Overwrites,
"incomparable", st.Incomparable,
"units_conflicts", st.UnitsConflicts,
"sealed_hits", st.SealedHits,
"skipped", skipped,
@@ -185,6 +190,9 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.Collisions) > 0 {
attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
}
if len(st.IncomparableAt) > 0 {
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
}
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
// штатная работа. Без этого условия смена формата метки выглядела бы как
@@ -192,6 +200,11 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
allSkipped := st.Points == 0 && skipped > 0
switch {
case st.Incomparable > 0:
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
// этом идут атрибутами всегда, так что выбор ветви ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...)
case st.Overwrites > 0:
// Единственное наблюдение, по которому проверяется правило слияния.
// В INFO оно тонуло: поток идёт раз в пять минут.
+69
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
@@ -113,3 +114,71 @@ func TestFoldВсеТочкиОтброшеныДаётWarn(t *testing.T) {
t.Errorf("доставка без единой сохранённой точки записана не как WARN:\n%s", buf.String())
}
}
// Несравнимые наборы полей — то самое событие, ради наблюдения за которым
// объединение полей не реализовано вовсе. Если оно когда-нибудь наступит, его
// обязано быть видно, а не додумано задним числом.
func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
ctx := context.Background()
const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` +
`{"date":"2025-06-05 10:00:00 +0300",%s}` +
`]}]}}`
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1`)))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("первая свёртка: %v", err)
}
buf.Reset()
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"mealTime":"До еды"`)))
stats, err := f.Fold(ctx, "d2")
if err != nil {
t.Fatalf("вторая свёртка: %v", err)
}
if stats.Incomparable != 1 {
t.Fatalf("несравнимых %d, ожидался 1: тест проверяет не то", stats.Incomparable)
}
var rec map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil {
t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String())
}
if rec["level"] != "WARN" {
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
}
if rec["msg"] != "delivery folded, incomparable point fields" {
t.Errorf("сообщение %v не называет событие", rec["msg"])
}
if rec["incomparable"] != float64(1) {
t.Errorf("счётчик несравнимых %v, ожидался 1", rec["incomparable"])
}
// Координаты объекта — не значения: метрика, слой и час.
at, _ := rec["incomparable_at"].(string)
if !strings.Contains(at, "blood_glucose/minute@") {
t.Errorf("координаты объекта в записи не те: %q", at)
}
// И ни одного значения точки: данные о здоровье чувствительнее токенов.
for _, secret := range []string{"5.1", "До еды"} {
if strings.Contains(buf.String(), secret) {
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String())
}
}
}
+33 -3
View File
@@ -40,7 +40,7 @@ func TestReplayЖивогоАрхива(t *testing.T) {
f, arch, st := newFold(t)
ctx := context.Background()
var folded, failed int
var folded, failed, incomparable int
for _, path := range bodies {
body, err := os.ReadFile(path)
if err != nil {
@@ -55,14 +55,21 @@ func TestReplayЖивогоАрхива(t *testing.T) {
id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
deliver(t, arch, st, id, "", "auto", gunzip(t, body))
if _, err := f.Fold(ctx, id); err != nil {
res, err := f.Fold(ctx, id)
if err != nil {
failed++
continue
}
folded++
incomparable += res.Incomparable
}
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d", len(bodies), folded, failed)
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
// полей: их не было ни разу на всём корпусе. Число печатается, а не
// проверяется: появление такого набора — событие для разбора, а не отказ
// сходимости.
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d, несравнимых наборов %d",
len(bodies), folded, failed, incomparable)
if folded == 0 {
t.Fatal("ни одна доставка не свернулась")
@@ -70,10 +77,19 @@ func TestReplayЖивогоАрхива(t *testing.T) {
// Повторный прогон того же журнала не меняет состояния: свёртка
// детерминирована, и пересборка даёт то же, что живой приём.
//
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
// заведомо сломанном правиле.
before, err := st.CountBuckets(ctx)
if err != nil {
t.Fatalf("счёт объектов: %v", err)
}
fingerprintBefore, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
var refolded int
for _, path := range bodies {
if _, err := f.Fold(ctx, strings.TrimSuffix(filepath.Base(path), ".json.gz")); err != nil {
@@ -92,6 +108,20 @@ func TestReplayЖивогоАрхива(t *testing.T) {
if before != after {
t.Errorf("повторный прогон журнала изменил число объектов: %d → %d", before, after)
}
fingerprintAfter, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
if fingerprintBefore != fingerprintAfter {
t.Errorf("повторный прогон журнала изменил содержимое объектов:\n %s\n %s",
fingerprintBefore, fingerprintAfter)
}
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
// живёт — он производен от архива, которого нет ни на одной другой машине.
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
t.Logf("объектов %d, отпечаток содержимого %s", after, fingerprintAfter)
// Главное измеренное число: ключ по метке дал бы 170 координат сна, ключ по
// интервалу — 174 (docs/local-research.md, находка 47). Если координата