Files
healthlog/docs/tasks/items/tie-break-equal-completeness.md
T
av de2001dea6 tie-break: записан диагноз красного verify:archive и снято прежнее решение
- байтовый тай-брейк решает 98,8% спорных координат из 84 978: step_count
  потерял род, потому что сохранённая точка выиграла у более поздней
- вариант (б) «брать бо́льшее» снят, взято «пришедшая побеждает сохранённую»;
  оно не полурешётка, и задача обязана доказать равенство пересборки приёму
2026-08-04 07:55:11 +03:00

195 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Тай-брейк при равной полноте точек
- **Секция:** ядро
- **Зачем:** Байтовый тай-брейк решает 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).
## Критерии приёмки
- ни одна метрика не теряет род из-за столкновения равной полноты; `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.