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

- **Секция:** ядро
- **Зачем:** Байтовый тай-брейк решает 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.
