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

16 KiB
Raw Blame History

Context

Правило слияния точек живёт в двух местах: мера полноты — в internal/canon (Completeness(raw) int), выбор победителя — в internal/store/bucket.go (resolve). Полнота сегодня — счётчик ключей, чьё значение не null и не пустая строка; source из счёта исключён.

Счётчик — не та операция. Полнота точки это частичный порядок (одна точка несёт всё, что несёт другая, и сверх того), а число даёт полный порядок, то есть отвечает и там, где ответа нет. На живом архиве замер (находка 49) показал, где именно счётчик работает верно случайно: 981 столкновение со сравнимыми наборами, 1 916 с равными и ноль с несравнимыми.

Ограничение, определяющее объём: витрина не мигрирует. Правило обязано менять исход только там, где сегодняшний неверен, и это проверяемо — весь живой архив прогоняется через свёртку, а состояние сравнивается с прежним по отпечатку содержимого, а не по числу объектов.

Goals / Non-Goals

Goals:

  • Полнота — сравнение множеств ключей с непустым значением, побеждает строгое надмножество.
  • Несравнимые множества наблюдаемы: счётчик, координаты и WARN.
  • Состояние витрины на живом архиве не меняется — проверено отпечатком.

Non-Goals:

  • Объединение полей двух точек при несравнимых множествах. Ноль случаев на 99 доставках; вместо реализации — счётчик, который скажет, если событие наступит.
  • Тай-брейк при равной полноте. Остаётся лексикографическим порядком канонических форм. Правильный тай-брейк зависит от рода метрики, а род измеряется сверкой слоёв — задача rod-agregacii-i-katalog. Выбор сейчас был бы угадыванием того, что скоро станет известно точно.
  • Изменение координатного ключа, хеша содержимого и формата хранения.

Decisions

Полнота выражается отношением, а не числом

canon.Completeness(raw) int заменяется на отношение пары:

type Fullness int
const (
    FullnessEqual Fullness = iota + 1 // множества совпадают
    FullnessSuperset                  // a несёт всё, что b, и сверх того
    FullnessSubset
    FullnessIncomparable              // у каждой есть ключ, которого нет у другой
)
func RelateFullness(a, b []byte) Fullness
func (f Fullness) String() string

Три решения внутри одного, каждое со своей ценой:

  • Отношение целиком, а не множество наружу. Альтернатива — отдавать map[string]bool и сравнивать в store. Отвергнута: правило «что считать полнотой» размажется по двум пакетам, а store начнёт знать, какие ключи HAE значащие. Пара предикатов (Covers/Overlaps, как у netip.Prefix) отвергнута по той же причине: вывод «несравнимы» пришлось бы собирать на стороне вызывающего.
  • Не Compare и не Less/More в именах. В словаре stdlib Compare — тотальный порядок с результатом -1/0/+1, и slices.SortFunc предписывает несравнимым элементам ответ 0. Здесь исходов четыре, поэтому имя RelateFullness, а константы названы по субъекту (Superset/Subset), а не по направлению: рядом в resolve стоит canon.Less о порядке канонических форм, и два «Less» о разном в одном выражении читались бы неверно молча.
  • Нумерация с единицы. Нулевое значение не означает ничего: незаполненное поле или ранний return не должны выглядеть как «множества равны» — это сегодняшнее поведение, и отказ маскировался бы под успех ровно в той проверке, которая требует совпадения состояния.

String() заводится сразу: исход правила виден только в отказах тестов, а «получено 3, ожидалось 1» читать нечем.

Пустое значение — то, что не несёт содержания, и false в него не входит

isEmpty расширяется с null/"" до null, "", числового нуля, {}, [].

Обоснование прежнее и то же, каким уже оправдан null: точка с context: null не полнее точки без context. Пустой массив и пустой объект ровно так же не несут содержания. Набор совпадает с omitempty из encoding/json минус false плюс пустой объект — у понятия есть готовая граница, и отклонения от неё названы вслух.

false пустотой не считается. Для булева поля это одно из двух значений: isIndoor: false — тренировка на улице, а не отсутствие сведений. Замер по живому потоку: единственное булево поле всего архива — workout.isIndoor, и false там встречается наравне с true. Цена ошибки асимметрична: добавить false в пустоту потом — одно слово, убрать после мерджа — правка спеки плюс пересборка витрины, потому что правило применяется реплеем ко всей истории.

Нуль остаётся в пустоте, и цена этого названа. Ноль бывает настоящим измерением: у walking_asymmetry_percentage нулевое значение — обычный результат, а не отсутствие данных. Значит при столкновении нулевого значения с ненулевым по одним координатам выиграет ненулевое. Это приемлемо ровно потому, что речь о столкновении — двух разных содержимых на одних координатах, где одно из значений заведомо неверно, — а не о выборе, хранить ли ноль. Одиночная нулевая точка хранится как пришла: правило полноты к ней не применяется вовсе. Без этой границы правило не чинит собственный мотивирующий пример: набор {"qty":0,"a":0,"b":0,"c":{},"d":[]} остался бы несравнимым с {"date":…,"qty":123.4}, ушёл бы на тай-брейк и по порядку канонических форм снова стёр бы измерение.

Пустота считается по разобранному значению, а не по байтам. Сегодняшний isEmpty сравнивает байты, и расширенный тем же способом он не увидел бы 0.0, 0e0, -0, { }. Разбор в пакете уже есть (decode с UseNumber), и он же используется канонизацией — то есть пустота и хеш считают числа одним кодом, а не двумя похожими.

Второй разряд сравнения — множество всех ключей

Расширение пустоты снимает защиту там, где её сегодня даёт счётчик: у точки {date, qty:10, Min:0, Max:0} и точки {date, qty:12} множества содержательных ключей равны, и Min с Max исчезли бы из витрины по жребию тай-брейка.

Поэтому сравнение двухразрядное: сперва множества ключей с непустым значением, при равенстве — множества всех ключей (кроме source). Оба разряда — одна и та же операция над разными множествами, нового понятия не появляется. Отложенного тай-брейка это не касается: он остаётся ровно там, где стоял, — после обоих разрядов.

Наблюдение о несравнимых наборах живёт там же, где остальные

MergeStats получает счётчик Incomparable и список координат IncomparableAt (той же формы и с тем же потолком, что Collisions). Логирует не store, а единственный логирующий чекпоинт свёртки fold.logResult.

Альтернатива — писать WARN прямо в store рядом с местом решения. Отвергнута: у store нет логгера, и заводить его значило бы получить второй логирующий чекпоинт на доменной границе (docs/conventions.md).

Несравнимый набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, координаты попадают в оба списка. Иначе сумма overwrites за период перестала бы быть сравнимой с той, что была до change, а именно она служит индикатором работы правила.

Ветвь WARN ставится выше ветви перезаписей — событие реже и информативнее, — но признак идёт атрибутом всегда, независимо от выбранной ветви: switch по сообщениям эксклюзивен, и класть наблюдение только в текст значило бы терять его при совпадении с другим сигналом.

Значений точек ни счётчик, ни лог не несут — только координаты объекта.

Отпечаток состояния вместо числа объектов

Число объектов и число точек к правилу разрешения столкновений нечувствительны: mergePoints держит одну точку на координату, а resolve выбирает, какая это будет точка, а не сколько их. Значит «объектов 1737, точек 444 256» совпадёт и при заведомо сломанном правиле.

Поэтому состояние сравнивается отпечатком содержимого: SHA-256 по metric | layer | hour_utc | content_hash | points всех объектов в детерминированном порядке. Отпечаток печатается прогоном живого архива (task verify:archive) и сравнивается с прежним вручную — хранить эталон в репозитории нельзя, он производен от данных, которых нет ни на одной другой машине.

Замер на ревью предложения: правило (в редакции без второго разряда) даёт отпечаток, идентичный прежнему, 0 несравнимых наборов и 0 изменённых исходов на всех 99 доставках. То есть на живых данных изменение — строгий no-op, и вся его работа относится к будущему.

Risks / Trade-offs

  • Расширение пустоты меняет исход там, где сегодня побеждал ноль. → Измеряется отпечатком содержимого витрины до и после. На ревью предложения расхождений не обнаружено; после реализации проверяется ещё раз.
  • 0 как пустота может показаться интерпретацией значения. → Она не выходит за границу разрешения столкновений: хранение остаётся дословным, точки не переписываются и не отбрасываются, правило работает только при выборе одного из двух содержимых на одних координатах.
  • Правило склеивает частичный порядок с полным (тай-брейк), и транзитивность такой склейки не гарантирована. → Детерминизм свёртки от неё не зависит: порядок применения задан журналом (доставки по received_at, точки — в порядке тела), и он одинаков у живого приёма и у пересборки. Коммутативность пары проверяется тестом.
  • Счётчик несравнимых наборов может не сработать никогда. → Это и есть ожидаемый исход (0 из 2 897 при обоих определениях пустоты). Цена — одно поле и одна ветвь лога; цена альтернативы — реализация объединения полей, которую нечем проверить на реальных данных.
  • Тай-брейк остаётся системно смещённым (в 96% случаев берёт меньшее значение). → Известно, измерено, отложено осознанно до задачи rod-agregacii-i-katalog; эта задача его не трогает и не ухудшает.