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

- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- Less стал тотальным, isEmpty не материализует значение, имя метрики в
  координате столкновения обрезается, отпечаток витрины включает units и sealed
- на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
av
2026-08-01 21:05:43 +03:00
parent 349a227ab1
commit 7a7594e3e7
22 changed files with 1818 additions and 176 deletions
+2 -1
View File
@@ -20,7 +20,6 @@
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Полнота точки — по множеству ключей, а не по их числу](pravilo-sliyaniya-tochek.md) — точка с пятью пустыми полями бьёт настоящее измерение: полнота считается счётчиком, а не множеством
- [Непокрытые секции доставки видны в статусе разбора](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind числится parsed, а ретеншен снесёт его как разобранное — и в экспорте Apple его нет
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
@@ -35,6 +34,8 @@
- [[idea] Что считать сутками при смене часового пояса](sutki-i-chasovoj-poyas.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [[idea] Пересекающиеся источники одной метрики](peresekayushchiesya-istochniki.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [Умолчания конфига указывают на прежнюю раскладку](umolchaniya-konfiga-data.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- [Счётчики слияния переживают ротацию логов](nablyudenie-za-sliyaniem-v-bd.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
## низкий
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
@@ -0,0 +1,58 @@
# Цена слияния на широкой доставке
**Приоритет:** средний
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
проход и независимая реализация — независимо друг от друга).
## Оракул: измерено
Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ
координате:
```
транзакция держалась 5.149 с, аллоцировано 6108 МБ, пик кучи 189 МБ
```
При `busy_timeout` 5000 мс и `_txlock=immediate` параллельная доставка
получает `SQLITE_BUSY`, `inTx` повторяет её до пяти раз (каждый повтор —
полный пересчёт слияния), и на исчерпании попыток доставка уходит в
`parse_status=failed`. Тело при этом в архиве остаётся, но пересборки, которая
его подберёт, ещё нет.
Отдельный вклад, измеренный бенчмарком на часовом объекте нижнего слоя
(3 600 точек):
```
HashAll на объект 9.46 мс 6.7 МБ аллокаций
Form одной точки 2.2 мкс
```
`hashPoints` пересчитывает каноническую форму ВСЕХ точек часа на каждое
слияние, включая неизменившиеся. Утверждение `docs/architecture.md` о
дешевизне глубокого прохода («4400 сравнений хеша, почти все сойдутся»)
стоимости самих сравнений не учитывает: чтобы сравнить хеш, его надо посчитать.
Часть цены задачей `pravilo-sliyaniya-tochek` уже снята: `isEmpty` больше не
материализует значение (было 410 мс на точку 16.5 МБ), а разбор точки
переиспользуется через `canon.Fields`. Осталось структурное.
## Что делать
1. Держать хеш каждой точки в `payload` рядом с координатами (`storedPoint`
уже есть) и пересчитывать форму только для новых и выигравших столкновение
— стоимость станет пропорциональна дельте, а не объёму часа.
2. Считать `canon.Form` точки один раз на столкновение и переиспользовать в
`Equal`, `Relate` и порядке вместо трёх независимых разборов.
3. Проверять `ctx.Err()` в цикле по точкам: сейчас дедлайн свёртки неисполним,
прервать слияние нечем.
4. Оценить потолок числа кандидатов на одной координате: выбор победителя
квадратичен по ним, и 119 точек на одну метку — вход, который приём
принимает.
## Связано
- [otvet-i-svyortka](otvet-i-svyortka.md) — воркер убирает влияние на ответ
приёму, но не на блокировку записи; задачи независимы.
- [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбирает доставки, ушедшие в
`failed` по этой причине.
@@ -0,0 +1,42 @@
# Счётчики слияния переживают ротацию логов
**Приоритет:** средний
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
негативного пространства, подтверждено эксплуатационным).
## Что не так
Решение не реализовывать объединение полей при несравнимых наборах стоит на
одном аргументе: «вместо реализации — счётчик, который скажет, если событие
наступит». Сказать он может только в одну сторону — строкой `WARN` в stdout
контейнера.
`docker-compose.yml` держит `json-file` с `max-size: 10m, max-file: 3`. При
~288 доставках в сутки это порядка кварталов, а ожидаемая частота события —
«ни разу за 99 доставок». Штатный сценарий: событие происходит ночью, логи
никто не грепал в этот месяц, строка уходит в ротацию, и в системе не остаётся
ни одного свидетельства. Ни колонки в `delivery`, ни `/stats`, ни файла.
То есть обещание «событие будет видно, а не додумано» на практике не
выполняется.
## Что делать
Положить `overwrites` и `incomparable` в строку `delivery` — миграция плюс
запись в `FinishParse`, которая эту строку всё равно трогает. Тогда «было ли
когда-нибудь несравнимо» это один `SELECT`, живущий столько же, сколько
витрина.
Заодно стоит решить смежное, найденное тем же проходом: сегодня в логе
неразличимы «правило полноты сработало» и «всё ушло в тай-брейк». Два разных
состояния мира дают одинаковую картину `overwrites=N, incomparable=0`, то есть
отказ правила выглядит как здоровая работа. Отдельный счётчик исходов
`Superset`/`Subset` рядом с `Overwrites` это закрывает.
## Связано
- [stats-nablyudaemost](stats-nablyudaemost.md) — то же наблюдение нужно и там.
- [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md) — придёт к вопросу о
тай-брейке и потребует эксплуатационной истории, которой без этой задачи не
будет: мерить придётся снова по архиву, а он к тому моменту подрезан.
-72
View File
@@ -1,72 +0,0 @@
# Полнота точки — по множеству ключей, а не по их числу
**Приоритет:** высокий
Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
`deep`, находка №7 триажа, severity major). **Решение принято замером**
(находка 49) — ниже задача на остаток.
## Что не так сегодня
Полнота — это **счётчик** ключей, чьё значение не `null`, не `""` и не пусто.
Поэтому `{}`, `[]`, `0` и `false` считаются содержательными:
```
точка {"qty":0,"a":0,"b":0,"c":{},"d":[]} полнота 5
точка {"date":"…","qty":123.4} полнота 2
```
Вторая точка — настоящее измерение — проигрывает первой и стирается
безвозвратно. Восстановить можно только из сырого архива, пока он жив.
## Что решено и на каком основании
Замер по всем 99 доставкам (находка 49): настоящих столкновений 2 897 из
444 256 координат (0.65%), и среди них **ноль несравнимых наборов полей**.
Отсюда:
- **Полнота — сравнение множеств ключей, побеждает надмножество.** Ровно 981
случай из замера, и там правило уже работает верно — но работает случайно,
через счётчик, который на другом входе даст обратное.
- **Объединение полей при несравнимых наборах не нужно.** Самая дорогая часть
обсуждавшегося варианта (1) на живом потоке не срабатывает ни разу. Вместо
реализации — счётчик: несравнимый набор считается и логируется `WARN`. Если
событие когда-нибудь наступит, оно будет видно, а не додумано заранее.
- **Тай-брейк при равной полноте в эту задачу не входит.** Обоснование ниже.
## Что делать
1. `resolve` в `internal/store/bucket.go`: полнота — множество ключей с
непустым значением; побеждает строгое надмножество.
2. Несравнимые множества — счётчик в `MergeStats` плюс `WARN` с координатами
объекта (без значений). Точку выбирает тот же тай-брейк, что и при равной
полноте.
3. Дельта-спека `openspec/specs/storage` → «Разрешение столкновений по
полноте»: переформулировать с числа ключей на множество.
4. Тесты: «бедная точка с пятью пустыми полями не стирает измерение»
(регрессия на приведённой выше паре), «надмножество побеждает»,
«несравнимые наборы считаются».
5. `task verify:archive` обязан дать то же состояние — правило меняет исход
только там, где сегодня он неверен.
## Что отложено и почему
**Тай-брейк при равной полноте.** Сегодня это лексикографический порядок
канонических форм. Измерено (находка 49): в 1 847 случаях из 1 912 он выбирает
**меньшее** значение — 96%. Четыре из шести затронутых метрик накопительные,
там это систематический недосчёт; но крупнейшая группа, `heart_rate` (985
случаев), мгновенная, и там «большее» не правильнее.
То есть правильный тай-брейк зависит от **рода метрики**, а род по замыслу
проекта измеряется сверкой слоёв между собой. Выбирать его сейчас — угадывать
ровно то, что станет известно точно.
Зависимость: [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md). Тай-брейк
доделывается **после** неё, отдельной задачей.
## Что стоит без решения
Ничего. Правило работает и **наблюдаемо**: столкновение считается расхождением
канонических форм (а не байтов), даёт `WARN` с координатами объекта и счётчик
`overwrites`.
+4 -1
View File
@@ -21,7 +21,10 @@ HAE. Он не сэмплы, а посекундная развёртка (на
Измерено (находка 49), что сегодняшний лексикографический порядок берёт меньшее
значение в 96% случаев — для накопительных это недосчёт, для мгновенных
безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк
доделывается по нему — см. [pravilo-sliyaniya-tochek](pravilo-sliyaniya-tochek.md).
доделывается по нему. Остальное правило слияния уже сделано — структурная часть
закрыта задачей `pravilo-sliyaniya-tochek` (архив change
`2026-08-01-polnota-tochki-mnozhestvom-klyuchey`), здесь остался только выбор
победителя при РАВНОЙ полноте.
Связано: `docs/architecture.md` → «Слои гранулярности», план шаг 4.