полнота точки — множество ключей, победитель — функция множества точек

- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- Less стал тотальным, isEmpty не материализует значение, имя метрики в
  координате столкновения обрезается, отпечаток витрины включает units и sealed
- на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
av
2026-08-01 21:05:43 +03:00
parent 349a227ab1
commit 7a7594e3e7
22 changed files with 1818 additions and 176 deletions
+3
View File
@@ -18,3 +18,6 @@
# IDE
/.idea/
/.vscode/
# worktree агентов ревью — не часть репозитория
.claude/worktrees/
+54 -2
View File
@@ -29,6 +29,8 @@ healthlog принимает выгрузки Apple Health из приложен
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
Полнота — **множество** ключей с непустым значением, а не их число (см.
«Разрешение столкновений»).
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации».
@@ -193,8 +195,10 @@ HRV); у накопительных — только `date`. Поэтому то
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
единицы записей, и месячное окно там почти ничего не стоит.
Последние пришедшие данные всегда актуализируют картину — правило слияния
одинаково для всех проходов, порядок прихода значения не имеет.
Правило слияния одинаково для всех проходов, и порядок прихода значения не
имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда
не было верным: при столкновении выигрывает более полная точка, а не последняя
пришедшая (см. «Разрешение столкновений»).
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12).
@@ -535,6 +539,54 @@ hour метки выровнены на час heart_rate 00:00:00
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
эксплуатации, а не из предположений.
#### Разрешение столкновений
По одним координатам приезжают разные содержимые: 2 897 случаев из 444 256
координат, 0.65% (находка 49). Выигрывает **более полная** точка, и полнота —
это сравнение **множеств** ключей с непустым значением, а не их числа.
Число сравнимо всегда и потому отвечает там, где ответа нет: точка
`{"qty":0,"a":0,"b":0,"c":{},"d":[]}` несла «пять значащих полей» против двух у
настоящего измерения и стирала его безвозвратно. Множества дают три исхода
вместо одного — надмножество, равенство, несравнимость, — и только первый
означает «полнее».
Пусто — `null`, пустая строка, нулевое число, пустой объект и пустой массив;
`false` содержателен (`isIndoor: false` — тренировка на улице). Считается по
разобранному значению, а не по байтам: иначе `0.0` и `{ }` прошли бы как
содержание. `source` не участвует — он нестабилен.
Надмножество побеждает только тогда, когда **несёт то же содержание**: значения
общих содержательных ключей должны совпасть. Иначе точки несут разные
измерения, и надмножество имён о полноте не говорит ничего — пара уходит в
тай-брейк. Без этого условия `{date, qty:0.001, p1:null, p2:null}` вытесняло бы
`{date, qty:72.5}`, то есть точка без единого измерения стирала бы измерение.
Разрядов сравнения два: сперва ключи с содержанием, при их равенстве (и
совпадении значений) — все ключи. Второй разряд бережёт поля, которые не несут
содержания, но и теряться не должны: `{date, qty:10, Min:0, Max:0}` не
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
не является: лишние ключи там заведомо пусты, объединять в них нечего.
**Победитель — функция множества точек, а не порядка их поступления.** Попарная
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
вместе они образуют нетранзитивное отношение победы, то есть цикл. При цикле
повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина
перестаёт быть свёрткой журнала. Поэтому кандидаты координаты собираются
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
минимум по каноническому порядку. Обе операции зависят только от состава
множества.
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
событие наступит, оно будет видно, а не додумано заранее.
**Тай-брейк при равной полноте не выбран.** Сегодня это порядок канонических
форм, и он измеримо смещён: в 96% случаев берёт меньшее значение. Правильный
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
значит он и станет известен точно, вместо того чтобы быть угаданным.
### Категориальные значения
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
+2 -1
View File
@@ -20,7 +20,6 @@
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Полнота точки — по множеству ключей, а не по их числу](pravilo-sliyaniya-tochek.md) — точка с пятью пустыми полями бьёт настоящее измерение: полнота считается счётчиком, а не множеством
- [Непокрытые секции доставки видны в статусе разбора](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind числится parsed, а ретеншен снесёт его как разобранное — и в экспорте Apple его нет
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
@@ -35,6 +34,8 @@
- [[idea] Что считать сутками при смене часового пояса](sutki-i-chasovoj-poyas.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [[idea] Пересекающиеся источники одной метрики](peresekayushchiesya-istochniki.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [Умолчания конфига указывают на прежнюю раскладку](umolchaniya-konfiga-data.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- [Счётчики слияния переживают ротацию логов](nablyudenie-za-sliyaniem-v-bd.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
## низкий
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
@@ -0,0 +1,58 @@
# Цена слияния на широкой доставке
**Приоритет:** средний
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
проход и независимая реализация — независимо друг от друга).
## Оракул: измерено
Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ
координате:
```
транзакция держалась 5.149 с, аллоцировано 6108 МБ, пик кучи 189 МБ
```
При `busy_timeout` 5000 мс и `_txlock=immediate` параллельная доставка
получает `SQLITE_BUSY`, `inTx` повторяет её до пяти раз (каждый повтор —
полный пересчёт слияния), и на исчерпании попыток доставка уходит в
`parse_status=failed`. Тело при этом в архиве остаётся, но пересборки, которая
его подберёт, ещё нет.
Отдельный вклад, измеренный бенчмарком на часовом объекте нижнего слоя
(3 600 точек):
```
HashAll на объект 9.46 мс 6.7 МБ аллокаций
Form одной точки 2.2 мкс
```
`hashPoints` пересчитывает каноническую форму ВСЕХ точек часа на каждое
слияние, включая неизменившиеся. Утверждение `docs/architecture.md` о
дешевизне глубокого прохода («4400 сравнений хеша, почти все сойдутся»)
стоимости самих сравнений не учитывает: чтобы сравнить хеш, его надо посчитать.
Часть цены задачей `pravilo-sliyaniya-tochek` уже снята: `isEmpty` больше не
материализует значение (было 410 мс на точку 16.5 МБ), а разбор точки
переиспользуется через `canon.Fields`. Осталось структурное.
## Что делать
1. Держать хеш каждой точки в `payload` рядом с координатами (`storedPoint`
уже есть) и пересчитывать форму только для новых и выигравших столкновение
— стоимость станет пропорциональна дельте, а не объёму часа.
2. Считать `canon.Form` точки один раз на столкновение и переиспользовать в
`Equal`, `Relate` и порядке вместо трёх независимых разборов.
3. Проверять `ctx.Err()` в цикле по точкам: сейчас дедлайн свёртки неисполним,
прервать слияние нечем.
4. Оценить потолок числа кандидатов на одной координате: выбор победителя
квадратичен по ним, и 119 точек на одну метку — вход, который приём
принимает.
## Связано
- [otvet-i-svyortka](otvet-i-svyortka.md) — воркер убирает влияние на ответ
приёму, но не на блокировку записи; задачи независимы.
- [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбирает доставки, ушедшие в
`failed` по этой причине.
@@ -0,0 +1,42 @@
# Счётчики слияния переживают ротацию логов
**Приоритет:** средний
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
негативного пространства, подтверждено эксплуатационным).
## Что не так
Решение не реализовывать объединение полей при несравнимых наборах стоит на
одном аргументе: «вместо реализации — счётчик, который скажет, если событие
наступит». Сказать он может только в одну сторону — строкой `WARN` в stdout
контейнера.
`docker-compose.yml` держит `json-file` с `max-size: 10m, max-file: 3`. При
~288 доставках в сутки это порядка кварталов, а ожидаемая частота события —
«ни разу за 99 доставок». Штатный сценарий: событие происходит ночью, логи
никто не грепал в этот месяц, строка уходит в ротацию, и в системе не остаётся
ни одного свидетельства. Ни колонки в `delivery`, ни `/stats`, ни файла.
То есть обещание «событие будет видно, а не додумано» на практике не
выполняется.
## Что делать
Положить `overwrites` и `incomparable` в строку `delivery` — миграция плюс
запись в `FinishParse`, которая эту строку всё равно трогает. Тогда «было ли
когда-нибудь несравнимо» это один `SELECT`, живущий столько же, сколько
витрина.
Заодно стоит решить смежное, найденное тем же проходом: сегодня в логе
неразличимы «правило полноты сработало» и «всё ушло в тай-брейк». Два разных
состояния мира дают одинаковую картину `overwrites=N, incomparable=0`, то есть
отказ правила выглядит как здоровая работа. Отдельный счётчик исходов
`Superset`/`Subset` рядом с `Overwrites` это закрывает.
## Связано
- [stats-nablyudaemost](stats-nablyudaemost.md) — то же наблюдение нужно и там.
- [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md) — придёт к вопросу о
тай-брейке и потребует эксплуатационной истории, которой без этой задачи не
будет: мерить придётся снова по архиву, а он к тому моменту подрезан.
-72
View File
@@ -1,72 +0,0 @@
# Полнота точки — по множеству ключей, а не по их числу
**Приоритет:** высокий
Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
`deep`, находка №7 триажа, severity major). **Решение принято замером**
(находка 49) — ниже задача на остаток.
## Что не так сегодня
Полнота — это **счётчик** ключей, чьё значение не `null`, не `""` и не пусто.
Поэтому `{}`, `[]`, `0` и `false` считаются содержательными:
```
точка {"qty":0,"a":0,"b":0,"c":{},"d":[]} полнота 5
точка {"date":"…","qty":123.4} полнота 2
```
Вторая точка — настоящее измерение — проигрывает первой и стирается
безвозвратно. Восстановить можно только из сырого архива, пока он жив.
## Что решено и на каком основании
Замер по всем 99 доставкам (находка 49): настоящих столкновений 2 897 из
444 256 координат (0.65%), и среди них **ноль несравнимых наборов полей**.
Отсюда:
- **Полнота — сравнение множеств ключей, побеждает надмножество.** Ровно 981
случай из замера, и там правило уже работает верно — но работает случайно,
через счётчик, который на другом входе даст обратное.
- **Объединение полей при несравнимых наборах не нужно.** Самая дорогая часть
обсуждавшегося варианта (1) на живом потоке не срабатывает ни разу. Вместо
реализации — счётчик: несравнимый набор считается и логируется `WARN`. Если
событие когда-нибудь наступит, оно будет видно, а не додумано заранее.
- **Тай-брейк при равной полноте в эту задачу не входит.** Обоснование ниже.
## Что делать
1. `resolve` в `internal/store/bucket.go`: полнота — множество ключей с
непустым значением; побеждает строгое надмножество.
2. Несравнимые множества — счётчик в `MergeStats` плюс `WARN` с координатами
объекта (без значений). Точку выбирает тот же тай-брейк, что и при равной
полноте.
3. Дельта-спека `openspec/specs/storage` → «Разрешение столкновений по
полноте»: переформулировать с числа ключей на множество.
4. Тесты: «бедная точка с пятью пустыми полями не стирает измерение»
(регрессия на приведённой выше паре), «надмножество побеждает»,
«несравнимые наборы считаются».
5. `task verify:archive` обязан дать то же состояние — правило меняет исход
только там, где сегодня он неверен.
## Что отложено и почему
**Тай-брейк при равной полноте.** Сегодня это лексикографический порядок
канонических форм. Измерено (находка 49): в 1 847 случаях из 1 912 он выбирает
**меньшее** значение — 96%. Четыре из шести затронутых метрик накопительные,
там это систематический недосчёт; но крупнейшая группа, `heart_rate` (985
случаев), мгновенная, и там «большее» не правильнее.
То есть правильный тай-брейк зависит от **рода метрики**, а род по замыслу
проекта измеряется сверкой слоёв между собой. Выбирать его сейчас — угадывать
ровно то, что станет известно точно.
Зависимость: [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md). Тай-брейк
доделывается **после** неё, отдельной задачей.
## Что стоит без решения
Ничего. Правило работает и **наблюдаемо**: столкновение считается расхождением
канонических форм (а не байтов), даёт `WARN` с координатами объекта и счётчик
`overwrites`.
+4 -1
View File
@@ -21,7 +21,10 @@ HAE. Он не сэмплы, а посекундная развёртка (на
Измерено (находка 49), что сегодняшний лексикографический порядок берёт меньшее
значение в 96% случаев — для накопительных это недосчёт, для мгновенных
безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк
доделывается по нему — см. [pravilo-sliyaniya-tochek](pravilo-sliyaniya-tochek.md).
доделывается по нему. Остальное правило слияния уже сделано — структурная часть
закрыта задачей `pravilo-sliyaniya-tochek` (архив change
`2026-08-01-polnota-tochki-mnozhestvom-klyuchey`), здесь остался только выбор
победителя при РАВНОЙ полноте.
Связано: `docs/architecture.md` → «Слои гранулярности», план шаг 4.
+8
View File
@@ -107,3 +107,11 @@
источником истины служат живые данные.
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
витрину.
- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать
обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает
коммутативность и молчит про ассоциативность, а сломаться правило может
именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их
попарная свёртка дала нетранзитивное отношение победы, из-за которого одна
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
выражается — отсюда проза.
+18
View File
@@ -1613,6 +1613,24 @@ apple_stand_time 14
Значит выбирать тай-брейк до каталога рода агрегации — значит угадывать ровно
то, что через задачу станет известно точно.
### Перемер под расширенным определением пустоты
Разложение выше считало пустыми `null` и пустую строку. Правило слияния с тех
пор считает пустыми ещё нулевое число, пустой объект и пустой массив — то есть
множества ключей стали меньше, а несравнимость от этого может только
появиться, но не исчезнуть. Перемер на тех же 99 доставках: **несравнимых
по-прежнему ноль**, а состояние витрины совпало с прежним побайтово (отпечаток
содержимого 1737 объектов). То есть смена правила — строгий no-op на живых
данных, и вся её работа относится к будущему.
Отдельно проверено про булевы поля: единственное на весь архив — `isIndoor` у
тренировок, и `false` там встречается наравне с `true`. Поэтому `false`
пустотой не считается: это одно из двух значений, а не отсутствие сведений.
Ноль же пустотой считается, хотя бывает и настоящим измерением (у
`walking_asymmetry_percentage` нулевое значение — обычный результат): цена
названа вслух и ограничена столкновением, где одно из двух содержимых на одних
координатах заведомо неверно.
## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
+242 -29
View File
@@ -107,34 +107,194 @@ func Equal(a, b []byte) bool {
return bytes.Equal(fa, fb)
}
// Completeness — мера полноты точки: сколько значащих полей она несёт.
// Fullness — отношение полноты двух точек: несёт ли одна всё, что несёт
// другая, и сверх того.
//
// Нужна правилу разрешения столкновений: по одним координатам приезжают точки
// с разным НАБОРОМ полей при одинаковом значении (0.66% координат), и правило
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не
// несёт.
// Полнота — частичный порядок, а не число. Счётчик значащих полей сравним
// всегда и потому отвечает там, где ответа нет: точка с пятью полями без
// содержания «полнее» настоящего измерения с двумя. Измерено (находка 49):
// настоящих столкновений 0.65% координат, из них 981 различаются набором
// полей — это и есть область правила, — а несравнимых наборов ноль.
//
// Поля с пустым значением не считаются: точка с `context: null` не полнее
// точки без `context`. Поле source в счёт не идёт — оно нестабильно и
// переписывается задним числом, так что его наличие ничего не говорит о
// полноте измерения.
func Completeness(raw []byte) int {
// Нумерация с единицы: нулевое значение не означает ничего. Незаполненное поле
// или ранний возврат не должны выглядеть как «множества равны» — это
// сегодняшний исход по умолчанию, и отказ маскировался бы под успех.
type Fullness int
const (
// FullnessEqual — множества ключей совпадают.
FullnessEqual Fullness = iota + 1
// FullnessSuperset — a несёт всё, что b, и сверх того.
FullnessSuperset
// FullnessSubset — b несёт всё, что a, и сверх того.
FullnessSubset
// FullnessIncomparable — у каждой точки есть ключ, которого нет у другой.
FullnessIncomparable
)
func (f Fullness) String() string {
switch f {
case FullnessEqual:
return "equal"
case FullnessSuperset:
return "superset"
case FullnessSubset:
return "subset"
case FullnessIncomparable:
return "incomparable"
default:
return "unknown(" + strconv.Itoa(int(f)) + ")"
}
}
// Fields — ключи точки, разобранные ОДИН раз: множество всех и значения тех,
// что несут содержание.
//
// Разбор вынесен в отдельный тип не ради красоты. Победитель столкновения
// выбирается из множества кандидатов, а не парой (см. store), и сравнений там
// квадратично по числу кандидатов. Разбирай их RelateFullness каждый раз —
// доставка с сотней точек на одной координате разобрала бы каждую сотню раз.
type Fields struct {
// full — ключ с непустым значением → его исходные байты. Значения нужны
// целиком: полнота требует не только наличия ключа, но и совпадения
// содержания (см. Relate).
full map[string]json.RawMessage
// all — все ключи, включая те, чьё значение пусто.
all map[string]struct{}
}
// Analyze разбирает точку на множества ключей.
//
// Поле source не входит ни в одно из множеств: оно нестабильно и
// переписывается задним числом (находка 36), так что о полноте измерения
// ничего не говорит.
//
// Содержимое, которое не разбирается как JSON-объект, даёт пустые множества:
// так оно проигрывает любой точке с содержанием и не загрязняет наблюдение о
// несравнимых наборах.
func Analyze(raw []byte) Fields {
f := Fields{
full: make(map[string]json.RawMessage),
all: make(map[string]struct{}),
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return 0
return f
}
n := 0
for k, v := range obj {
if k == "source" {
continue
}
if isEmpty(v) {
f.all[k] = struct{}{}
if !isEmpty(v) {
f.full[k] = v
}
}
return f
}
// RelateFullness сравнивает полноту двух точек.
//
// Обёртка над Analyze и Relate для одиночного сравнения; в слиянии разбор
// переиспользуется.
func RelateFullness(a, b []byte) Fullness {
return Analyze(a).Relate(Analyze(b))
}
// Relate сравнивает полноту: несёт ли одна точка всё, что несёт другая, и
// сверх того.
//
// Разрядов сравнения два. Сперва ключи с НЕПУСТЫМ значением: точка с
// `context: null` не полнее точки без `context`. Если они совпали — все ключи:
// иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с
// `Max` исчезли бы из витрины по жребию тай-брейка.
//
// «Несёт всё, что несёт другая» — про СОДЕРЖАНИЕ, а не про имена ключей. Если
// значения общих содержательных ключей разошлись, точки несут разные
// измерения, и надмножество имён о полноте не говорит ничего: иначе
// `{qty: 0.001, p1: null, p2: null}` оказывалось бы полнее `{qty: 72.5}` и
// стирало настоящее измерение — ровно то, ради отрицания чего правило и
// переписано. Такая пара уходит в тай-брейк как равнополная.
//
// Несравнимость — исход ТОЛЬКО первого разряда: у каждой точки есть
// содержательный ключ, которого нет у другой, и объединять там было бы что.
// Во втором разряде лишние ключи заведомо пусты, объединять в них нечего, и
// счётчик, ради которого объединение полей отложено, не должен считать это
// событие (иначе замер «0 из 2 897», снятый по содержательным ключам, теряет
// сопоставимость с тем, что считает код).
//
// Отношение — частичный порядок: антисимметрично по построению и транзитивно
// (если A ⊇ B и B ⊇ C, то ключи C ⊆ ключей B ⊆ ключей A, а согласие значений
// переносится через B). На это опирается выбор победителя из множества.
func (f Fields) Relate(g Fields) Fullness {
rel := relateKeys(keysOf(f.full), keysOf(g.full))
if rel == FullnessIncomparable {
return rel
}
if !agreeOnShared(f.full, g.full) {
return FullnessEqual
}
if rel != FullnessEqual {
return rel
}
if rel2 := relateKeys(f.all, g.all); rel2 == FullnessSuperset || rel2 == FullnessSubset {
return rel2
}
return FullnessEqual
}
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих
// точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда
// расхождением не считаются.
func agreeOnShared(a, b map[string]json.RawMessage) bool {
for k, va := range a {
vb, ok := b[k]
if !ok {
continue
}
n++
if !Equal(va, vb) {
return false
}
}
return n
return true
}
func keysOf(m map[string]json.RawMessage) map[string]struct{} {
out := make(map[string]struct{}, len(m))
for k := range m {
out[k] = struct{}{}
}
return out
}
// relateKeys сравнивает два множества ключей по включению.
func relateKeys(a, b map[string]struct{}) Fullness {
aExtra := hasExtra(a, b)
bExtra := hasExtra(b, a)
switch {
case aExtra && bExtra:
return FullnessIncomparable
case aExtra:
return FullnessSuperset
case bExtra:
return FullnessSubset
default:
return FullnessEqual
}
}
// hasExtra говорит, есть ли в a ключ, которого нет в b.
func hasExtra(a, b map[string]struct{}) bool {
for k := range a {
if _, ok := b[k]; !ok {
return true
}
}
return false
}
// Less задаёт детерминированный порядок на точках равной полноты.
@@ -144,28 +304,81 @@ func Completeness(raw []byte) int {
// ВНУТРИ себя, где время приёма общее. Порядок канонических форм зависит
// только от самих значений, поэтому свёртка по журналу даёт то же состояние,
// что приём в реальном времени.
//
// Порядок ТОТАЛЬНЫЙ, включая вход, который не канонизируется: иначе на паре
// из двух неразбираемых значений Less(a,b) и Less(b,a) оба давали бы false,
// победителем оказывался бы просто второй аргумент, и пересборка журнала
// разошлась бы с живым приёмом. Сегодня такой вход недостижим — hae отсеивает
// точки, не разбирающиеся в объект, — но станет достижимым со вторым
// источником точек (импорт родного экспорта Apple).
func Less(a, b []byte) bool {
fa, err := Form(a)
if err != nil {
return false
}
fb, err := Form(b)
if err != nil {
return true
}
return bytes.Compare(fa, fb) < 0
return bytes.Compare(SortKey(a), SortKey(b)) < 0
}
// SortKey возвращает то, по чему точки упорядочиваются: каноническую форму,
// а для содержимого, которое не канонизируется, — исходные байты.
//
// Вынесено наружу, чтобы слияние считало форму один раз на точку, а не по разу
// на каждое сравнение.
func SortKey(raw []byte) []byte {
form, err := Form(raw)
if err != nil {
return raw
}
return form
}
// isEmpty говорит, несёт ли поле содержание.
//
// Пусто — `null`, пустая строка, число, равное нулю, пустой объект и пустой
// массив. Набор совпадает с `omitempty` из encoding/json минус `false` плюс
// пустой объект, и оба отклонения сознательны:
//
// - `false` пустотой НЕ считается: для булева поля это одно из двух значений,
// а не отсутствие сведений (`isIndoor: false` — тренировка на улице).
// - Ноль считается: точка, где все значения нулевые, не должна вытеснять
// настоящее измерение. Цена названа вслух — при столкновении нулевого
// значения с ненулевым по одним координатам выиграет ненулевое, хотя ноль
// бывает и настоящим измерением. Речь именно о столкновении, где одно из
// двух содержимых заведомо неверно; одиночная нулевая точка хранится как
// пришла.
//
// Значение НЕ материализуется: решение принимается по литералу. Разбор
// значения целиком стоил бы разворачивания heartbeatSeries в []any на каждое
// сравнение — ровно той формы, от которой разбор тела намеренно отказался
// (197 МиБ кучи против 54 МиБ на теле 42 МиБ). При этом `0.0`, `0e0`, `-0` и
// `{ }` обязаны считаться пустыми, поэтому по байтам сравнивать тоже нельзя:
// число проверяется strconv, скобки — на пробельное содержимое.
func isEmpty(v json.RawMessage) bool {
t := bytes.TrimSpace(v)
switch {
case len(t) == 0, bytes.Equal(t, []byte("null")):
return true
case bytes.Equal(t, []byte(`""`)):
lit := bytes.TrimSpace(v)
if len(lit) == 0 {
return true
}
switch lit[0] {
case 'n': // null
return bytes.Equal(lit, []byte("null"))
case '"':
return bytes.Equal(lit, []byte(`""`))
case '{':
return emptyBracketed(lit, '{', '}')
case '[':
return emptyBracketed(lit, '[', ']')
case 't', 'f':
// Булево — одно из двух значений, а не отсутствие сведений.
return false
default:
f, err := strconv.ParseFloat(string(lit), 64)
return err == nil && f == 0
}
}
// emptyBracketed говорит, что между скобками нет ничего, кроме пробелов.
func emptyBracketed(lit []byte, open, close byte) bool {
if len(lit) < 2 || lit[0] != open || lit[len(lit)-1] != close {
return false
}
return len(bytes.TrimSpace(lit[1:len(lit)-1])) == 0
}
// decode разбирает значение с числами в виде json.Number: строковый литерал
+190 -16
View File
@@ -1,7 +1,9 @@
package canon_test
import (
"bytes"
"encoding/json"
"fmt"
"testing"
"git.vakhrushev.me/av/healthlog/internal/canon"
@@ -158,44 +160,216 @@ func TestFormНеСдвигаетБольшиеЦелые(t *testing.T) {
}
}
func TestCompleteness(t *testing.T) {
func TestRelateFullness(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want int
a string
b string
want canon.Fullness
}{
{"пустой объект", `{}`, 0},
{"только qty", `{"qty":1}`, 1},
{"qty и границы", `{"qty":1,"start":"a","end":"b"}`, 3},
{"одинаковые", `{"qty":1}`, `{"qty":1}`, canon.FullnessEqual},
{
"надмножество", `{"qty":1,"context":"x"}`, `{"qty":1}`,
canon.FullnessSuperset,
},
{
"подмножество", `{"qty":1}`, `{"qty":1,"context":"x"}`,
canon.FullnessSubset,
},
{
// Тот случай, ради которого правило и переписано: числом ключей он
// не выражается вовсе.
name: "несравнимые", a: `{"qty":1}`, b: `{"context":"x"}`,
want: canon.FullnessIncomparable,
},
{
// source нестабилен и переписывается задним числом, поэтому его
// наличие ничего не говорит о полноте измерения.
name: "source не считается",
raw: `{"qty":1,"source":"Device A"}`,
want: 1,
a: `{"qty":1,"source":"Device A"}`, b: `{"qty":1}`,
want: canon.FullnessEqual,
},
{
// Точка с пустым полем не полнее точки без него — иначе бедная
// доставка выиграла бы столкновение одним лишь наличием ключа.
name: "пустые значения не считаются",
raw: `{"qty":1,"context":null,"note":""}`,
want: 1,
// Регрессия задачи: пять полей без содержания против настоящего
// измерения. Счётчик давал 5 против 2 и стирал измерение.
name: "поля без содержания не добавляют полноты",
a: `{"qty":0,"a":0,"b":0,"c":{},"d":[]}`,
b: `{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`,
want: canon.FullnessSubset,
},
{"не объект", `[1,2,3]`, 0},
{
// Второй разряд: содержательные ключи те же, но нулевые поля
// теряться не должны.
// Второй разряд работает именно при РАВНОМ содержании: qty один и
// тот же, лишние ключи пусты — терять их незачем.
name: "при равном содержании выигрывает набор со всеми ключами",
a: `{"date":"d","qty":10,"Min":0,"Max":0}`,
b: `{"date":"d","qty":10}`,
want: canon.FullnessSuperset,
},
{
// А вот при РАЗНОМ содержании лишние пустые ключи полноты не дают:
// точки несут разные измерения, и надмножество имён об этом ничего
// не говорит. Иначе точка, где ни одно значение не измерение,
// вытесняла бы настоящее измерение.
name: "разное содержание не перебивается пустыми ключами",
a: `{"date":"d","qty":10,"Min":0,"Max":0}`,
b: `{"date":"d","qty":12}`,
want: canon.FullnessEqual,
},
{
// Тот же дефект в самой опасной форме: падинг из null. Множества
// содержательных ключей равны, поэтому раньше решал второй разряд —
// и настоящее измерение проигрывало точке, не несущей измерения.
name: "падинг из null не полнее измерения",
a: `{"date":"d","qty":0.001,"p1":null,"p2":null,"p3":null}`,
b: `{"date":"d","qty":72.5}`,
want: canon.FullnessEqual,
},
{
// Надмножество содержательных ключей тоже обязано СОГЛАСОВЫВАТЬСЯ
// по общим значениям, иначе `qty:false` побеждало бы `qty:72.5`
// одним лишь наличием соседних полей.
name: "надмножество с чужим значением полноты не даёт",
a: `{"date":"d","qty":false,"Min":false,"Max":false}`,
b: `{"date":"d","qty":72.5}`,
want: canon.FullnessEqual,
},
{
// Настоящее надмножество: общее значение совпадает, поля добавлены.
name: "надмножество с тем же значением полнее",
a: `{"date":"d","qty":72.5,"Min":70,"Max":75}`,
b: `{"date":"d","qty":72.5}`,
want: canon.FullnessSuperset,
},
{
// false — одно из двух значений булева поля, а не отсутствие
// сведений: `isIndoor: false` это тренировка на улице.
name: "false содержателен",
a: `{"qty":1,"isIndoor":false}`, b: `{"qty":1}`,
want: canon.FullnessSuperset,
},
{
// Пустота считается по разобранному значению: будь она побайтовой,
// эти поля прошли бы как содержательные и набор стал бы несравнимым.
name: "запись нуля и пустоты роли не играет",
a: `{"qty":1,"a":0.0,"b":-0,"c":0e0,"d":{ },"e":[ ],"f":""}`,
b: `{"qty":1,"context":"x"}`,
want: canon.FullnessSubset,
},
{
// Ключ без содержания всё же лучше его отсутствия — но только когда
// содержательные множества уже сравнялись.
name: "лишний пустой ключ решает вторым разрядом",
a: `{"qty":1,"Min":0}`, b: `{"qty":1}`,
want: canon.FullnessSuperset,
},
{
// Граница пустоты проведена по содержанию, а не по «похоже на
// пустое»: пробел, строка "0" и контейнер с элементом — содержание.
name: "похожее на пустоту содержательно",
a: `{"qty":1,"a":" ","b":"0","c":[null],"d":{"x":null}}`,
b: `{"qty":1}`,
want: canon.FullnessSuperset,
},
{"не объект против точки", `[1,2,3]`, `{"qty":1}`, canon.FullnessSubset},
{"оба не объекты", `[1,2,3]`, `"строка"`, canon.FullnessEqual},
{"невалидный JSON", `{"qty":`, `{"qty":1}`, canon.FullnessSubset},
{"пустой вход", ``, ``, canon.FullnessEqual},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := canon.Completeness([]byte(c.raw)); got != c.want {
t.Errorf("полнота %s = %d, ожидалось %d", c.raw, got, c.want)
got := canon.RelateFullness([]byte(c.a), []byte(c.b))
if got != c.want {
t.Errorf("полнота %s против %s = %s, ожидалось %s",
c.a, c.b, got, c.want)
}
// Отношение обязано быть симметричным: свёртка по журналу не знает,
// какая из точек «первая».
want := mirror(c.want)
if got := canon.RelateFullness([]byte(c.b), []byte(c.a)); got != want {
t.Errorf("обратный порядок = %s, ожидалось %s", got, want)
}
})
}
}
func mirror(f canon.Fullness) canon.Fullness {
switch f {
case canon.FullnessSuperset:
return canon.FullnessSubset
case canon.FullnessSubset:
return canon.FullnessSuperset
default:
return f
}
}
// Полнота смотрит на наличие содержания, а не на форму записи: значения,
// различающиеся дребезгом последнего разряда, обязаны давать равные множества.
func TestRelateFullnessУстойчивКФормеЗаписи(t *testing.T) {
t.Parallel()
a := []byte(`{"qty":0.09523182962471353,"date":"d"}`)
b := []byte(` { "date" : "d" , "qty" : 0.09523182962471352 } `)
if got := canon.RelateFullness(a, b); got != canon.FullnessEqual {
t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessEqual)
}
if !canon.Equal(a, b) {
t.Error("канонические формы разошлись — тест проверяет не то")
}
}
// Нулевое значение типа не должно совпадать ни с одним исходом: забытое поле
// или ранний возврат не выглядят как «множества равны».
func TestFullnessНулевоеЗначениеНеИсход(t *testing.T) {
t.Parallel()
var zero canon.Fullness
for _, f := range []canon.Fullness{
canon.FullnessEqual, canon.FullnessSuperset,
canon.FullnessSubset, canon.FullnessIncomparable,
} {
if f == zero {
t.Errorf("исход %s совпал с нулевым значением", f)
}
if f.String() == zero.String() {
t.Errorf("имя исхода %s совпало с именем нулевого значения", f)
}
}
}
// Точка HAE несёт до нескольких десятков полей, но правило обязано быть
// тотальным и на неправдоподобном входе: паника здесь остановила бы разбор
// доставки целиком.
func TestRelateFullnessНеПаникуетНаБольшомВходе(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
buf.WriteByte('{')
for i := range 5000 {
if i > 0 {
buf.WriteByte(',')
}
fmt.Fprintf(&buf, `"k%d":%d`, i, i)
}
buf.WriteByte('}')
if got := canon.RelateFullness(buf.Bytes(), []byte(`{"k1":1}`)); got != canon.FullnessSuperset {
t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessSuperset)
}
if got := canon.RelateFullness(nil, nil); got != canon.FullnessEqual {
t.Errorf("полнота на nil = %s, ожидалось %s", got, canon.FullnessEqual)
}
}
// Тай-брейк обязан зависеть только от значений: свёртка по журналу должна
// давать то же состояние, что приём в реальном времени, а внутри одной
// доставки время приёма у столкнувшихся точек общее.
+13
View File
@@ -72,6 +72,7 @@ type Stats struct {
Buckets int
Unchanged int
Overwrites int
Incomparable int
SealedHits int
UnitsConflicts int
SkippedNoTime int
@@ -80,6 +81,7 @@ type Stats struct {
Layer string
LayerMismatch bool
Collisions []store.Collision
IncomparableAt []store.Collision
}
// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
@@ -138,9 +140,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
stats.Buckets = merge.Buckets
stats.Unchanged = merge.Unchanged
stats.Overwrites = merge.Overwrites
stats.Incomparable = merge.Incomparable
stats.SealedHits = merge.SealedHits
stats.UnitsConflicts = merge.UnitsConflicts
stats.Collisions = merge.Collisions
stats.IncomparableAt = merge.IncomparableAt
if err := s.finish(ctx, deliveryID, store.ParseDone, int64(stats.Points), stats.Layer); err != nil {
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
@@ -173,6 +177,7 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"buckets", st.Buckets,
"unchanged", st.Unchanged,
"overwrites", st.Overwrites,
"incomparable", st.Incomparable,
"units_conflicts", st.UnitsConflicts,
"sealed_hits", st.SealedHits,
"skipped", skipped,
@@ -185,6 +190,9 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.Collisions) > 0 {
attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
}
if len(st.IncomparableAt) > 0 {
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
}
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
// штатная работа. Без этого условия смена формата метки выглядела бы как
@@ -192,6 +200,11 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
allSkipped := st.Points == 0 && skipped > 0
switch {
case st.Incomparable > 0:
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
// этом идут атрибутами всегда, так что выбор ветви ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...)
case st.Overwrites > 0:
// Единственное наблюдение, по которому проверяется правило слияния.
// В INFO оно тонуло: поток идёт раз в пять минут.
+69
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
@@ -113,3 +114,71 @@ func TestFoldВсеТочкиОтброшеныДаётWarn(t *testing.T) {
t.Errorf("доставка без единой сохранённой точки записана не как WARN:\n%s", buf.String())
}
}
// Несравнимые наборы полей — то самое событие, ради наблюдения за которым
// объединение полей не реализовано вовсе. Если оно когда-нибудь наступит, его
// обязано быть видно, а не додумано задним числом.
func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
ctx := context.Background()
const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` +
`{"date":"2025-06-05 10:00:00 +0300",%s}` +
`]}]}}`
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1`)))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("первая свёртка: %v", err)
}
buf.Reset()
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"mealTime":"До еды"`)))
stats, err := f.Fold(ctx, "d2")
if err != nil {
t.Fatalf("вторая свёртка: %v", err)
}
if stats.Incomparable != 1 {
t.Fatalf("несравнимых %d, ожидался 1: тест проверяет не то", stats.Incomparable)
}
var rec map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil {
t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String())
}
if rec["level"] != "WARN" {
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
}
if rec["msg"] != "delivery folded, incomparable point fields" {
t.Errorf("сообщение %v не называет событие", rec["msg"])
}
if rec["incomparable"] != float64(1) {
t.Errorf("счётчик несравнимых %v, ожидался 1", rec["incomparable"])
}
// Координаты объекта — не значения: метрика, слой и час.
at, _ := rec["incomparable_at"].(string)
if !strings.Contains(at, "blood_glucose/minute@") {
t.Errorf("координаты объекта в записи не те: %q", at)
}
// И ни одного значения точки: данные о здоровье чувствительнее токенов.
for _, secret := range []string{"5.1", "До еды"} {
if strings.Contains(buf.String(), secret) {
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String())
}
}
}
+33 -3
View File
@@ -40,7 +40,7 @@ func TestReplayЖивогоАрхива(t *testing.T) {
f, arch, st := newFold(t)
ctx := context.Background()
var folded, failed int
var folded, failed, incomparable int
for _, path := range bodies {
body, err := os.ReadFile(path)
if err != nil {
@@ -55,14 +55,21 @@ func TestReplayЖивогоАрхива(t *testing.T) {
id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
deliver(t, arch, st, id, "", "auto", gunzip(t, body))
if _, err := f.Fold(ctx, id); err != nil {
res, err := f.Fold(ctx, id)
if err != nil {
failed++
continue
}
folded++
incomparable += res.Incomparable
}
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d", len(bodies), folded, failed)
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
// полей: их не было ни разу на всём корпусе. Число печатается, а не
// проверяется: появление такого набора — событие для разбора, а не отказ
// сходимости.
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d, несравнимых наборов %d",
len(bodies), folded, failed, incomparable)
if folded == 0 {
t.Fatal("ни одна доставка не свернулась")
@@ -70,10 +77,19 @@ func TestReplayЖивогоАрхива(t *testing.T) {
// Повторный прогон того же журнала не меняет состояния: свёртка
// детерминирована, и пересборка даёт то же, что живой приём.
//
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
// заведомо сломанном правиле.
before, err := st.CountBuckets(ctx)
if err != nil {
t.Fatalf("счёт объектов: %v", err)
}
fingerprintBefore, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
var refolded int
for _, path := range bodies {
if _, err := f.Fold(ctx, strings.TrimSuffix(filepath.Base(path), ".json.gz")); err != nil {
@@ -92,6 +108,20 @@ func TestReplayЖивогоАрхива(t *testing.T) {
if before != after {
t.Errorf("повторный прогон журнала изменил число объектов: %d → %d", before, after)
}
fingerprintAfter, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
if fingerprintBefore != fingerprintAfter {
t.Errorf("повторный прогон журнала изменил содержимое объектов:\n %s\n %s",
fingerprintBefore, fingerprintAfter)
}
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
// живёт — он производен от архива, которого нет ни на одной другой машине.
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
t.Logf("объектов %d, отпечаток содержимого %s", after, fingerprintAfter)
// Главное измеренное число: ключ по метке дал бы 170 координат сна, ключ по
// интервалу — 174 (docs/local-research.md, находка 47). Если координата
+207 -44
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -58,13 +60,23 @@ type MergeStats struct {
// сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и
// у ранних точек не остаётся ничего, по чему их единицы восстановимы.
UnitsConflicts int
// Incomparable — столкновения, где наборы полей несравнимы: у каждой точки
// есть содержательный ключ, которого нет у другой. Правило полноты тут
// бессильно, и вместо объединения полей — которого на живом потоке не
// потребовалось ни разу — заведено наблюдение. Несравнимость считается
// СВЕРХ Overwrites, а не вместо: иначе сумма перезаписей за период
// перестала бы быть сравнимой с прежней.
Incomparable int
// Collisions — координаты первых столкновений, для записи в лог. Без них
// счётчик перезаписей не говорит, какая метрика и какой час пострадали.
Collisions []Collision
// IncomparableAt — то же для несравнимых наборов.
IncomparableAt []Collision
}
// Collision — координаты объекта, где содержимое точки было перезаписано.
// Значений точек не несёт: данные о здоровье чувствительнее токенов.
// Collision — координаты объекта, где столкновение разрешилось перезаписью
// содержимого точки. Значений точек не несёт: данные о здоровье чувствительнее
// токенов.
type Collision struct {
Metric string
Layer string
@@ -75,6 +87,21 @@ type Collision struct {
// Больше горсти не нужно: они нужны как зацепка для разбора, а не как отчёт.
const maxCollisionsReported = 5
// maxMetricInLog — предел длины имени метрики в координате столкновения.
//
// Имя приходит из тела доставки дословно и ничем не ограничено, а предел
// приёма — 64 МиБ: без обрезки одна доставка порождает WARN-строку в десятки
// мегабайт и вытесняет из ротации логов всю недавнюю историю, включая записи о
// доставках, которые действительно потерялись. Имена метрик HAE — десятки байт.
const maxMetricInLog = 64
func clipMetric(metric string) string {
if len(metric) <= maxMetricInLog {
return metric
}
return metric[:maxMetricInLog] + "…"
}
// MergePoints раскладывает точки по часовым объектам и сливает их с
// сохранёнными.
//
@@ -110,6 +137,7 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
stats.Buckets++
stats.Stored += res.stored
stats.Overwrites += res.overwrites
stats.Incomparable += res.incomparable
if res.unchanged {
stats.Unchanged++
}
@@ -119,10 +147,15 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
if res.unitsConflict {
stats.UnitsConflicts++
}
// Список координат упирается в потолок, счётчики — нет: обрезанный
// список остаётся зацепкой для разбора, а масштаб события считает
// счётчик.
coord := Collision{Metric: clipMetric(key.metric), Layer: key.layer, HourUTC: key.hourUTC}
if res.overwrites > 0 && len(stats.Collisions) < maxCollisionsReported {
stats.Collisions = append(stats.Collisions, Collision{
Metric: key.metric, Layer: key.layer, HourUTC: key.hourUTC,
})
stats.Collisions = append(stats.Collisions, coord)
}
if res.incomparable > 0 && len(stats.IncomparableAt) < maxCollisionsReported {
stats.IncomparableAt = append(stats.IncomparableAt, coord)
}
}
return nil
@@ -192,6 +225,7 @@ func groupByHour(in []IncomingPoint) map[bucketKey]*pointGroup {
type mergeResult struct {
overwrites int
incomparable int
stored int
unchanged bool
sealed bool
@@ -211,8 +245,9 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
return res, err
}
merged, overwrites := mergePoints(stored.Points, group.points)
merged, overwrites, incomparable := mergePoints(stored.Points, group.points)
res.overwrites = overwrites
res.incomparable = incomparable
// Считаем сохранённые точки, а не присланные: точные повторы внутри
// доставки схлопываются, и счётчик присланных систематически завышал бы
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы
@@ -253,85 +288,169 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
// mergePoints сливает сохранённые точки с пришедшими по координатному ключу.
//
// При столкновении выигрывает БОЛЕЕ ПОЛНАЯ точка, а не последняя пришедшая:
// 0.66% координат различаются набором полей при одинаковом значении, и правило
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не
// несёт.
// 981 столкновение из 2 897 различается набором полей, и правило «последняя
// победила» стирало бы у сохранённой точки поля, которых новая не несёт.
//
// При равной полноте исход определяется порядком канонических форм, а не
// порядком доставок: у сохранённой точки нет провенанса, а четверть доставок
// несёт столкновения внутри себя, где время приёма общее. Свёртка по журналу
// обязана давать то же состояние, что приём в реальном времени.
//
// Второй счётчик — несравнимые наборы полей. Они и есть та часть правила,
// которую заменили наблюдением: объединять поля никто не будет, пока счётчик
// не заговорит.
//
// Точки из объекта не удаляются никогда.
func mergePoints(stored, incoming []Point) ([]Point, int) {
func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incomparable int) {
type coord struct {
start int64
end int64
}
byCoord := make(map[coord]Point, len(stored)+len(incoming))
byCoord := make(map[coord][]candidate, len(stored)+len(incoming))
order := make([]coord, 0, len(stored)+len(incoming))
put := func(p Point) int {
// Кандидаты копятся, а не сворачиваются попарно. Попарная свёртка была
// НЕВЕРНА: полнота — частичный порядок, тай-брейк — тотальный, и их
// смешение даёт нетранзитивное отношение победы. Оно образует цикл
// (A ⊃ B по ключам, B бьёт C тай-брейком, C бьёт A тай-брейком), после
// чего исход зависит от того, что уже лежало в объекте: одна и та же
// доставка, свёрнутая дважды, давала два разных состояния витрины
// поочерёдно. Это ломало главный инвариант — «состояние пересобираемо».
add := func(p Point) {
c := coord{start: p.Start.UnixNano(), end: p.End.UnixNano()}
old, ok := byCoord[c]
if !ok {
byCoord[c] = p
return 0
cands, seen := byCoord[c]
if !seen {
order = append(order, c)
}
// Побайтовое равенство — только быстрый путь. Столкновением считается
// расхождение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
cand := newCandidate(p)
// Побайтовое равенство — только быстрый путь. Тем же самым считается
// совпадение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
// байты нестабильны. Из 81952 повторно приехавших точек 67534
// различаются лишь порядком ключей, ещё 63% — последним разрядом
// double. Считай мы по байтам, счётчик перезаписей давал бы тысячи
// ложных срабатываний на каждом глубоком проходе, и настоящий отказ
// правила слияния стал бы неотличим от нормы.
if bytes.Equal(old.Raw, p.Raw) || canon.Equal(old.Raw, p.Raw) {
return 0
for _, q := range cands {
if bytes.Equal(q.key, cand.key) {
return
}
}
byCoord[c] = resolve(old, p)
return 1
byCoord[c] = append(cands, cand)
}
overwrites := 0
for _, p := range stored {
put(p)
add(p)
}
for _, p := range incoming {
overwrites += put(p)
add(p)
}
out := make([]Point, 0, len(order))
for _, c := range order {
cands := byCoord[c]
winner, unrelated := resolve(cands)
// Перезаписей столько, сколько точек уступило: при двух кандидатах
// одна, при трёх две. Так счёт остаётся сравнимым с прежним, где
// столкновение считалось на каждую приехавшую точку.
overwrites += len(cands) - 1
if unrelated {
incomparable++
}
out = append(out, winner)
}
// Порядок точек в объекте канонический и входит в хеш: его задаёт ТОЛЬКО
// сортировка ниже. Порядок обхода карты не специфицирован, и полагаться на
// него значило бы получать разные хеши для одного содержимого — тогда
// «неизменившийся» объект переписывался бы каждым глубоким проходом.
out := make([]Point, 0, len(byCoord))
for _, p := range byCoord {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool {
if !out[i].Start.Equal(out[j].Start) {
return out[i].Start.Before(out[j].Start)
}
return out[i].End.Before(out[j].End)
})
return out, overwrites
return out, overwrites, incomparable
}
// resolve выбирает победителя столкновения: сперва по полноте, затем
// детерминированно по канонической форме.
func resolve(a, b Point) Point {
ca, cb := canon.Completeness(a.Raw), canon.Completeness(b.Raw)
switch {
case ca > cb:
return a
case cb > ca:
return b
case canon.Less(a.Raw, b.Raw):
return a
default:
return b
// candidate — точка вместе с тем, что о ней нужно знать при выборе
// победителя. Разбор и канонизация делаются ОДИН раз на точку: сравнений
// квадратично по числу кандидатов, и пересчёт на каждое сравнение означал бы
// разбор точки столько раз, сколько на координате кандидатов.
type candidate struct {
pt Point
key []byte // каноническая форма: она же ключ дедупликации и порядок
fields canon.Fields
}
func newCandidate(p Point) candidate {
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw)}
}
// resolve выбирает победителя среди кандидатов одной координаты.
//
// Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления.
// Сперва отбрасываются те, кого превосходит по полноте кто-то другой
// (полнота — частичный порядок, поэтому «непревзойдённые» определены
// однозначно), затем среди оставшихся берётся минимум по каноническому
// порядку — он тотальный, поэтому минимум единственен. Обе операции зависят
// только от состава множества, поэтому пересборка журнала даёт то же
// состояние, что живой приём, а повторная свёртка той же доставки не меняет
// ничего.
//
// Победителем остаётся одна из пришедших точек ДОСЛОВНО: правило выбирает, а
// не конструирует. Каноническая форма существует только в момент сравнения, и
// вернуть её значило бы сохранить округлённое число вместо присланного.
//
// Второй возврат — остались ли непревзойдёнными несколько точек с
// несравнимыми наборами содержательных полей. На живом потоке этого не
// случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не
// реализовано: вместо него счётчик, который скажет, если событие наступит.
func resolve(cands []candidate) (Point, bool) {
if len(cands) == 1 {
return cands[0].pt, false
}
maximal := make([]candidate, 0, len(cands))
for i, a := range cands {
dominated := false
for j, b := range cands {
if i == j {
continue
}
if b.fields.Relate(a.fields) == canon.FullnessSuperset {
dominated = true
break
}
}
if !dominated {
maximal = append(maximal, a)
}
}
best := maximal[0]
for _, c := range maximal[1:] {
if bytes.Compare(c.key, best.key) < 0 {
best = c
}
}
// Несравнимость — не «осталось больше одного»: точки с одинаковыми
// наборами полей и разными значениями тоже остаются обе, и это рядовой
// тай-брейк. Считается только то, ради чего отложено объединение полей:
// у каждой из двух есть содержательный ключ, которого нет у другой.
return best.pt, hasIncomparablePair(maximal)
}
func hasIncomparablePair(cands []candidate) bool {
for i := range cands {
for j := i + 1; j < len(cands); j++ {
if cands[i].fields.Relate(cands[j].fields) == canon.FullnessIncomparable {
return true
}
}
}
return false
}
func hashPoints(points []Point) (string, error) {
@@ -580,6 +699,50 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
return n, nil
}
// Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам
// и хешам всех объектов в детерминированном порядке.
//
// Нужен проверке сходимости на живом архиве. Число объектов и число точек к
// правилу разрешения столкновений нечувствительны: на координате всегда лежит
// ровно одна точка, и правило выбирает, КАКАЯ это будет точка, а не сколько их.
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
// отпечаток — нет.
//
// Значений точек он не раскрывает: содержимое участвует только своим хешем.
func (s *Store) Fingerprint(ctx context.Context) (string, error) {
const q = `
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
ORDER BY metric, layer, hour_utc`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return "", fmt.Errorf("select buckets: %w", err)
}
defer func() { _ = rows.Close() }()
h := sha256.New()
for rows.Next() {
var metric, layer, hour, hash, units string
var points int
var sealed bool
if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil {
return "", fmt.Errorf("scan bucket: %w", err)
}
// Поля переменной длины идут с длиной впереди: разделитель, который
// может встретиться ВНУТРИ поля, даёт одну строку для разных состояний,
// а имя метрики и единицы приходят из тела доставки дословно. Ошибиться
// здесь значит получить «состояние совпало» при разошедшемся состоянии —
// то есть сломать молча ровно тот оракул, ради которого отпечаток и
// заведён. Тот же приём в canon.HashAll и по той же причине.
fmt.Fprintf(h, "%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
len(metric), metric, len(layer), layer, hour, hash, points, len(units), units, sealed)
}
if err := rows.Err(); err != nil {
return "", fmt.Errorf("select buckets: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// Bucket читает объект по координатам. Нужен тестам и будущему Read API.
func (s *Store) Bucket(ctx context.Context, metric, layer string, hour time.Time) (Bucket, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+317
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
@@ -150,6 +151,12 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
if stats.Unchanged != 1 {
t.Errorf("объектов без изменений %d, ожидался 1", stats.Unchanged)
}
// Повтор — не столкновение: точка сравнивается сама с собой, и ни один
// счётчик правила слияния расти не должен.
if stats.Overwrites != 0 || stats.Incomparable != 0 {
t.Errorf("повтор посчитан столкновением: перезаписей %d, несравнимых %d",
stats.Overwrites, stats.Incomparable)
}
after, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
@@ -301,6 +308,149 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
}
}
// Полнота — множество ключей, а не их число. Счётчик значащих полей давал
// сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно:
// восстановить его можно было бы только из сырого архива, пока он жив.
func TestMergePointsПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
hollow := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"qty":0,"a":0,"b":0,"c":{},"d":[]}`)
measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{hollow}, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.MergePoints(ctx, []store.IncomingPoint{measured}, "d2")
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != string(measured.Raw) {
t.Errorf("измерение стёрто точкой без содержания: %s", got)
}
if stats.Incomparable != 0 {
t.Errorf("несравнимых %d: наборы сравнимы, содержательных ключей у первой нет",
stats.Incomparable)
}
}
// Поле с нулевым значением содержания не несёт, но и теряться не должно: при
// равном множестве содержательных ключей выигрывает точка со всеми ключами.
func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":10,"Min":0,"Max":0}`)
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":12}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{wide}, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
if _, err := st.MergePoints(ctx, []store.IncomingPoint{narrow}, "d2"); err != nil {
t.Fatalf("второе слияние: %v", err)
}
b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != string(wide.Raw) {
t.Errorf("ключи Min и Max потеряны: %s", got)
}
}
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
// счётчик и координаты объекта, по которым событие можно будет разобрать.
func TestMergePointsНесравнимыеНаборыСчитаются(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
a := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"qty":5.1}`)
b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"mealTime":"До еды"}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2")
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
if stats.Incomparable != 1 {
t.Fatalf("несравнимых %d, ожидался 1", stats.Incomparable)
}
// Несравнимость — частный случай столкновения: иначе сумма перезаписей за
// период перестала бы быть сравнимой с прежней.
if stats.Overwrites != 1 {
t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
}
if len(stats.IncomparableAt) != 1 {
t.Fatalf("координат %d, ожидалась 1", len(stats.IncomparableAt))
}
if got := stats.IncomparableAt[0]; got.Metric != "blood_glucose" || got.Layer != "minute" {
t.Errorf("координаты объекта не те: %s/%s", got.Metric, got.Layer)
}
got, err := st.Bucket(ctx, "blood_glucose", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if len(got.Points) != 1 {
t.Errorf("точек %d, ожидалась 1: правило обязано выбрать одну", len(got.Points))
}
}
// Список координат упирается в потолок, счётчик — нет: обрезанный список
// остаётся зацепкой для разбора, а масштаб события считает счётчик.
func TestMergePointsСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
const hours = 8
var first, second []store.IncomingPoint
for h := range hours {
at := fmt.Sprintf("2025-06-05T%02d:00:00Z", h)
first = append(first, point(t, "blood_glucose", "minute", at, at, `{"qty":5.1}`))
second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`))
}
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.MergePoints(ctx, second, "d2")
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
if stats.Incomparable != hours {
t.Errorf("несравнимых %d, ожидалось %d: счётчик остановился вместе со списком",
stats.Incomparable, hours)
}
if len(stats.IncomparableAt) >= hours {
t.Errorf("координат %d — список не обрезан потолком", len(stats.IncomparableAt))
}
}
// source нестабилен: то же измерение приезжает то с одним именем устройства,
// то с другим. Он не входит в ключ и не считается полнотой.
func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
@@ -529,3 +679,170 @@ func TestMergePointsОтменаНеОставляетПоловины(t *testin
t.Errorf("точек %d, ожидалась 1: отмена оставила половинчатое состояние", len(b.Points))
}
}
// Отношение победы обязано быть функцией МНОЖЕСТВА точек, а не порядка их
// поступления. Попарная свёртка этого не давала: полнота — частичный порядок,
// тай-брейк — тотальный, и вместе они образовывали цикл (A ⊃ B по ключам,
// B бьёт C тай-брейком, C бьёт A тай-брейком). Из-за цикла одна и та же
// доставка, свёрнутая дважды, давала два состояния витрины поочерёдно —
// хранилище переставало быть свёрткой журнала.
//
// Тройка ниже — ровно такая: её нашёл враждебный проход ревью.
const (
cyclеB = `{"a":1,"b":1}`
cyclеC = `{"a":2,"d":1}`
cyclеA = `{"a":3,"b":1,"c":1}`
)
func cycleTriple(t *testing.T) []store.IncomingPoint {
t.Helper()
return []store.IncomingPoint{
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеB),
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеC),
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеA),
}
}
func TestMergePointsПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
in := cycleTriple(t)
state := func() (string, string) {
t.Helper()
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
t.Fatalf("слияние: %v", err)
}
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
fp, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return string(b.Points[0].Raw), fp
}
wantRaw, wantFP := state()
for pass := 2; pass <= 4; pass++ {
gotRaw, gotFP := state()
if gotRaw != wantRaw || gotFP != wantFP {
t.Fatalf("свёртка %d той же доставки изменила витрину:\n было %s / %s\n стало %s / %s",
pass, wantRaw, wantFP[:16], gotRaw, gotFP[:16])
}
}
}
func TestMergePointsИсходНеЗависитОтПерестановки(t *testing.T) {
t.Parallel()
ctx := context.Background()
in := cycleTriple(t)
// Все шесть перестановок тройки, и вдобавок разбиение на разные доставки:
// в живом приёме точки приходят порознь, в пересборке — вместе.
perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
winner := func(order []int, split bool) string {
st := open(t)
if split {
for _, i := range order {
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in[i]}, "d"); err != nil {
t.Fatalf("слияние: %v", err)
}
}
} else {
batch := make([]store.IncomingPoint, 0, len(order))
for _, i := range order {
batch = append(batch, in[i])
}
if _, err := st.MergePoints(ctx, batch, "d"); err != nil {
t.Fatalf("слияние: %v", err)
}
}
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
return string(b.Points[0].Raw)
}
want := winner(perms[0], false)
for _, p := range perms {
for _, split := range []bool{false, true} {
if got := winner(p, split); got != want {
t.Errorf("перестановка %v (порознь=%v) дала %s, ожидалось %s", p, split, got, want)
}
}
}
}
// Точка, ни одно значение которой не несёт измерения, не должна вытеснять
// настоящее измерение. Раньше вытесняла: множества содержательных ключей
// равны, и решал второй разряд — по ключам, а не по содержанию.
func TestMergePointsПадингНеВытесняетИзмерение(t *testing.T) {
t.Parallel()
ctx := context.Background()
const real = `{"date":"d","qty":72.5}`
cases := []struct {
name string
junk string
}{
{"падинг из null", `{"date":"d","qty":0.001,"p1":null,"p2":null,"p3":null}`},
{"падинг из false", `{"date":"d","qty":false,"Min":false,"Max":false}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
st := open(t)
in := []store.IncomingPoint{
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real),
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk),
}
stats, err := st.MergePoints(ctx, in, "d1")
if err != nil {
t.Fatalf("слияние: %v", err)
}
// Правило полноты обязано молчать: это столкновение равнополных,
// а не победа надмножества.
if stats.Incomparable != 0 {
t.Errorf("несравнимых %d, ожидалось 0: наборы содержательных ключей равны", stats.Incomparable)
}
})
}
}
// Имя метрики приходит из тела доставки дословно и ничем не ограничено.
// Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и
// вытесняет из ротации логов всю недавнюю историю.
func TestMergePointsИмяМетрикиВКоординатеОбрезано(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
huge := strings.Repeat("м", 5000)
in := []store.IncomingPoint{
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`),
}
stats, err := st.MergePoints(ctx, in, "d1")
if err != nil {
t.Fatalf("слияние: %v", err)
}
if len(stats.Collisions) == 0 {
t.Fatal("столкновение не зафиксировано")
}
if got := len(stats.Collisions[0].Metric); got > 128 {
t.Errorf("имя метрики в координате %d байт — оно уедет в лог как есть", got)
}
}
@@ -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` на живом архиве —
состояние обязано совпасть с прежним.
@@ -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` — только счётчики и координаты объекта: ни значений, ни имён полей точки
+105 -8
View File
@@ -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% — последним