diff --git a/docs/adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md b/docs/adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md index a6536f7..db6db26 100644 --- a/docs/adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md +++ b/docs/adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md @@ -82,3 +82,38 @@ - `−` Живая витрина в `./data` расходится с новым правилом до пересборки: подмена файла базы — необратимое действие человека и этим изменением не выполняется. + +## Открыто, решает владелец + +Записано здесь, а не в файле задачи: файл закрытой задачи удаляется, а эти два +решения переживают её. + +**1. Пересобирать ли живую витрину сейчас.** Правило действует только вперёд: +уже сохранённые часы держат значение прежнего, измеримо смещённого правила, пока +витрину не пересоберут, — это 75 494 координаты (95% — `basal_energy_burned` +слоя `raw`). До пересборки сверка отпечатков с `healthlog reindex` не сойдётся и +будет выглядеть отказом. + +- **(а)** `reindex` с остановкой сервиса и подменой базы сразу после выкладки. + Цена: минута простоя приёма на нынешнем архиве плюс необратимое действие + руками. **Рекомендация.** +- **(б)** отложить до планового окна, приняв расхождение витрины на этот срок. +- **(в)** не пересобирать: витрина сойдётся только по координатам, которые + переприедут доставками, — смещение останется в истории навсегда. + +**2. Не сузить ли тай-брейк там, где он теряет содержание.** Разряд полноты +гаснет при разошедшихся значениях общих ключей, и тогда пришедшая точка +побеждает, даже если унесёт ключ, которого сама не несёт. Замер: 2 координаты из +80 129 спорных, обе — те же, что дают несравнимые наборы. + +- **(а, сделано)** оставить правило, завести счётчик `PointsErased` с `WARN`. + Событие наблюдается, но не предотвращается; обратимо пересборкой, пока жив + архив. +- **(б)** сузить «побеждает пришедшая» до случая совпавших множеств + содержательных ключей, а при строгом включении имён оставлять более полную + независимо от происхождения. Цена: правило перестаёт быть чисто структурным на + этом разряде, дельта хранения переписывается, прогон живого архива снимается + заново. Проверить обязательно: сохраняется ли починка `step_count` — по замеру + его столкновения идут с одинаковыми наборами `{date, qty}`, то есть должна. + +Переход к (б) остаётся дешёвым: счётчик скажет, если событие станет массовым. diff --git a/docs/tasks/BACKLOG.md b/docs/tasks/BACKLOG.md index b6eae58..8d24e59 100644 --- a/docs/tasks/BACKLOG.md +++ b/docs/tasks/BACKLOG.md @@ -33,7 +33,6 @@ - [Выведенные из данных схемы содержимого](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке - [[idea] Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено - [Сверка живой витрины с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит -- [Тай-брейк при равной полноте точек](items/tie-break-equal-completeness.md) — Байтовый тай-брейк решает 98,8% спорных координат и хранит устаревшую точку: step_count потерял род, verify:archive красный - [Устаревание нижнего слоя после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен - [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно - [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке diff --git a/docs/tasks/items/tie-break-equal-completeness.md b/docs/tasks/items/tie-break-equal-completeness.md deleted file mode 100644 index 60758db..0000000 --- a/docs/tasks/items/tie-break-equal-completeness.md +++ /dev/null @@ -1,240 +0,0 @@ -# Тай-брейк при равной полноте точек - -- **Секция:** ядро -- **Зачем:** Байтовый тай-брейк решает 98,8% спорных координат и хранит устаревшую точку: step_count потерял род, verify:archive красный -- **Теги:** goal:merge-robustness - -**Решение владельца 2026-08-04, заменяет прежнее: при равной полноте побеждает -пришедшая точка, а не сохранённая.** Байтовый порядок канонических форм -остаётся **только** для столкновений внутри одной доставки, где провенанс общий -и различать нечем. Это облегчённая форма варианта «в» — без колонки провенанса -и без миграции. - -**Прежнее решение от 2026-08-02 — вариант (б), брать бо́льшее значение — снято.** -Причина: разбор красного `verify:archive` (2026-08-04) возразил, что «большее» -неверно для мгновенных метрик, которые досчитываются **вниз**, а постановка -утверждала обратное; спор не был закрыт замером, и вместо него взято правило, -которое значение вообще не интерпретирует. - -**Что при этом сломано намеренно и обязано быть починено задачей.** «Пришедшая -побеждает» — не полурешётка: правило перестаёт быть коммутативным, и исход -слияния становится функцией **порядка свёртки**. Прежняя постановка называла -полурешётку обязательной ровно затем, чтобы витрина оставалась свёрткой -журнала. Значит задача обязана определить порядок так, чтобы **пересборка -равнялась приёму**, и доказать это оракулом, а не рассуждением. Осложняющий -факт назван в соседней задаче: `replay` идёт по ULID, а живая свёртка — по -факту свёртки, и доставка, отложенная занятостью базы, их разводит -([journal-order-on-ingest](journal-order-on-ingest.md)). Если развести их не -удаётся — это вопрос владельцу, а не повод принять расхождение молча. - -Род агрегации в правило **не входит**: род есть функция витрины, и правило -слияния, читающее собственную выдачу, повторяет дефект наследования слоя «из -будущего» (`docs/review.md`, 2026-08-01). - -## Вопросы - -Записаны 2026-08-04 по итогам ревью (профиль `deep`). Работа доведена до -коммита в объявленных границах; эти два решения не мои. - -**1. Пересобирать ли живую витрину сейчас.** Новое правило меняет исход на -75 494 координатах (95% — `basal_energy_burned` слоя `raw`), но действует -только вперёд: уже сохранённые часы держат значение, выбранное прежним, -измеримо смещённым правилом, пока витрину не пересоберут. Отпечаток живого -`./data` с новым правилом **не сойдётся** — это ожидаемо и названо в дизайне. - -- **(а)** `healthlog reindex` с остановкой сервиса и подменой файла базы сразу - после выкладки. Цена: простой приёма на время прогона (минута на нынешнем - архиве) плюс необратимое действие руками. -- **(б)** отложить до планового окна, приняв расхождение витрины на этот срок. - Цена: до пересборки Read API отдаёт по историческим часам прежние значения, а - сверка отпечатков с пересборкой не сойдётся и будет выглядеть отказом. -- **(в)** не пересобирать вовсе — витрина сойдётся только по тем координатам, - которые переприедут доставками. Цена: смещение остаётся в истории навсегда, - обнаружится сверкой с родным экспортом Apple, то есть месяцами позже. - -Рекомендация: **(а)**. Подмена файла базы — необратимое действие человека -(`CLAUDE.md`), выполнить его я не вправе; этим изменением оно и не выполняется. - -**2. Не сузить ли тай-брейк там, где он теряет содержание.** Разряд полноты -гаснет, когда значения общих содержательных ключей разошлись, — и тогда -пришедшая точка побеждает, даже если унесёт ключ, которого сама не несёт. -Замер: 2 координаты из 80 129 спорных на живом корпусе, обе — те же, что дают -несравнимые наборы. - -- **(а, сделано)** оставить правило и завести счётчик `PointsErased` с `WARN` и - координатами. Цена: событие наблюдается, но не предотвращается; обратимо - пересборкой, пока жив архив. -- **(б)** сузить «побеждает пришедшая» до случая, когда множества - содержательных ключей совпали, а при строгом включении имён оставлять более - полную независимо от происхождения. Цена: правило перестаёт быть чисто - структурным на этом разряде, дельта хранения переписывается, прогон живого - архива надо снимать заново. Проверить обязательно: сохраняется ли при этом - починка `step_count` — по замеру его столкновения идут с одинаковыми - наборами `{date, qty}`, то есть должна сохраниться. - -Рекомендация: **(а)** — она уже реализована, потому что не меняет принятого -владельцем правила и восстанавливает наблюдаемость. Переход к (б) остаётся -дешёвым: счётчик скажет, если событие станет массовым. - - -## Критерии приёмки - -- ни одна метрика не теряет род из-за столкновения равной полноты; `step_count` - снова накопительная — оракул: `task verify:archive`, ноль противоречащих - часов -- живая свёртка и пересборка дают **один** отпечаток витрины — оракул: - `healthlog reindex` против живого состояния; это же и есть страховка от - потерянной коммутативности -- повторный прогон реплея даёт тот же отпечаток — оракул: `task verify:archive`, - второй прогон подряд -- столкновение **внутри одной доставки** разрешается прежним байтовым порядком — - оракул: тест на двух точках одной доставки с равной полнотой -- «пришедшая точка проиграла сохранённой» считается отдельно от общего - `MergeStats.Overwrites` — оракул: тест плюс прогон на живом архиве, число - сходится с замером 2026-08-04 - -## Рамки - -Схему не трогаем: колонки провенанса на точку не заводим. Отпечаток витрины -обязан **измениться** — иначе правило не сработало. Пересборка обязательна и -делается человеком при остановленном сервисе; подмена файла базы — необратимое -действие и здесь не выполняется. - -## Диагноз 2026-08-04: у дефекта появился независимый оракул - -`task verify:archive` покраснел на `master` без единого коммита — отказ приехал -с ростом корпуса. Разбор довёл до причины, и причина эта. - -**Противоречащий час — `2026-08-03T07:00Z`, метрика `step_count`.** Часовой слой -несёт одну точку `V`, минутный — две точки, каждая ровно `V`. Значит -`sum = 2V`, `mean = V`, и сверка слоёв объявляет метрику **мгновенной** — один -голос против 22 накопительных. `internal/catalog/catalog.go:498` уводит -`step_count` в `unknown`, то есть **суммировать шаги Read API больше не имеет -права**. - -**Правильное значение лежит в архиве.** На координату приехало 4 точки, два -различных значения: `V` от первой доставки и `2V` от трёх последующих, при -совпадающих `source` и офсете. `2V` в точности равно сумме минутных точек, то -есть даёт накопительную, как остальные 22 часа. Побеждает `V` — потому что -каноническая форма меньшего числа сортируется первой -(`internal/store/bucket.go:517` не находит превосходства по полноте, управление -уходит на `bucket.go:523`, `bytes.Compare(canon.SortKey(...))`). - -**Корпусный замер (453 171 координата) переоценивает масштаб:** - -| | координат | -| --- | --- | -| спорных (больше одной канонической формы) | 84 978 | -| решено полнотой | 1 022 (1,2%) | -| **упало на байтовый тай-брейк** | **83 956 (98,8%)** | - -Прежняя оценка «1916 столкновений, 0.43% координат» была снята на меньшем -корпусе и считала другое. Инвариант «выигрывает более полная точка» на живом -потоке решает 1,2% спорных координат; в остальных 98,8% исход определяет -лексикографический порядок канонического JSON. - -**Потеря не наблюдаема.** `MergeStats.Overwrites` считает столкновение в обе -стороны и на этом корпусе сработал бы ~84 000 раз; отличить «оставили новое» от -«выбросили новое» по нему нельзя. Счётчика «пришедшая точка проиграла -сохранённой при равной полноте» не существует, и завести его — часть этой -задачи. - -**Возражение против выбранного варианта (б), которого при выборе не звучало.** -Разбор отвергает «брать бо́льшее» тем, что оно неверно для мгновенных метрик, -которые досчитываются **вниз**. Постановка ниже утверждает обратное — что для -мгновенных выбор безразличен, поскольку это пересэмплирование. Одно из двух -утверждений неверно, и решает это замер, а не рассуждение: до реализации надо -проверить на живом корпусе, встречается ли мгновенная метрика, у которой -поздняя версия точки меньше ранней. - -Разбор предлагает вместо (б) разрешать равную полноту **позицией в журнале** -(вариант «в» ниже) либо его облегчённую форму: при равной полноте предпочитать -пришедшую точку сохранённой, оставив байтовый порядок только для столкновений -внутри одной доставки, где провенанс общий. Первое требует смены формата -`payload` (миграция `00003_bucket.sql`), пересборки витрины и смены отпечатка; -второе схему не трогает. У обоих одна общая развилка: `replay` идёт по ULID, а -живая свёртка — по факту свёртки, и отложенная занятостью доставка их разводит; -«позиция в журнале» обязана быть определена так, чтобы пересборка равнялась -приёму. - -Ужесточение измерения (`minFinePoints` 2→3) замерено и отвергнуто как плохой -размен: чинит `step_count`, но роняет `blood_oxygen_saturation`, -`environmental_audio_exposure` и `stair_speed_up` в `unknown` — и прячет -неверное хранимое, из-за которого Read API отдаёт за этот час половину шагов. - -Не проверено: останется ли измерение бесконфликтным после починки — изменится -81 343 координаты. Тот же ли дефект у слияния сущностей (`internal/store/winner.go`, -отношение другое) — не мерялось. - -## Что происходит - -Какое правило выбирает победителя, когда по одним координатам приехали две точки -с **равными** наборами содержательных полей и разными значениями. Структурная -часть правила слияния закрыта (`pravilo-sliyaniya-tochek`); открыт только этот -разряд. - -Сегодня это порядок канонических форм, и он измеримо смещён: из 1912 случаев, где -сравнение чисел определено, лексикографический порядок берёт **меньшее** значение -в 1847 — 96% (находка 49). Столкновений с равной полнотой 1916 из 444 256 -координат, то есть 0.43% координат. - -## Что стало известно - -Задача «Измеренный род агрегации и каталог разрезов» закрыла посылку, ради -которой тай-брейк откладывали: род метрик теперь **измерен**, а не угадан -(находка 53). Четыре из шести метрик, где тай-брейк системно берёт меньшее -(`step_count`, `walking_running_distance`, `active_energy`, -`basal_energy_burned`), измерены как **накопительные** — там «меньшее» это -систематический недосчёт порядка 0.4% координат, ровно тот, что HAE досчитывает -задним числом (находка 10). Самая крупная группа, `heart_rate`, измерена как -**мгновенная**, и там выбор безразличен: это пересэмплирование, а не досчёт. - -И тем же измерением закрылся напрашивавшийся ответ: **сделать тай-брейк -зависящим от измеренного рода нельзя**. Род есть функция витрины, витрина — -результат слияния, и правило слияния, читающее собственную выдачу, повторяет -ровно тот дефект, на котором свёртка уже переставала быть функцией префикса -журнала (`docs/review.md`, 2026-08-01, наследование слоя «из будущего»). - -## Варианты и цена - -**а. Оставить порядок канонических форм.** Цена: систематический недосчёт 0.4% -координат у накопительных метрик, невидимый до сверки с родным экспортом Apple, -то есть месяцами. Плюс: ноль работы, правило остаётся структурным и не знает -ничего о значениях. - -**б. Брать бо́льшее значение точки.** Правильно для накопительных (досчёт растёт, -находка 10, и набор полей у версий тренировки ни разу не уменьшался) и безвредно -для мгновенных (пересэмплирование). Цена: слияние перестаёт быть структурным — -оно начинает знать, какое поле точки несёт число (`hae.PointValue` уже есть). -Метрика, у которой «большее» неверно, в потоке не наблюдалась, но и не -исключена; правило приходится делать тотальным (нет числа — откат на порядок -канонических форм), то есть в нём появляется вторая ветка. - -**в. Провенанс у точки и тай-брейк по позиции в журнале** — как у сущностей. -Цена: колонка провенанса на точку (или на объект) и рост объёма нижнего слоя; -плюс это не работает для столкновений **внутри одной доставки**, где -`received_at` общий, а таких четверть (находка 47: 33 столкновения внутри -доставки на эпизодах сна). То есть вариант не самодостаточен и всё равно требует -второго разряда. - -## Что заблокировано - -Ничего срочного: сегодняшнее правило детерминировано и воспроизводимо, витрина -остаётся свёрткой журнала. Блокирован только сам недосчёт — он копится молча. -Сверить его величину можно будет после `healthlog import`: родной экспорт Apple -даст независимый эталон по тем же периодам. - -## Рекомендация - -**Вариант б.** Он чинит измеренное смещение там, где оно есть, и не трогает -там, где его нет; цена — одна ветка в правиле слияния и признание, что слияние -знает про число точки (а оно уже знает — `hae.PointValue` живёт в разборе). От -варианта «а» отличается тем, что перестаёт систематически терять данные; -от «в» — тем, что не требует ни колонки, ни решения для внутридоставочных -столкновений. - -Проверять на прогоне живого архива: отпечаток витрины обязан измениться (иначе -правило не сработало), а число столкновений с равной полнотой — остаться прежним. - -Связано: `docs/architecture.md` → «Разрешение столкновений», находки 10, 47, 49, -53.