store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
+67
View File
@@ -1774,6 +1774,73 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
отсеивают неполный час сами.
## 54. Перемер тай-брейка: 98,8% спорных координат решает не полнота, а порядок форм
Замер 2026-08-04, повод — `task verify:archive` покраснел на `master` без
единого коммита, с ростом корпуса. Метод назван целиком, потому что прежняя
оценка (находка 49) и эта расходятся в 29 раз, и расхождение объясняется
методом, а не данными.
**Метод.** 155 тел архива, разбор настоящий (`hae.Parse` с наследованием слоя по
цепочке), ключ координаты **настоящий** — `метрика + слой + начало + конец`.
Кандидаты схлопываются по канонической форме (`canon.SortKey`, округление до 12
значащих цифр, находка 30); полнота — `canon.Fields.Relate`, то есть с условием
«значения общих содержательных ключей совпали». Программа лежала в `tmp/`
(вне репозитория: она ходит в рабочий архив).
Прежний замер того же дня давал «84 978 спорных из 453 171» — он считал ключ
**без слоя**, а без слоя часовая точка сталкивается с минутной, и это не
столкновение, а два разных ряда (та же ошибка названа в находке 49 первой
строкой её таблицы).
| что мерялось | сколько |
| --- | --- |
| координат всего | 460 995 |
| спорных (больше одной канонической формы) | 80 129 (17,4%) |
| из них полнота кого-то отбрасывает | 981 (1,2%) |
| из них все кандидаты непревзойдённые — решает тай-брейк | **79 148 (98,8%)** |
| несравнимых пар среди непревзойдённых | 2 |
| координат, где смена тай-брейка меняет исход | 75 494 |
**Соотношение 1,2% / 98,8% устойчиво** — оно совпало у обоих методов, и именно
оно, а не абсолютное число, было основанием решения: инвариант «выигрывает
более полная точка» на живом потоке отвечает в одном случае из восьмидесяти.
**Изменение сосредоточено в одной метрике одного слоя.** Из 75 494 изменившихся
координат 71 773 (95%) — `basal_energy_burned` слоя `raw`, то есть посекундная
развёртка HAE, которую Read API суммировать и так не имеет права. Следом
`basal_energy_burned/minute` (1 833), `walking_running_distance/raw` (777),
`step_count/raw` (746). Ошибка «системно храним меньшее» была массовой по
координатам и узкой по метрикам.
**Несравнимых наборов больше не ноль.** Находка 49 фиксировала 0 из 2 897; на
155 доставках их 2. Порог «объединять поля не будем, пока счётчик молчит»
поэтому подтверждается, но уже не абсолютен: событие наступило, просто редко.
**Направление, в котором новое правило теряет содержание, замерено отдельно.**
Разряд полноты гаснет, когда значения общих содержательных ключей разошлись, —
и тогда пришедшая точка побеждает, даже если у проигравшей был содержательный
ключ, которого у неё нет. Таких координат на корпусе **2**, обе
`sleep_analysis_summary/day`, и обе — ровно те же, что дают несравнимые наборы.
То есть случай «сохранённая беднее по именам, но значения разошлись» на живом
потоке не наблюдался вовсе. Прежний байтовый порядок давал ту же потерю по
жребию и так же молча; теперь она детерминирована и считается
(`MergeStats.PointsErased`, `WARN`).
**Исход починки, тем же прогоном.** Смена тай-брейка на «побеждает пришедшая»
вернула род двум метрикам: `step_count` (`unknown` → `cumulative`, ноль
противоречащих часов вместо одного) и `headphone_audio_exposure`
(`unknown` → `instant`). Итог каталога: накопительных 6 → 7, мгновенных 9 → 10,
неизвестных 16 → 14. Отпечаток витрины сменился, как и требовалось: 3 194
объекта, `bf36b477…` → `03aace91…`. Удержаний правилом полноты на весь
корпус — 1 247, потерь содержания — 2.
**Проверено ещё раз на выросшем корпусе.** Пока шла работа, телефон прислал ещё
три доставки; прогон на 158 телах остался зелёным (3 255 объектов, отпечаток
`c4fbb1c7…`, ноль противоречащих часов, `step_count` по-прежнему
`cumulative`). Это и есть ответ на то, чем дефект был найден: прежнее правило
покраснело именно от роста корпуса, новое рост пережило.
## Открытые вопросы
- **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для