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

- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- 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 # IDE
/.idea/ /.idea/
/.vscode/ /.vscode/
# worktree агентов ревью — не часть репозитория
.claude/worktrees/
+54 -2
View File
@@ -29,6 +29,8 @@ healthlog принимает выгрузки Apple Health из приложен
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений, нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой. последняя пришедшая: бедная доставка не должна стирать поля у богатой.
Полнота — **множество** ключей с непустым значением, а не их число (см.
«Разрешение столкновений»).
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной - **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела — глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации». см. «Модель синхронизации».
@@ -193,8 +195,10 @@ HRV); у накопительных — только `date`. Поэтому то
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
единицы записей, и месячное окно там почти ничего не стоит. единицы записей, и месячное окно там почти ничего не стоит.
Последние пришедшие данные всегда актуализируют картину — правило слияния Правило слияния одинаково для всех проходов, и порядок прихода значения не
одинаково для всех проходов, порядок прихода значения не имеет. имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда
не было верным: при столкновении выигрывает более полная точка, а не последняя
пришедшая (см. «Разрешение столкновений»).
Автоматизации различимы по заголовку `automation-id`; имена стоит задать, Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12). иначе `automation-name` приходит пустым (находка 12).
@@ -535,6 +539,54 @@ hour метки выровнены на час heart_rate 00:00:00
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из `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 отдаёт перечислимые значения строками из локали телефона, а не кодами: HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
+2 -1
View File
@@ -20,7 +20,6 @@
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем - [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Полнота точки — по множеству ключей, а не по их числу](pravilo-sliyaniya-tochek.md) — точка с пятью пустыми полями бьёт настоящее измерение: полнота считается счётчиком, а не множеством
- [Непокрытые секции доставки видны в статусе разбора](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind числится parsed, а ретеншен снесёт его как разобранное — и в экспорте Apple его нет - [Непокрытые секции доставки видны в статусе разбора](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind числится parsed, а ретеншен снесёт его как разобранное — и в экспорте Apple его нет
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200 - [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
@@ -35,6 +34,8 @@
- [[idea] Что считать сутками при смене часового пояса](sutki-i-chasovoj-poyas.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено - [[idea] Что считать сутками при смене часового пояса](sutki-i-chasovoj-poyas.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [[idea] Пересекающиеся источники одной метрики](peresekayushchiesya-istochniki.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь - [[idea] Пересекающиеся источники одной метрики](peresekayushchiesya-istochniki.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [Умолчания конфига указывают на прежнюю раскладку](umolchaniya-konfiga-data.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 он избыточен - [Устаревание нижнего слоя после экспорта](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), что сегодняшний лексикографический порядок берёт меньшее Измерено (находка 49), что сегодняшний лексикографический порядок берёт меньшее
значение в 96% случаев — для накопительных это недосчёт, для мгновенных значение в 96% случаев — для накопительных это недосчёт, для мгновенных
безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк
доделывается по нему — см. [pravilo-sliyaniya-tochek](pravilo-sliyaniya-tochek.md). доделывается по нему. Остальное правило слияния уже сделано — структурная часть
закрыта задачей `pravilo-sliyaniya-tochek` (архив change
`2026-08-01-polnota-tochki-mnozhestvom-klyuchey`), здесь остался только выбор
победителя при РАВНОЙ полноте.
Связано: `docs/architecture.md` → «Слои гранулярности», план шаг 4. Связано: `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, только стандартная Разбор ведётся скриптом `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) return bytes.Equal(fa, fb)
} }
// Completeness — мера полноты точки: сколько значащих полей она несёт. // Fullness — отношение полноты двух точек: несёт ли одна всё, что несёт
// другая, и сверх того.
// //
// Нужна правилу разрешения столкновений: по одним координатам приезжают точки // Полнота — частичный порядок, а не число. Счётчик значащих полей сравним
// с разным НАБОРОМ полей при одинаковом значении (0.66% координат), и правило // всегда и потому отвечает там, где ответа нет: точка с пятью полями без
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не // содержания «полнее» настоящего измерения с двумя. Измерено (находка 49):
// несёт. // настоящих столкновений 0.65% координат, из них 981 различаются набором
// полей — это и есть область правила, — а несравнимых наборов ноль.
// //
// Поля с пустым значением не считаются: точка с `context: null` не полнее // Нумерация с единицы: нулевое значение не означает ничего. Незаполненное поле
// точки без `context`. Поле source в счёт не идёт — оно нестабильно и // или ранний возврат не должны выглядеть как «множества равны» — это
// переписывается задним числом, так что его наличие ничего не говорит о // сегодняшний исход по умолчанию, и отказ маскировался бы под успех.
// полноте измерения. type Fullness int
func Completeness(raw []byte) 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 var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil { if err := json.Unmarshal(raw, &obj); err != nil {
return 0 return f
} }
n := 0
for k, v := range obj { for k, v := range obj {
if k == "source" { if k == "source" {
continue 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 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 задаёт детерминированный порядок на точках равной полноты. // 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 { func Less(a, b []byte) bool {
fa, err := Form(a) return bytes.Compare(SortKey(a), SortKey(b)) < 0
if err != nil {
return false
}
fb, err := Form(b)
if err != nil {
return true
}
return bytes.Compare(fa, fb) < 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 { func isEmpty(v json.RawMessage) bool {
t := bytes.TrimSpace(v) lit := bytes.TrimSpace(v)
switch { if len(lit) == 0 {
case len(t) == 0, bytes.Equal(t, []byte("null")):
return true
case bytes.Equal(t, []byte(`""`)):
return true 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: 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 false
} }
return len(bytes.TrimSpace(lit[1:len(lit)-1])) == 0
} }
// decode разбирает значение с числами в виде json.Number: строковый литерал // decode разбирает значение с числами в виде json.Number: строковый литерал
+190 -16
View File
@@ -1,7 +1,9 @@
package canon_test package canon_test
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt"
"testing" "testing"
"git.vakhrushev.me/av/healthlog/internal/canon" "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() t.Parallel()
cases := []struct { cases := []struct {
name string name string
raw string a string
want int b string
want canon.Fullness
}{ }{
{"пустой объект", `{}`, 0}, {"одинаковые", `{"qty":1}`, `{"qty":1}`, canon.FullnessEqual},
{"только qty", `{"qty":1}`, 1}, {
{"qty и границы", `{"qty":1,"start":"a","end":"b"}`, 3}, "надмножество", `{"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 нестабилен и переписывается задним числом, поэтому его // source нестабилен и переписывается задним числом, поэтому его
// наличие ничего не говорит о полноте измерения. // наличие ничего не говорит о полноте измерения.
name: "source не считается", name: "source не считается",
raw: `{"qty":1,"source":"Device A"}`, a: `{"qty":1,"source":"Device A"}`, b: `{"qty":1}`,
want: 1, want: canon.FullnessEqual,
}, },
{ {
// Точка с пустым полем не полнее точки без него — иначе бедная // Регрессия задачи: пять полей без содержания против настоящего
// доставка выиграла бы столкновение одним лишь наличием ключа. // измерения. Счётчик давал 5 против 2 и стирал измерение.
name: "пустые значения не считаются", name: "поля без содержания не добавляют полноты",
raw: `{"qty":1,"context":null,"note":""}`, a: `{"qty":0,"a":0,"b":0,"c":{},"d":[]}`,
want: 1, 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 { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
t.Parallel() 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 Buckets int
Unchanged int Unchanged int
Overwrites int Overwrites int
Incomparable int
SealedHits int SealedHits int
UnitsConflicts int UnitsConflicts int
SkippedNoTime int SkippedNoTime int
@@ -80,6 +81,7 @@ type Stats struct {
Layer string Layer string
LayerMismatch bool LayerMismatch bool
Collisions []store.Collision Collisions []store.Collision
IncomparableAt []store.Collision
} }
// Fold разбирает тело доставки и раскладывает точки по часовым объектам. // Fold разбирает тело доставки и раскладывает точки по часовым объектам.
@@ -138,9 +140,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
stats.Buckets = merge.Buckets stats.Buckets = merge.Buckets
stats.Unchanged = merge.Unchanged stats.Unchanged = merge.Unchanged
stats.Overwrites = merge.Overwrites stats.Overwrites = merge.Overwrites
stats.Incomparable = merge.Incomparable
stats.SealedHits = merge.SealedHits stats.SealedHits = merge.SealedHits
stats.UnitsConflicts = merge.UnitsConflicts stats.UnitsConflicts = merge.UnitsConflicts
stats.Collisions = merge.Collisions stats.Collisions = merge.Collisions
stats.IncomparableAt = merge.IncomparableAt
if err := s.finish(ctx, deliveryID, store.ParseDone, int64(stats.Points), stats.Layer); err != nil { 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) 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, "buckets", st.Buckets,
"unchanged", st.Unchanged, "unchanged", st.Unchanged,
"overwrites", st.Overwrites, "overwrites", st.Overwrites,
"incomparable", st.Incomparable,
"units_conflicts", st.UnitsConflicts, "units_conflicts", st.UnitsConflicts,
"sealed_hits", st.SealedHits, "sealed_hits", st.SealedHits,
"skipped", skipped, "skipped", skipped,
@@ -185,6 +190,9 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.Collisions) > 0 { if len(st.Collisions) > 0 {
attrs = append(attrs, "collisions", formatCollisions(st.Collisions)) 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 allSkipped := st.Points == 0 && skipped > 0
switch { switch {
case st.Incomparable > 0:
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
// этом идут атрибутами всегда, так что выбор ветви ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...)
case st.Overwrites > 0: case st.Overwrites > 0:
// Единственное наблюдение, по которому проверяется правило слияния. // Единственное наблюдение, по которому проверяется правило слияния.
// В INFO оно тонуло: поток идёт раз в пять минут. // В INFO оно тонуло: поток идёт раз в пять минут.
+69
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -113,3 +114,71 @@ func TestFoldВсеТочкиОтброшеныДаётWarn(t *testing.T) {
t.Errorf("доставка без единой сохранённой точки записана не как WARN:\n%s", buf.String()) 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) f, arch, st := newFold(t)
ctx := context.Background() ctx := context.Background()
var folded, failed int var folded, failed, incomparable int
for _, path := range bodies { for _, path := range bodies {
body, err := os.ReadFile(path) body, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -55,14 +55,21 @@ func TestReplayЖивогоАрхива(t *testing.T) {
id := strings.TrimSuffix(filepath.Base(path), ".json.gz") id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
deliver(t, arch, st, id, "", "auto", gunzip(t, body)) 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++ failed++
continue continue
} }
folded++ 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 { if folded == 0 {
t.Fatal("ни одна доставка не свернулась") t.Fatal("ни одна доставка не свернулась")
@@ -70,10 +77,19 @@ func TestReplayЖивогоАрхива(t *testing.T) {
// Повторный прогон того же журнала не меняет состояния: свёртка // Повторный прогон того же журнала не меняет состояния: свёртка
// детерминирована, и пересборка даёт то же, что живой приём. // детерминирована, и пересборка даёт то же, что живой приём.
//
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
// заведомо сломанном правиле.
before, err := st.CountBuckets(ctx) before, err := st.CountBuckets(ctx)
if err != nil { if err != nil {
t.Fatalf("счёт объектов: %v", err) t.Fatalf("счёт объектов: %v", err)
} }
fingerprintBefore, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
var refolded int var refolded int
for _, path := range bodies { for _, path := range bodies {
if _, err := f.Fold(ctx, strings.TrimSuffix(filepath.Base(path), ".json.gz")); err != nil { 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 { if before != after {
t.Errorf("повторный прогон журнала изменил число объектов: %d → %d", 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 координат сна, ключ по // Главное измеренное число: ключ по метке дал бы 170 координат сна, ключ по
// интервалу — 174 (docs/local-research.md, находка 47). Если координата // интервалу — 174 (docs/local-research.md, находка 47). Если координата
+208 -45
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -58,13 +60,23 @@ type MergeStats struct {
// сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и // сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и
// у ранних точек не остаётся ничего, по чему их единицы восстановимы. // у ранних точек не остаётся ничего, по чему их единицы восстановимы.
UnitsConflicts int UnitsConflicts int
// Incomparable — столкновения, где наборы полей несравнимы: у каждой точки
// есть содержательный ключ, которого нет у другой. Правило полноты тут
// бессильно, и вместо объединения полей — которого на живом потоке не
// потребовалось ни разу — заведено наблюдение. Несравнимость считается
// СВЕРХ Overwrites, а не вместо: иначе сумма перезаписей за период
// перестала бы быть сравнимой с прежней.
Incomparable int
// Collisions — координаты первых столкновений, для записи в лог. Без них // Collisions — координаты первых столкновений, для записи в лог. Без них
// счётчик перезаписей не говорит, какая метрика и какой час пострадали. // счётчик перезаписей не говорит, какая метрика и какой час пострадали.
Collisions []Collision Collisions []Collision
// IncomparableAt — то же для несравнимых наборов.
IncomparableAt []Collision
} }
// Collision — координаты объекта, где содержимое точки было перезаписано. // Collision — координаты объекта, где столкновение разрешилось перезаписью
// Значений точек не несёт: данные о здоровье чувствительнее токенов. // содержимого точки. Значений точек не несёт: данные о здоровье чувствительнее
// токенов.
type Collision struct { type Collision struct {
Metric string Metric string
Layer string Layer string
@@ -75,6 +87,21 @@ type Collision struct {
// Больше горсти не нужно: они нужны как зацепка для разбора, а не как отчёт. // Больше горсти не нужно: они нужны как зацепка для разбора, а не как отчёт.
const maxCollisionsReported = 5 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 раскладывает точки по часовым объектам и сливает их с // MergePoints раскладывает точки по часовым объектам и сливает их с
// сохранёнными. // сохранёнными.
// //
@@ -110,6 +137,7 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
stats.Buckets++ stats.Buckets++
stats.Stored += res.stored stats.Stored += res.stored
stats.Overwrites += res.overwrites stats.Overwrites += res.overwrites
stats.Incomparable += res.incomparable
if res.unchanged { if res.unchanged {
stats.Unchanged++ stats.Unchanged++
} }
@@ -119,10 +147,15 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
if res.unitsConflict { if res.unitsConflict {
stats.UnitsConflicts++ stats.UnitsConflicts++
} }
// Список координат упирается в потолок, счётчики — нет: обрезанный
// список остаётся зацепкой для разбора, а масштаб события считает
// счётчик.
coord := Collision{Metric: clipMetric(key.metric), Layer: key.layer, HourUTC: key.hourUTC}
if res.overwrites > 0 && len(stats.Collisions) < maxCollisionsReported { if res.overwrites > 0 && len(stats.Collisions) < maxCollisionsReported {
stats.Collisions = append(stats.Collisions, Collision{ stats.Collisions = append(stats.Collisions, coord)
Metric: key.metric, Layer: key.layer, HourUTC: key.hourUTC, }
}) if res.incomparable > 0 && len(stats.IncomparableAt) < maxCollisionsReported {
stats.IncomparableAt = append(stats.IncomparableAt, coord)
} }
} }
return nil return nil
@@ -192,6 +225,7 @@ func groupByHour(in []IncomingPoint) map[bucketKey]*pointGroup {
type mergeResult struct { type mergeResult struct {
overwrites int overwrites int
incomparable int
stored int stored int
unchanged bool unchanged bool
sealed bool sealed bool
@@ -211,8 +245,9 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
return res, err return res, err
} }
merged, overwrites := mergePoints(stored.Points, group.points) merged, overwrites, incomparable := mergePoints(stored.Points, group.points)
res.overwrites = overwrites res.overwrites = overwrites
res.incomparable = incomparable
// Считаем сохранённые точки, а не присланные: точные повторы внутри // Считаем сохранённые точки, а не присланные: точные повторы внутри
// доставки схлопываются, и счётчик присланных систематически завышал бы // доставки схлопываются, и счётчик присланных систематически завышал бы
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы // содержимое витрины — расхождение «прислали 1000, лежит 700» было бы
@@ -253,85 +288,169 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
// mergePoints сливает сохранённые точки с пришедшими по координатному ключу. // 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 { type coord struct {
start int64 start int64
end 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()} c := coord{start: p.Start.UnixNano(), end: p.End.UnixNano()}
old, ok := byCoord[c] cands, seen := byCoord[c]
if !ok { if !seen {
byCoord[c] = p order = append(order, c)
return 0
} }
// Побайтовое равенство — только быстрый путь. Столкновением считается cand := newCandidate(p)
// расхождение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что // Побайтовое равенство — только быстрый путь. Тем же самым считается
// совпадение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
// байты нестабильны. Из 81952 повторно приехавших точек 67534 // байты нестабильны. Из 81952 повторно приехавших точек 67534
// различаются лишь порядком ключей, ещё 63% — последним разрядом // различаются лишь порядком ключей, ещё 63% — последним разрядом
// double. Считай мы по байтам, счётчик перезаписей давал бы тысячи // double. Считай мы по байтам, счётчик перезаписей давал бы тысячи
// ложных срабатываний на каждом глубоком проходе, и настоящий отказ // ложных срабатываний на каждом глубоком проходе, и настоящий отказ
// правила слияния стал бы неотличим от нормы. // правила слияния стал бы неотличим от нормы.
if bytes.Equal(old.Raw, p.Raw) || canon.Equal(old.Raw, p.Raw) { for _, q := range cands {
return 0 if bytes.Equal(q.key, cand.key) {
return
}
}
byCoord[c] = append(cands, cand)
} }
byCoord[c] = resolve(old, p)
return 1
}
overwrites := 0
for _, p := range stored { for _, p := range stored {
put(p) add(p)
} }
for _, p := range incoming { 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 { sort.Slice(out, func(i, j int) bool {
if !out[i].Start.Equal(out[j].Start) { if !out[i].Start.Equal(out[j].Start) {
return out[i].Start.Before(out[j].Start) return out[i].Start.Before(out[j].Start)
} }
return out[i].End.Before(out[j].End) return out[i].End.Before(out[j].End)
}) })
return out, overwrites return out, overwrites, incomparable
} }
// resolve выбирает победителя столкновения: сперва по полноте, затем // candidate — точка вместе с тем, что о ней нужно знать при выборе
// детерминированно по канонической форме. // победителя. Разбор и канонизация делаются ОДИН раз на точку: сравнений
func resolve(a, b Point) Point { // квадратично по числу кандидатов, и пересчёт на каждое сравнение означал бы
ca, cb := canon.Completeness(a.Raw), canon.Completeness(b.Raw) // разбор точки столько раз, сколько на координате кандидатов.
switch { type candidate struct {
case ca > cb: pt Point
return a key []byte // каноническая форма: она же ключ дедупликации и порядок
case cb > ca: fields canon.Fields
return b }
case canon.Less(a.Raw, b.Raw):
return a func newCandidate(p Point) candidate {
default: return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw)}
return b }
// 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) { func hashPoints(points []Point) (string, error) {
@@ -580,6 +699,50 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
return n, nil 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. // Bucket читает объект по координатам. Нужен тестам и будущему Read API.
func (s *Store) Bucket(ctx context.Context, metric, layer string, hour time.Time) (Bucket, error) { 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}) tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+317
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -150,6 +151,12 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
if stats.Unchanged != 1 { if stats.Unchanged != 1 {
t.Errorf("объектов без изменений %d, ожидался 1", stats.Unchanged) 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")) after, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil { 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 нестабилен: то же измерение приезжает то с одним именем устройства, // source нестабилен: то же измерение приезжает то с одним именем устройства,
// то с другим. Он не входит в ключ и не считается полнотой. // то с другим. Он не входит в ключ и не считается полнотой.
func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) { func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
@@ -529,3 +679,170 @@ func TestMergePointsОтменаНеОставляетПоловины(t *testin
t.Errorf("точек %d, ожидалась 1: отмена оставила половинчатое состояние", len(b.Points)) 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: Разрешение столкновений по полноте ### Requirement: Разрешение столкновений по полноте
Когда по одним координатам приходят разные содержимые, система SHALL оставлять Когда по одним координатам приходят разные содержимые, система 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` для этого не годится: у сохранённой точки нет Сравнение по `received_at` для этого не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
не с чем. Детерминизм обеспечивается свойством самих значений (например, не с чем. Детерминизм обеспечивается свойством самих значений (например,
порядком канонических форм), а не порядком событий. порядком канонических форм), а не порядком событий.
Если множества **несравнимы** — каждое несёт ключ с непустым значением,
которого нет у другого, — система SHALL выбрать победителя тем же
детерминированным правилом, что и при равных множествах, и MUST оставить
наблюдение: счётчик в итоге разбора доставки, координаты объекта и запись
`WARN` без значений точки. Несравнимый набор — частный случай столкновения:
счётчик перезаписей растёт вместе с ним, а координаты попадают в оба списка.
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимых
наборов не встретилось ни разу (0 из 2 897 столкновений, при обоих определениях
пустоты), и реализация правила, которое никогда не срабатывает, стоила бы
больше, чем счётчик, который скажет, если оно наступит.
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
выбирает, а не конструирует. Каноническая форма существует только в момент
сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое
число вместо присланного.
#### Scenario: Бедная точка не стирает поля богатой #### Scenario: Бедная точка не стирает поля богатой
- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context` - **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max` - **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
- **THEN** сохранённая точка остаётся с `context` - **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: Одинаково полные точки с разными значениями #### Scenario: Одинаково полные точки с разными значениями
- **WHEN** по одним координатам приходят две одинаково полные точки с разными - **WHEN** по одним координатам приходят две точки с одинаковыми множествами
значениями ключей и разными значениями
- **THEN** исход определяется детерминированно и не зависит от порядка - **THEN** исход определяется детерминированно и не зависит от порядка
воспроизведения доставок воспроизведения доставок
#### Scenario: Несравнимые множества считаются, а не сливаются
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
ключ с непустым значением, которого нет у другой
- **THEN** остаётся ровно одна точка, выбранная детерминированно
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
- **AND** система пишет `WARN` с координатами объекта и без значений точки
#### Scenario: Содержимое, которое не разбирается в объект
- **WHEN** по координатам сталкиваются точка с непустыми полями и содержимое,
не разбирающееся как JSON-объект
- **THEN** остаётся точка с полями
- **AND** счётчик несравнимых наборов не растёт
Столкновением SHALL считаться расхождение **канонических форм**, а не байтов. Столкновением SHALL считаться расхождение **канонических форм**, а не байтов.
Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно
приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним