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

188 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`; эта задача его не трогает и не ухудшает.