- отношение победы было нетранзитивным: полнота (частичный порядок) плюс тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна и та же доставка меняла содержимое объекта при каждой пересборке - надмножество побеждает только при совпадении значений общих содержательных ключей: иначе точка без единого измерения вытесняла измерение - Less стал тотальным, isEmpty не материализует значение, имя метрики в координате столкновения обрезается, отпечаток витрины включает units и sealed - на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
188 lines
16 KiB
Markdown
188 lines
16 KiB
Markdown
## 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`; эта задача его не трогает и не ухудшает.
|