закрыта задача tie-break-equal-completeness
This commit is contained in:
@@ -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}`, то есть должна.
|
||||
|
||||
Переход к (б) остаётся дешёвым: счётчик скажет, если событие станет массовым.
|
||||
|
||||
@@ -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) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user