полнота точки — множество ключей, победитель — функция множества точек
- отношение победы было нетранзитивным: полнота (частичный порядок) плюс тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна и та же доставка меняла содержимое объекта при каждой пересборке - надмножество побеждает только при совпадении значений общих содержательных ключей: иначе точка без единого измерения вытесняла измерение - Less стал тотальным, isEmpty не материализует значение, имя метрики в координате столкновения обрезается, отпечаток витрины включает units и sealed - на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-01
|
||||
@@ -0,0 +1,187 @@
|
||||
## 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`; эта задача его не трогает и не ухудшает.
|
||||
@@ -0,0 +1,62 @@
|
||||
## Why
|
||||
|
||||
Полнота точки при разрешении столкновения считается **числом** ключей с
|
||||
непустым значением. Число сравнимо всегда, а сравнивать надо содержание: точка
|
||||
с пятью полями, где значения `0`, `{}` и `[]`, выигрывает у настоящего
|
||||
измерения с двумя полями и стирает его безвозвратно.
|
||||
|
||||
```
|
||||
точка {"qty":0,"a":0,"b":0,"c":{},"d":[]} полнота 5 ← выигрывает сегодня
|
||||
точка {"date":"…","qty":123.4} полнота 2 ← стирается
|
||||
```
|
||||
|
||||
Замер по всем 99 доставкам (docs/local-research.md, находка 49) даёт основание
|
||||
для правильной формы правила: настоящих столкновений 2 897 из 444 256
|
||||
координат (0.65%), из них наборы полей сравнимы в 981 случае, равны в 1 916 и
|
||||
**несравнимы ни разу**. То есть правило сводится к «побеждает надмножество», а
|
||||
самая дорогая часть — объединение полей из двух точек — на живом потоке не
|
||||
нужна вовсе.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Полнота точки перестаёт быть числом и становится **множеством ключей с
|
||||
непустым значением**. Побеждает строгое надмножество; равные множества
|
||||
уходят на прежний тай-брейк.
|
||||
- Пустым значением считается не только `null` и `""`, но и `0`, `{}`, `[]`:
|
||||
поле, не несущее содержания, не делает точку полнее отсутствующего поля.
|
||||
Это и есть та часть, из-за которой сегодняшний счётчик даёт неверный исход
|
||||
на приведённой выше паре. `false` пустотой НЕ считается — для булева поля
|
||||
это одно из двух значений, а не отсутствие сведений.
|
||||
- Надмножество побеждает только при совпадении значений общих содержательных
|
||||
ключей: иначе точка без единого измерения вытесняла бы измерение.
|
||||
- Несравнимые множества (ни одно не является надмножеством другого) не
|
||||
объединяются, а **считаются**: счётчик в итоге слияния плюс `WARN` с
|
||||
координатами объекта (метрика, слой, час), без значений. Победителя в этом
|
||||
случае выбирает тот же тай-брейк, что и при равной полноте.
|
||||
- **Не меняется** тай-брейк при равной полноте: он зависит от рода метрики,
|
||||
а род измеряется сверкой слоёв (задача `rod-agregacii-i-katalog`). Выбирать
|
||||
его сейчас — угадывать то, что скоро станет известно точно.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `storage`: требование «Разрешение столкновений по полноте» переформулируется
|
||||
с числа значащих полей на множество ключей с непустым значением; добавляется
|
||||
наблюдаемость несравнимых множеств.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/canon` — `Completeness` (число) заменяется сравнением множеств
|
||||
ключей; расширяется понятие пустого значения.
|
||||
- `internal/store/bucket.go` — `resolve` и `MergeStats` (новый счётчик и
|
||||
координаты несравнимых случаев).
|
||||
- `internal/fold` — новая ветвь `WARN` в единственном логирующем чекпоинте.
|
||||
- `openspec/specs/storage/spec.md` — дельта-спека.
|
||||
- Витрина не мигрирует: правило меняет исход только там, где сегодня он
|
||||
неверен. Проверяется прогоном `task verify:archive` на живом архиве —
|
||||
состояние обязано совпасть с прежним.
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Разрешение столкновений по полноте
|
||||
|
||||
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
|
||||
**более полную** точку — ту, чьё множество ключей с непустым значением является
|
||||
**строгим надмножеством** множества другой, — а не последнюю пришедшую. Иначе
|
||||
бедная доставка стирает у богатой поля, которых сама не несёт.
|
||||
|
||||
Полнота SHALL сравниваться множествами, а не их размером. Число сравнимо
|
||||
всегда, и потому счётчик даёт ответ там, где ответа нет: точка с пятью полями,
|
||||
не несущими содержания, побеждала бы настоящее измерение с двумя полями и
|
||||
стирала бы его безвозвратно.
|
||||
|
||||
Измерено (находка 49): настоящих столкновений 2 897 из 444 256 координат
|
||||
(0.65%); из них 981 различаются набором полей — это и есть область правила
|
||||
полноты, — 1 916 несут равные наборы и разные значения, где исход решает
|
||||
тай-брейк, а несравнимых наборов ноль.
|
||||
|
||||
Пустым значением MUST считаться `null`, пустая строка, число, равное нулю (в
|
||||
любой записи), пустой объект и пустой массив: поле без содержания не делает
|
||||
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
|
||||
разобранному значению, а не по байтам: `0`, `0.0`, `0e0`, `-0` и `{ }` — та же
|
||||
пустота, что `0` и `{}`.
|
||||
|
||||
`false` пустотой MUST NOT считаться: для булева поля это одно из двух значений,
|
||||
а не отсутствие содержания (`isIndoor: false` — тренировка на улице).
|
||||
|
||||
Поле `source` в множество не входит — оно нестабильно и переписывается задним
|
||||
числом (находка 36), так что о полноте измерения ничего не говорит.
|
||||
|
||||
Содержимое, которое не разбирается как JSON-объект, SHALL нести **пустое
|
||||
множество** ключей: так оно проигрывает любой точке с содержанием и не
|
||||
загрязняет наблюдение о несравнимых наборах.
|
||||
|
||||
Надмножество побеждает только тогда, когда оно **несёт то же содержание**:
|
||||
значения ключей, содержательных у обеих точек, MUST совпадать (с точностью до
|
||||
канонической формы). Иначе точки несут разные измерения, и надмножество имён
|
||||
о полноте не говорит ничего — такая пара MUST разрешаться как равнополная.
|
||||
Без этого условия точка `{date, qty:0.001, p1:null, p2:null}` вытесняла бы
|
||||
`{date, qty:72.5}`, то есть точка, где ни одно значение не измерение,
|
||||
стирала бы измерение — ровно то, ради отрицания чего правило переписано.
|
||||
|
||||
Если множества ключей с непустым значением **равны и значения совпали**,
|
||||
система SHALL сравнить множества **всех** ключей, кроме `source`, и оставить
|
||||
строгое надмножество. Без этого разряда правило теряло бы поля там, где
|
||||
заведено их беречь: точка `{date, qty:10, Min:0, Max:0}` и точка
|
||||
`{date, qty:10}` несут одинаковое содержание, и `Min` с `Max` исчезли бы из
|
||||
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
|
||||
лишние ключи там заведомо пусты, объединять в них нечего.
|
||||
|
||||
Если равны и эти множества, а значения различаются, исход MUST быть
|
||||
детерминированным и не зависеть от порядка, в котором доставки дошли до
|
||||
хранилища: свёртка по журналу обязана давать то же состояние, что приём в
|
||||
реальном времени.
|
||||
|
||||
Победитель MUST быть функцией **множества** точек координаты, а не порядка их
|
||||
поступления. Попарная свёртка этого не даёт: полнота — частичный порядок,
|
||||
тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы
|
||||
(A превосходит B по полноте, B бьёт C тай-брейком, C бьёт A тай-брейком).
|
||||
При таком цикле повторная свёртка одной и той же доставки меняет содержимое
|
||||
объекта, и витрина перестаёт быть функцией журнала. Поэтому система SHALL
|
||||
отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
|
||||
победителя среди оставшихся по тотальному порядку — обе операции зависят
|
||||
только от состава множества.
|
||||
|
||||
Сравнение по `received_at` для этого не годится: у сохранённой точки нет
|
||||
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
|
||||
не с чем. Детерминизм обеспечивается свойством самих значений (например,
|
||||
порядком канонических форм), а не порядком событий.
|
||||
|
||||
Если множества **несравнимы** — каждое несёт ключ с непустым значением,
|
||||
которого нет у другого, — система SHALL выбрать победителя тем же
|
||||
детерминированным правилом, что и при равных множествах, и MUST оставить
|
||||
наблюдение: счётчик в итоге разбора доставки, координаты объекта и запись
|
||||
`WARN` без значений точки. Несравнимый набор — частный случай столкновения:
|
||||
счётчик перезаписей растёт вместе с ним, а координаты попадают в оба списка.
|
||||
|
||||
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимых
|
||||
наборов не встретилось ни разу (0 из 2 897 столкновений, при обоих определениях
|
||||
пустоты), и реализация правила, которое никогда не срабатывает, стоила бы
|
||||
больше, чем счётчик, который скажет, если оно наступит.
|
||||
|
||||
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
|
||||
выбирает, а не конструирует. Каноническая форма существует только в момент
|
||||
сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое
|
||||
число вместо присланного.
|
||||
|
||||
#### Scenario: Бедная точка не стирает поля богатой
|
||||
|
||||
- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
|
||||
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
|
||||
- **THEN** сохранённая точка остаётся с `context`
|
||||
|
||||
#### Scenario: Поля без содержания полноты не добавляют
|
||||
|
||||
- **WHEN** сохранена точка с пятью полями, значения которых `0`, `{}` и `[]`
|
||||
- **AND** по тем же координатам приезжает точка с `date` и ненулевым `qty`
|
||||
- **THEN** остаётся точка с `date` и `qty`
|
||||
|
||||
#### Scenario: При равном содержании поля не теряются
|
||||
|
||||
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
|
||||
- **AND** по тем же координатам приезжает точка `{date, qty}` с другим `qty`
|
||||
- **THEN** остаётся точка с `Min` и `Max`
|
||||
|
||||
#### Scenario: Одинаково полные точки с разными значениями
|
||||
|
||||
- **WHEN** по одним координатам приходят две точки с одинаковыми множествами
|
||||
ключей и разными значениями
|
||||
- **THEN** исход определяется детерминированно и не зависит от порядка
|
||||
воспроизведения доставок
|
||||
|
||||
#### Scenario: Несравнимые множества считаются, а не сливаются
|
||||
|
||||
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
|
||||
ключ с непустым значением, которого нет у другой
|
||||
- **THEN** остаётся ровно одна точка, выбранная детерминированно
|
||||
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
|
||||
- **AND** система пишет `WARN` с координатами объекта и без значений точки
|
||||
|
||||
#### Scenario: Содержимое, которое не разбирается в объект
|
||||
|
||||
- **WHEN** по координатам сталкиваются точка с непустыми полями и содержимое,
|
||||
не разбирающееся как JSON-объект
|
||||
- **THEN** остаётся точка с полями
|
||||
- **AND** счётчик несравнимых наборов не растёт
|
||||
|
||||
Столкновением SHALL считаться расхождение **канонических форм**, а не байтов.
|
||||
Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно
|
||||
приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним
|
||||
разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на
|
||||
каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы.
|
||||
|
||||
#### Scenario: Столкновение с различием содержимого оставляет след
|
||||
|
||||
- **WHEN** по одним координатам сохраняется точка, каноническая форма которой
|
||||
отличается от уже сохранённой
|
||||
- **THEN** система пишет запись уровня `WARN` без значений точки
|
||||
- **AND** запись несёт координаты объекта: метрику, слой и час
|
||||
- **AND** увеличивает счётчик перезаписей в итоге разбора доставки
|
||||
|
||||
#### Scenario: Дребезг сериализации столкновением не считается
|
||||
|
||||
- **WHEN** та же точка приезжает с другим порядком ключей или отличаясь
|
||||
последним разрядом числа
|
||||
- **THEN** счётчик перезаписей не растёт и `WARN` не пишется
|
||||
|
||||
Без этого следа допущение «меньше полей не значит новее» не получит ни одного
|
||||
наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с
|
||||
экспортом Apple — то есть месяцами.
|
||||
@@ -0,0 +1,51 @@
|
||||
## 1. Полнота как множество (internal/canon)
|
||||
|
||||
- [x] 1.1 `isEmpty`: `null`, `""`, число, равное нулю (`0`, `0.0`, `0e0`, `-0`), `{}`, `[]`; `false` — НЕ пустота. Считается по литералу, БЕЗ материализации значения: разбор целиком разворачивал `heartbeatSeries` в `[]any` на каждое сравнение (замер ревью: 410 мс на точку 16.5 МБ)
|
||||
- [x] 1.2 Тип `Fullness` (`Equal`/`Superset`/`Subset`/`Incomparable`, нумерация с единицы, `String()`) и `RelateFullness(a, b []byte) Fullness`; `source` в множества не входит; содержимое, не разбирающееся в объект, даёт пустое множество
|
||||
- [x] 1.3 Второй разряд: при равенстве множеств содержательных ключей **и совпадении их значений** сравниваются множества всех ключей; несравнимость на втором разряде исходом не является
|
||||
- [x] 1.6 (по ревью) Надмножество побеждает только при совпадении значений общих содержательных ключей — иначе точка без единого измерения вытесняла измерение (`{qty:0.001,p1:null,p2:null}` против `{qty:72.5}`)
|
||||
- [x] 1.7 (по ревью) `Less` тотален: при отказе канонизации сравниваются исходные байты. Прежде на паре из двух неразбираемых значений `Less` в обе стороны давал `false`, и победителем оказывался просто второй аргумент
|
||||
- [x] 1.8 (по ревью) Разбор точки вынесен в `Fields`/`Analyze` и переиспользуется: сравнений квадратично по числу кандидатов
|
||||
- [x] 1.4 `Completeness` удалить — второй меры полноты в проекте не остаётся
|
||||
- [x] 1.5 Тесты `canon` по приёмочным критериям ниже
|
||||
|
||||
## 2. Разрешение столкновения (internal/store)
|
||||
|
||||
- [x] 2.1 `resolve` выбирает победителя из МНОЖЕСТВА кандидатов координаты: отбрасываются превзойдённые по полноте, среди оставшихся — минимум по каноническому порядку
|
||||
- [x] 2.4 (по ревью, **критическое**) Попарная свёртка заменена на выбор из множества. Полнота — частичный порядок, тай-брейк — тотальный; вместе они давали нетранзитивное отношение победы, из-за которого одна и та же доставка, свёрнутая дважды, давала два состояния витрины поочерёдно
|
||||
- [x] 2.5 (по ревью) Имя метрики в координате столкновения обрезается: оно приходит из тела дословно, предел приёма 64 МиБ, и без обрезки одна доставка порождала WARN-строку в десятки мегабайт
|
||||
- [x] 2.2 `MergeStats`: счётчик `Incomparable` и координаты `IncomparableAt` (потолок тот же, что у `Collisions`); несравнимость — частный случай столкновения, `Overwrites` растёт вместе с ней
|
||||
- [x] 2.3 Тесты `store`: регрессия «пять полей без содержания не стирают измерение», «надмножество побеждает», «при равном содержании поля не теряются», «несравнимые наборы считаются и дают координаты», «повторная свёртка не меняет состояние», «исход не зависит от перестановки (6 перестановок × вместе/порознь)», «падинг не вытесняет измерение»
|
||||
|
||||
## 3. Наблюдаемость (internal/fold)
|
||||
|
||||
- [x] 3.1 Пробросить счётчик и координаты в `Stats`; признак идёт атрибутом всегда, независимо от выбранной ветви
|
||||
- [x] 3.2 Ветвь `WARN` в `logResult` выше ветви перезаписей, без значений точек
|
||||
- [x] 3.3 Тест на уровень, сообщение и набор атрибутов
|
||||
|
||||
## 4. Проверка на живых данных
|
||||
|
||||
- [x] 4.1 `task gate` зелёный
|
||||
- [x] 4.2 `task verify:archive` проходит и печатает отпечаток содержимого витрины
|
||||
- [x] 4.5 (по ревью) Отпечаток включает `units` и `sealed`, а поля переменной длины идут с длиной впереди: разделитель `|` допустим внутри имени метрики, и две разошедшиеся витрины давали один отпечаток
|
||||
- [x] 4.3 Отпечаток совпадает с прежним: `05db720966e47bd5670bfe6b022ddf4a2194dc946aedf93db926b3e944f226d6` (объектов 1737) — **перепроверен после всех правок ревью**: содержимое витрины не изменилось ни на одной координате. Сверка сделана временным возвратом прежнего формата отпечатка, иначе сравнивать было бы нечего. Отпечаток в новом формате — `ba40c1d9bc8e6acfe953b5a207c2f8278d8bfbac116e2f1bee42484854c57fd6`
|
||||
- [x] 4.4 Счётчик несравнимых наборов на всём архиве равен нулю (проверяет посылку «объединять поля не нужно»)
|
||||
|
||||
## 5. Документация
|
||||
|
||||
- [x] 5.1 `docs/architecture.md`: правило полноты — множество ключей, а не их число; заодно строка «последние пришедшие данные всегда актуализируют картину», противоречащая правилу слияния
|
||||
- [x] 5.2 `docs/local-research.md`, находка 49: замер под расширенным определением пустоты (несравнимых по-прежнему ноль) и наблюдение про нулевые значения как настоящие измерения
|
||||
- [x] 5.3 Комментарии у изменённых функций отражают основание, а не только механику
|
||||
|
||||
## Приёмочные критерии (из ревью предложения, профиль design)
|
||||
|
||||
- [x] П1 `RelateFullness` тотальна и не паникует: `nil`, пустой срез, усечённый JSON, не-объект (`[]`, `"s"`, `123`, `null`), объект с тысячами ключей
|
||||
- [x] П2 исход не зависит от порядка — проверен не только на паре, но и на всех шести перестановках тройки, и при разбиении на разные доставки. На паре критерий выполнялся и у дефектной реализации: сломано было именно на трёх
|
||||
- [x] П3 `resolve(a,a)` возвращает `a` дословно; повторная доставка часа не растит счётчики и не пишет `WARN`
|
||||
- [x] П4 победитель — одна из входных точек дословно: ни объединения полей, ни возврата канонической формы
|
||||
- [x] П5 отношение согласовано: `Equal` устойчива к порядку ключей и пробелам, `Superset(a,b) ⟺ Subset(b,a)`, `Incomparable` симметрична
|
||||
- [x] П6 совпадение канонических форм влечёт `Equal`; пустота и хеш считают числа одним кодом
|
||||
- [x] П7 таблица записей пустоты: `0`/`0.0`/`0e0`/`-0`/`{}`/`{ }`/`[]`/`""`/`null` пусты, `false`/`" "`/`"0"`/`[null]`/`{"a":null}` — нет
|
||||
- [x] П8 заданный исход на вырожденном входе: невалидный JSON, не-объект, `{}` против непустой точки
|
||||
- [x] П9 счётчик `Incomparable` растёт и после того, как список координат упёрся в потолок
|
||||
- [x] П10 атрибуты `WARN` — только счётчики и координаты объекта: ни значений, ни имён полей точки
|
||||
@@ -65,32 +65,129 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
|
||||
### Requirement: Разрешение столкновений по полноте
|
||||
|
||||
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
|
||||
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю
|
||||
пришедшую. Иначе бедная доставка стирает у богатой поля, которых сама не несёт:
|
||||
0.66% координат различаются именно набором полей при одинаковом значении.
|
||||
**более полную** точку — ту, чьё множество ключей с непустым значением является
|
||||
**строгим надмножеством** множества другой, — а не последнюю пришедшую. Иначе
|
||||
бедная доставка стирает у богатой поля, которых сама не несёт.
|
||||
|
||||
Если полнота равна, а значения различаются, исход MUST быть детерминированным
|
||||
и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по
|
||||
журналу обязана давать то же состояние, что приём в реальном времени.
|
||||
Полнота SHALL сравниваться множествами, а не их размером. Число сравнимо
|
||||
всегда, и потому счётчик даёт ответ там, где ответа нет: точка с пятью полями,
|
||||
не несущими содержания, побеждала бы настоящее измерение с двумя полями и
|
||||
стирала бы его безвозвратно.
|
||||
|
||||
Измерено (находка 49): настоящих столкновений 2 897 из 444 256 координат
|
||||
(0.65%); из них 981 различаются набором полей — это и есть область правила
|
||||
полноты, — 1 916 несут равные наборы и разные значения, где исход решает
|
||||
тай-брейк, а несравнимых наборов ноль.
|
||||
|
||||
Пустым значением MUST считаться `null`, пустая строка, число, равное нулю (в
|
||||
любой записи), пустой объект и пустой массив: поле без содержания не делает
|
||||
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
|
||||
разобранному значению, а не по байтам: `0`, `0.0`, `0e0`, `-0` и `{ }` — та же
|
||||
пустота, что `0` и `{}`.
|
||||
|
||||
`false` пустотой MUST NOT считаться: для булева поля это одно из двух значений,
|
||||
а не отсутствие содержания (`isIndoor: false` — тренировка на улице).
|
||||
|
||||
Поле `source` в множество не входит — оно нестабильно и переписывается задним
|
||||
числом (находка 36), так что о полноте измерения ничего не говорит.
|
||||
|
||||
Содержимое, которое не разбирается как JSON-объект, SHALL нести **пустое
|
||||
множество** ключей: так оно проигрывает любой точке с содержанием и не
|
||||
загрязняет наблюдение о несравнимых наборах.
|
||||
|
||||
Надмножество побеждает только тогда, когда оно **несёт то же содержание**:
|
||||
значения ключей, содержательных у обеих точек, MUST совпадать (с точностью до
|
||||
канонической формы). Иначе точки несут разные измерения, и надмножество имён
|
||||
о полноте не говорит ничего — такая пара MUST разрешаться как равнополная.
|
||||
Без этого условия точка `{date, qty:0.001, p1:null, p2:null}` вытесняла бы
|
||||
`{date, qty:72.5}`, то есть точка, где ни одно значение не измерение,
|
||||
стирала бы измерение — ровно то, ради отрицания чего правило переписано.
|
||||
|
||||
Если множества ключей с непустым значением **равны и значения совпали**,
|
||||
система SHALL сравнить множества **всех** ключей, кроме `source`, и оставить
|
||||
строгое надмножество. Без этого разряда правило теряло бы поля там, где
|
||||
заведено их беречь: точка `{date, qty:10, Min:0, Max:0}` и точка
|
||||
`{date, qty:10}` несут одинаковое содержание, и `Min` с `Max` исчезли бы из
|
||||
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
|
||||
лишние ключи там заведомо пусты, объединять в них нечего.
|
||||
|
||||
Если равны и эти множества, а значения различаются, исход MUST быть
|
||||
детерминированным и не зависеть от порядка, в котором доставки дошли до
|
||||
хранилища: свёртка по журналу обязана давать то же состояние, что приём в
|
||||
реальном времени.
|
||||
|
||||
Победитель MUST быть функцией **множества** точек координаты, а не порядка их
|
||||
поступления. Попарная свёртка этого не даёт: полнота — частичный порядок,
|
||||
тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы
|
||||
(A превосходит B по полноте, B бьёт C тай-брейком, C бьёт A тай-брейком).
|
||||
При таком цикле повторная свёртка одной и той же доставки меняет содержимое
|
||||
объекта, и витрина перестаёт быть функцией журнала. Поэтому система SHALL
|
||||
отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
|
||||
победителя среди оставшихся по тотальному порядку — обе операции зависят
|
||||
только от состава множества.
|
||||
|
||||
Сравнение по `received_at` для этого не годится: у сохранённой точки нет
|
||||
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
|
||||
не с чем. Детерминизм обеспечивается свойством самих значений (например,
|
||||
порядком канонических форм), а не порядком событий.
|
||||
|
||||
Если множества **несравнимы** — каждое несёт ключ с непустым значением,
|
||||
которого нет у другого, — система SHALL выбрать победителя тем же
|
||||
детерминированным правилом, что и при равных множествах, и MUST оставить
|
||||
наблюдение: счётчик в итоге разбора доставки, координаты объекта и запись
|
||||
`WARN` без значений точки. Несравнимый набор — частный случай столкновения:
|
||||
счётчик перезаписей растёт вместе с ним, а координаты попадают в оба списка.
|
||||
|
||||
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимых
|
||||
наборов не встретилось ни разу (0 из 2 897 столкновений, при обоих определениях
|
||||
пустоты), и реализация правила, которое никогда не срабатывает, стоила бы
|
||||
больше, чем счётчик, который скажет, если оно наступит.
|
||||
|
||||
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
|
||||
выбирает, а не конструирует. Каноническая форма существует только в момент
|
||||
сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое
|
||||
число вместо присланного.
|
||||
|
||||
#### Scenario: Бедная точка не стирает поля богатой
|
||||
|
||||
- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
|
||||
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
|
||||
- **THEN** сохранённая точка остаётся с `context`
|
||||
|
||||
#### Scenario: Поля без содержания полноты не добавляют
|
||||
|
||||
- **WHEN** сохранена точка с пятью полями, значения которых `0`, `{}` и `[]`
|
||||
- **AND** по тем же координатам приезжает точка с `date` и ненулевым `qty`
|
||||
- **THEN** остаётся точка с `date` и `qty`
|
||||
|
||||
#### Scenario: При равном содержании поля не теряются
|
||||
|
||||
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
|
||||
- **AND** по тем же координатам приезжает точка `{date, qty}` с другим `qty`
|
||||
- **THEN** остаётся точка с `Min` и `Max`
|
||||
|
||||
#### Scenario: Одинаково полные точки с разными значениями
|
||||
|
||||
- **WHEN** по одним координатам приходят две одинаково полные точки с разными
|
||||
значениями
|
||||
- **WHEN** по одним координатам приходят две точки с одинаковыми множествами
|
||||
ключей и разными значениями
|
||||
- **THEN** исход определяется детерминированно и не зависит от порядка
|
||||
воспроизведения доставок
|
||||
|
||||
#### Scenario: Несравнимые множества считаются, а не сливаются
|
||||
|
||||
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
|
||||
ключ с непустым значением, которого нет у другой
|
||||
- **THEN** остаётся ровно одна точка, выбранная детерминированно
|
||||
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
|
||||
- **AND** система пишет `WARN` с координатами объекта и без значений точки
|
||||
|
||||
#### Scenario: Содержимое, которое не разбирается в объект
|
||||
|
||||
- **WHEN** по координатам сталкиваются точка с непустыми полями и содержимое,
|
||||
не разбирающееся как JSON-объект
|
||||
- **THEN** остаётся точка с полями
|
||||
- **AND** счётчик несравнимых наборов не растёт
|
||||
|
||||
Столкновением SHALL считаться расхождение **канонических форм**, а не байтов.
|
||||
Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно
|
||||
приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним
|
||||
|
||||
Reference in New Issue
Block a user