## 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` заменяется на отношение пары: ```go 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`; эта задача его не трогает и не ухудшает.