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