Files
healthlog/docs/tasks/items/tie-break-equal-completeness.md
T
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

21 KiB
Raw Blame History

Тай-брейк при равной полноте точек

  • Секция: ядро
  • Зачем: Байтовый тай-брейк решает 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). Если развести их не удаётся — это вопрос владельцу, а не повод принять расхождение молча.

Род агрегации в правило не входит: род есть функция витрины, и правило слияния, читающее собственную выдачу, повторяет дефект наследования слоя «из будущего» (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.