полнота точки — множество ключей, победитель — функция множества точек
- отношение победы было нетранзитивным: полнота (частичный порядок) плюс тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна и та же доставка меняла содержимое объекта при каждой пересборке - надмножество побеждает только при совпадении значений общих содержательных ключей: иначе точка без единого измерения вытесняла измерение - Less стал тотальным, isEmpty не материализует значение, имя метрики в координате столкновения обрезается, отпечаток витрины включает units и sealed - на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
+54
-2
@@ -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 отдаёт перечислимые значения строками из локали телефона, а не кодами:
|
||||
|
||||
@@ -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) — придёт к вопросу о
|
||||
тай-брейке и потребует эксплуатационной истории, которой без этой задачи не
|
||||
будет: мерить придётся снова по архиву, а он к тому моменту подрезан.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -107,3 +107,11 @@
|
||||
источником истины служат живые данные.
|
||||
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
|
||||
витрину.
|
||||
- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать
|
||||
обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает
|
||||
коммутативность и молчит про ассоциативность, а сломаться правило может
|
||||
именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их
|
||||
попарная свёртка дала нетранзитивное отношение победы, из-за которого одна
|
||||
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
|
||||
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
|
||||
выражается — отсюда проза.
|
||||
|
||||
@@ -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, только стандартная
|
||||
|
||||
Reference in New Issue
Block a user