блокеры разобраны замером: три стали задачами, один закрыт

- находки 48 и 49: единицы не менялись ни разу; настоящих столкновений 0.65%,
  несравнимых наборов полей нет, тай-брейк берёт меньшее в 96% случаев
- edinicy-metriki-v-razreze закрыт: гипотеза не подтвердилась замером
- тай-брейк отложен до каталога рода агрегации
This commit is contained in:
av
2026-08-01 19:33:47 +03:00
parent 37413bb551
commit 349a227ab1
8 changed files with 211 additions and 165 deletions
+1
View File
@@ -5,3 +5,4 @@
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … --> <!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … -->
- 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Был приоритет: высокий. - 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Был приоритет: высокий.
- 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Был приоритет: блокеры. - 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Был приоритет: блокеры.
- 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Был приоритет: блокеры.
+3 -4
View File
@@ -12,10 +12,6 @@
одному — прерывать поток ради каждого дороже, чем накопить. одному — прерывать поток ради каждого дороже, чем накопить.
## блокеры ## блокеры
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
- [Правило выбора победителя при столкновении точек](pravilo-sliyaniya-tochek.md) — полнота считается числом ключей, поэтому мусорные поля бьют измерение
- [Единицы метрики: часть координаты или свойство объекта](edinicy-metriki-v-razreze.md) — смена единиц в настройках HAE делит час на точки в разных единицах
- [Судьба доставки, у которой разобрана не вся секция data](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind помечается parsed, а ретеншен снесёт его как разобранное
## высокий ## высокий
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика - [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
@@ -24,6 +20,9 @@
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем - [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Полнота точки — по множеству ключей, а не по их числу](pravilo-sliyaniya-tochek.md) — точка с пятью пустыми полями бьёт настоящее измерение: полнота считается счётчиком, а не множеством
- [Непокрытые секции доставки видны в статусе разбора](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind числится parsed, а ретеншен снесёт его как разобранное — и в экспорте Apple его нет
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
## средний ## средний
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить - [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
-53
View File
@@ -1,53 +0,0 @@
# Единицы метрики: часть координаты или свойство объекта
**Приоритет:** блокеры
Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, найдено
тремя проходами независимо).
## Что решить
Единицы измерения живут колонкой объекта — одна на все точки часа. Что делать,
когда в тот же час приезжают точки в **других** единицах.
## Почему это не мелочь
Внутри точки единиц нет: проверено на 89 доставках, поле `units` не
встретилось ни разу, оно живёт только на уровне метрики. Значит у точки,
сохранённой раньше, не остаётся **ничего**, по чему её единицы восстановимы —
кроме сырого архива, пока он жив.
Смена реальна и не требует злого умысла: переключатель единиц в настройках
HAE, смена локали телефона, переименование единицы в новой версии приложения.
## Варианты и цена
**(1) Единицы — часть координаты объекта** (`метрика + слой + единицы + час`).
Цена: миграция, ключ шире, каталог разрезов обязан показывать единицы. Зато
точки никогда не подписаны чужим — разные единицы просто разные ряды.
**(2) Первое непустое побеждает, расхождение — `WARN` и счётчик** (сделано
сейчас как безопасное умолчание).
Цена: нулевая, уже работает. Но час, начавшийся в километрах, останется
километровым навсегда, даже если телефон окончательно переехал на мили.
**(3) Хранить обе величины у объекта** (`units` и `units_seen`).
Цена: малая, но это откладывание решения: читателю всё равно придётся
выбирать.
## Рекомендация
**(1)**, если единицы вообще могут меняться на живом потоке — а они могут.
Сегодняшний (2) безопасен, но оставляет систематическую ложь в подписи.
## Что стоит без решения
Ничего не теряется: сохранённые единицы больше **не перезаписываются** молча
(было — пришедшие всегда побеждали), расхождение считается в
`MergeStats.UnitsConflicts` и даёт `WARN` в свёртке.
Отдельная тонкость, которую надо закрыть тем же решением: единицы не входят в
хеш содержимого, поэтому доставка с теми же точками и исправленными единицами
уходит по ветке «ничего не изменилось» и колонку не трогает. То есть единицы
сегодня обновляются тогда и только тогда, когда изменилось содержимое, —
правило, которого никто не формулировал.
+42 -40
View File
@@ -1,58 +1,60 @@
# Судьба доставки, у которой разобрана не вся секция data # Непокрытые секции доставки видны в статусе разбора
**Приоритет:** блокеры **Приоритет:** высокий
Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, проход Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
негативного пространства). **Решить до реализации ретеншена.** `deep`, проход негативного пространства). **Решение принято** — ниже задача.
## Что решить ## Что не так сегодня
Разбор читает только `data.metrics`. Доставка, состоящая из `workouts`, Разбор читает только `data.metrics`. Доставка, состоящая из `workouts`,
`stateOfMind`, `symptoms` или `ecg`, помечается `parse_status=parsed` с нулём `stateOfMind`, `symptoms` или `ecg`, помечается `parse_status=parsed` с нулём
точек — неотличимо от доставки с пустой секцией метрик. точек — неотличимо от доставки с пустой секцией метрик.
## Почему это блокер, а не задача Само по себе некритично: секции пока не разбираются сознательно, тела лежат в
архиве. Опасность в **сцепке с ретеншеном**. Ретеншен по замыслу срезает архив
до следующего проверенного экспорта. Если он будет ориентироваться на
`parse_status`, он снесёт тела, которые числятся разобранными, — а для
`stateOfMind` это необратимо: **в экспорте Apple его нет** (находка 46),
доставки HAE для него единственный источник.
Само по себе это некритично: секции пока не разбираются сознательно, тела Цена ошибки здесь не «придётся пересобрать», а «истории состояния разума больше
лежат в архиве. Опасность в **сцепке с ретеншеном**. не существует».
Ретеншен по замыслу срезает архив до следующего проверенного экспорта. Если он ## Что решено
будет ориентироваться на `parse_status`, он снесёт тела, которые числятся
разобранными, — а для `stateOfMind` это необратимо: **в экспорте Apple его
нет** (находка 46), доставки HAE для него единственный источник.
То есть цена ошибки здесь не «придётся пересобрать», а «истории состояния Вариант (1): **разбор возвращает список верхнеуровневых ключей `data`, которые
разума больше не существует». он не покрыл; статус доставки — `partial`.**
## Варианты и цена Почему он, а не альтернативы:
**(1) Разбор возвращает список верхнеуровневых ключей `data`, которые он не - Считать `parsed` только полностью разобранную доставку, а остальные держать
покрыл; статус `partial`.** в `pending` — дёшево, но `pending` перестаёт означать «ещё не смотрели», и
Цена: малая — один `json.Decoder` по верхнему уровню, без чтения содержимого. подбор зависших доставок теряет свой признак. А подбор `pending` как раз
Ретеншен и `reindex` получают честный признак, а в логе появляется момент, появляется задачей [otvet-i-svyortka](otvet-i-svyortka.md).
когда поток принёс новую секцию (ровно то, ради чего заведена задача - Запретить ретеншену смотреть на `parse_status` — нулевая цена сейчас, но
`proverka-novyh-sekcij`). ретеншен становится тупым и не защищает от «тело разобрано неверно, а мы его
уже срезали».
**(2) Считать `parsed` только доставку, у которой разобрано всё; остальные — Список непокрытых ключей — дешёвая честность, и он же закрывает задачу «не
`pending`.** пропустить момент, когда поедет новая секция».
Цена: малая, но `pending` перестаёт означать «ещё не смотрели», и подбор
зависших доставок теряет свой признак.
**(3) Ничего не менять, но ретеншену запретить смотреть на `parse_status` ## Что делать
резать только по дате экспорта.**
Цена: нулевая сейчас, но ретеншен становится тупым и не защищает от «тело
разобрано неверно, а мы его уже срезали».
## Рекомендация 1. `hae.Parse`: перечислить верхнеуровневые ключи `data` и вернуть те, что
разбор не покрыл. Один `json.Decoder` по верхнему уровню, **без чтения
содержимого** — секции доходят до десятков МиБ.
2. Статус `partial` рядом с `parsed`/`failed`; миграция, если статус хранится
ограниченным набором.
3. Свёртка пишет непокрытые ключи в исход доставки и логирует их один раз —
именами ключей, без содержимого.
4. Тест на фикстуре с секцией, которой разбор не знает: статус `partial`,
ключ в списке, точки метрик при этом сохранены.
**(1).** Список непокрытых ключей — дешёвая честность, и он же закрывает ## Связано
задачу «не пропустить момент, когда поедет новая секция».
## Что стоит без решения - [retenshen-syrogo-arhiva](retenshen-syrogo-arhiva.md) — решить **до** неё.
- [proverka-novyh-sekcij](proverka-novyh-sekcij.md) — тот же признак закрывает
Ничего: сегодня ретеншена нет, тела не удаляются. Задача — не дать сцепке и её: момент появления новой секции становится событием в логе.
сложиться позже. - [trenirovki-i-zapisi](trenirovki-i-zapisi.md) — по мере разбора секций список
непокрытых сокращается сам.
Связано: [retenshen-syrogo-arhiva](retenshen-syrogo-arhiva.md) — решить **до**
неё; [proverka-novyh-sekcij](proverka-novyh-sekcij.md) — тот же признак закрыл
бы и её.
+39 -27
View File
@@ -1,16 +1,14 @@
# Разнести ответ приёма и свёртку доставки # Разнести ответ приёма и свёртку доставки
**Приоритет:** блокеры **Приоритет:** высокий
Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, находка №4 Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
триажа, severity major). `deep`, находка №4 триажа, severity major). **Решение принято** — ниже задача.
## Что решить ## Что не так сегодня
Свёртка выполняется **синхронно внутри обработчика запроса**, поэтому время Свёртка выполняется **синхронно внутри обработчика запроса**, поэтому время
ответа равно времени свёртки. Вопрос: разносить ли их, и какой ценой. ответа равно времени свёртки.
## Оракул: измерено
`WriteTimeout` в Go ставится в `readRequest`**до** чтения тела и до вызова `WriteTimeout` в Go ставится в `readRequest`**до** чтения тела и до вызова
обработчика (`net/http/server.go:993-997`, прочитано в исходниках). Значит обработчика (`net/http/server.go:993-997`, прочитано в исходниках). Значит
@@ -46,28 +44,39 @@ client: elapsed=501ms err=EOF
экспорт, — то есть ровно по тем, ради которых заведён инвариант «дыры экспорт, — то есть ровно по тем, ради которых заведён инвариант «дыры
закрываются сами». закрываются сами».
## Варианты и цена ## Что решено
**(а) Отвечать `200` сразу после архивации и учёта; свёртка — воркером в Вариант (а): **отвечать `200` сразу после архивации и учёта; свёртка —
порядке журнала, с подбором `pending` при старте.** воркером в порядке журнала, с подбором `pending` при старте.**
Цена: средняя — воркер, очередь, подбор при старте. Бонусом закрываются ещё
две дыры: параллельные доставки одной автоматизации перестают гонять
наследование слоя (сейчас вторая может не найти слоя первой и уйти в
`failed`), и доставка, застрявшая в `pending` из-за сбоя записи, наконец
кем-то подбирается.
**(б) Поднять `write_timeout` до согласованного с `foldTimeout`.** Почему он, а не альтернативы:
Цена: малая. Но худший случай (64 МиБ) всё равно минуты, и молчание
`accessLog` остаётся.
**(в) Оставить как есть, задокументировав потолок размера доставки.** - Поднять `write_timeout` до согласованного с `foldTimeout` — дёшево, но
Цена: нулевая. Широкие проходы продолжают рваться. худший случай (64 МиБ) всё равно минуты, и молчание `accessLog` остаётся.
Это лечит симптом.
- Оставить как есть — широкие проходы продолжают рваться.
## Рекомендация Вариант (а) решает причину и попутно снимает две смежные дыры: параллельные
доставки одной автоматизации перестают гонять наследование слоя (сейчас вторая
может не найти слоя первой и уйти в `failed`), и доставка, застрявшая в
`pending` из-за сбоя записи, наконец кем-то подбирается.
**(а).** Единственный вариант, который решает причину, а не симптом, и попутно ## Что делать
снимает две смежные находки. Он же приближает `reindex`: подбор `pending` при
старте — его половина. 1. Воркер свёртки: одна горутина, очередь идентификаторов доставок, обработка
**строго в порядке журнала** (`received_at`, `id`) — от этого зависит
наследование слоя и воспроизводимость.
2. Приём отвечает `200` после архивации и вставки доставки; свёртку ставит в
очередь. Очередь переполнена — доставка остаётся `pending`, это не отказ.
3. Подбор `pending` при старте, тем же путём. Это половина `reindex`, поэтому
код должен быть общим с ним, а не соседним.
4. Остановка сервиса дожидается текущей доставки: свёртка — одна транзакция,
рвать её нечем, но очередь надо дренировать осознанно.
5. Метка «доставка ждала свёртки дольше N» — в наблюдаемость, чтобы отставание
воркера было видно до того, как оно станет отставанием на сутки.
6. Тесты: порядок журнала соблюдается при конкурентных доставках; `pending`
подбирается при старте; отмена контекста не оставляет половинчатого
состояния; `task verify:archive` даёт то же состояние.
## Что стоит без решения ## Что стоит без решения
@@ -75,6 +84,9 @@ client: elapsed=501ms err=EOF
широких доставках. Данные при этом не теряются — тело ложится в архив **до** широких доставках. Данные при этом не теряются — тело ложится в архив **до**
свёртки. свёртки.
Связано: [reindex-iz-arhiva](reindex-iz-arhiva.md), ## Связано
[stats-nablyudaemost](stats-nablyudaemost.md) — метка «ответ не уложился в
таймаут» должна попасть туда. - [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбор `pending` это её половина;
делать одним кодом.
- [stats-nablyudaemost](stats-nablyudaemost.md) — метка «ответ не уложился в
таймаут» и отставание воркера должны попасть туда.
+48 -41
View File
@@ -1,19 +1,14 @@
# Правило выбора победителя при столкновении точек # Полнота точки — по множеству ключей, а не по их числу
**Приоритет:** блокеры **Приоритет:** высокий
Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, находка №7 Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
триажа, severity major). Трогает записанный инвариант «при столкновении `deep`, находка №7 триажа, severity major). **Решение принято замером**
выигрывает более полная точка». (находка 49) — ниже задача на остаток.
## Что решить ## Что не так сегодня
Как выбирать победителя, когда по одним координатам приехали разные Полнота — это **счётчик** ключей, чьё значение не `null`, не `""` и не пусто.
содержимые. Сегодняшнее правило измеримо неверно в двух местах.
## Оракул: измерено
**Полнота — это счётчик ключей**, чьё значение не `null`, не `""` и не пусто.
Поэтому `{}`, `[]`, `0` и `false` считаются содержательными: Поэтому `{}`, `[]`, `0` и `false` считаются содержательными:
``` ```
@@ -24,42 +19,54 @@
Вторая точка — настоящее измерение — проигрывает первой и стирается Вторая точка — настоящее измерение — проигрывает первой и стирается
безвозвратно. Восстановить можно только из сырого архива, пока он жив. безвозвратно. Восстановить можно только из сырого архива, пока он жив.
**Тай-брейк при равной полноте** — лексикографический порядок канонических ## Что решено и на каком основании
форм, то есть исход зависит от самого значения. Для накопительной метрики,
которую досчитывают задним числом, это систематическая победа **меньшего**
числа: `qty:1` бьёт `qty:100`. Недосчёт, неотличимый от нормы.
Частота на живом потоке **не измерена** — стоит прогнать по архиву до решения. Замер по всем 99 доставкам (находка 49): настоящих столкновений 2 897 из
444 256 координат (0.65%), и среди них **ноль несравнимых наборов полей**.
## Варианты и цена Отсюда:
**(1) Полнота по множеству ключей: побеждает надмножество; несравнимые - **Полнота — сравнение множеств ключей, побеждает надмножество.** Ровно 981
множества — объединять поля, а не выбирать точку целиком.** случай из замера, и там правило уже работает верно — но работает случайно,
Цена: средняя — правка спеки, `resolve` и тестов. Самое честное прочтение через счётчик, который на другом входе даст обратное.
инварианта «ничего не теряем молча»: при несравнимых наборах не теряется - **Объединение полей при несравнимых наборах не нужно.** Самая дорогая часть
ничего вообще. обсуждавшегося варианта (1) на живом потоке не срабатывает ни разу. Вместо
реализации — счётчик: несравнимый набор считается и логируется `WARN`. Если
событие когда-нибудь наступит, оно будет видно, а не додумано заранее.
- **Тай-брейк при равной полноте в эту задачу не входит.** Обоснование ниже.
**(2) Оставить счёт ключей, но не считать содержательными `{}`, `[]`, `0`, ## Что делать
`false`, `""`.**
Цена: малая. Правило остаётся играбельным (точку с лишними непустыми полями
никто не мешает прислать), и произвол тай-брейка не решён.
**(3) При равной полноте побеждает точка более поздней доставки по 1. `resolve` в `internal/store/bucket.go`: полнота — множество ключей с
`received_at`.** непустым значением; побеждает строгое надмножество.
Цена: малая, но спека должна признать зависимость от журнала, и нужен 2. Несравнимые множества — счётчик в `MergeStats` плюс `WARN` с координатами
детерминированный порядок **внутри** одной доставки — а именно там измерены объекта (без значений). Точку выбирает тот же тай-брейк, что и при равной
столкновения (31 координата в 21 доставке из 89). полноте.
3. Дельта-спека `openspec/specs/storage` → «Разрешение столкновений по
полноте»: переформулировать с числа ключей на множество.
4. Тесты: «бедная точка с пятью пустыми полями не стирает измерение»
(регрессия на приведённой выше паре), «надмножество побеждает»,
«несравнимые наборы считаются».
5. `task verify:archive` обязан дать то же состояние — правило меняет исход
только там, где сегодня он неверен.
## Рекомендация ## Что отложено и почему
**(1).** Объединение полей при несравнимых наборах — единственный вариант, при **Тай-брейк при равной полноте.** Сегодня это лексикографический порядок
котором столкновение вообще перестаёт быть выбором «кого потерять». канонических форм. Измерено (находка 49): в 1 847 случаях из 1 912 он выбирает
**меньшее** значение — 96%. Четыре из шести затронутых метрик накопительные,
там это систематический недосчёт; но крупнейшая группа, `heart_rate` (985
случаев), мгновенная, и там «большее» не правильнее.
То есть правильный тай-брейк зависит от **рода метрики**, а род по замыслу
проекта измеряется сверкой слоёв между собой. Выбирать его сейчас — угадывать
ровно то, что станет известно точно.
Зависимость: [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md). Тай-брейк
доделывается **после** неё, отдельной задачей.
## Что стоит без решения ## Что стоит без решения
Правило работает и теперь **наблюдаемо**: столкновение считается расхождением Ничего. Правило работает и **наблюдаемо**: столкновение считается расхождением
канонических форм (а не байтов, как было), даёт `WARN` с координатами объекта канонических форм (а не байтов), даёт `WARN` с координатами объекта и счётчик
и счётчик `overwrites`. Если правило начнёт терять — это станет видно в логе, `overwrites`.
а не через месяцы при сверке с экспортом Apple.
Связано: `openspec/specs/storage` → «Разрешение столкновений по полноте».
+6
View File
@@ -17,5 +17,11 @@ HAE. Он не сэмплы, а посекундная развёртка (на
Готово, когда каталог отдаёт по каждой метрике единицы, род и список слоёв с Готово, когда каталог отдаёт по каждой метрике единицы, род и список слоёв с
диапазонами, а род проставлен измерением на живой истории. диапазонами, а род проставлен измерением на живой истории.
От этой задачи зависит ещё одно решение: тай-брейк при равной полноте точек.
Измерено (находка 49), что сегодняшний лексикографический порядок берёт меньшее
значение в 96% случаев — для накопительных это недосчёт, для мгновенных
безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк
доделывается по нему — см. [pravilo-sliyaniya-tochek](pravilo-sliyaniya-tochek.md).
Связано: `docs/architecture.md` → «Слои гранулярности», план шаг 4. Связано: `docs/architecture.md` → «Слои гранулярности», план шаг 4.
+72
View File
@@ -1541,6 +1541,78 @@ type sourceName sourceVersion creationDate startDate endDate value
То есть обе распространённые схемы хранения теряют данные ровно там, где мы это То есть обе распространённые схемы хранения теряют данные ровно там, где мы это
измерили, а единственная принятая схема дедупликации — это `start`+`end`. измерили, а единственная принятая схема дедупликации — это `start`+`end`.
## 48. Единицы метрики на живом потоке не менялись ни разу
Замер по всем 99 доставкам архива: **30 различных метрик, ни у одной единицы не
менялись**. Ни между доставками, ни внутри одной.
Это снимает основание под гипотезой «единицы — часть координаты объекта»: разряд
`метрика + слой + единицы + час` защищал бы от события, которого поток не
производит. Сегодняшнее поведение (сохранённые единицы побеждают, расхождение —
`MergeStats.UnitsConflicts` и `WARN`) правильно ровно тем, что превращает
гипотезу в **наблюдаемое** событие: если единицы когда-нибудь поедут, это будет
видно в логе в тот же день, а не через квартал при сверке с экспортом.
Оговорка, которая остаётся верной и с этим решением: единицы не входят в хеш
содержимого, поэтому доставка с теми же точками и другими единицами уходит по
ветке «ничего не изменилось». При правиле «сохранённое побеждает» это не
расхождение, а то же самое правило, — но если правило когда-нибудь поменяют,
хеш придётся менять вместе с ним.
## 49. Столкновений 0.65%, несравнимых наборов полей нет, тай-брейк системно берёт меньшее
Замер по всем 99 доставкам, ключ — реальный (`метрика + слой + начало + конец`),
сравнение — по канонической форме с округлением до 12 значащих цифр
(находка 30).
| что мерялось | столкновений | из 444 256 |
|---|---|---|
| без слоя в ключе | 53 678 | 12.29% |
| со слоем, побайтово | 47 235 | 10.63% |
| **со слоем, после канонизации** | **2 897** | **0.65%** |
Первая строка меряет не то: без слоя часовая точка сталкивается с минутной, и
это не столкновение, а два разных ряда. Разница между второй и третьей —
дребезг последних разрядов `float64`, то есть **94% побайтовых расхождений
канонизация схлопывает**. Это же и есть независимое подтверждение находки 30:
округление до 12 цифр выбрано верно.
Разложение настоящих 2 897:
| случай | сколько | что делает правило |
|---|---|---|
| наборы полей **сравнимы** (одно надмножество другого) | 981 | правило полноты работает верно |
| наборы полей **равны**, значения разные | 1 916 | срабатывает тай-брейк |
| наборы полей **несравнимы** | **0** | не встречается вовсе |
**Ноль несравнимых наборов** — важный результат: объединение полей при
несравнимых наборах, самая дорогая часть обсуждавшегося правила слияния, на
живом потоке не срабатывает ни разу. Правило полноты сводится к «надмножество
побеждает», и это не упрощение из лени, а измеренная форма данных.
А вот тай-брейк при равной полноте измеримо смещён. Там, где сравнение чисел
определено (1 912 случаев из 1 916), лексикографический порядок канонических
форм выбирает **меньшее значение в 1 847 случаях — 96%**:
```
heart_rate 985
walking_running_distance 562
step_count 509
active_energy 409
basal_energy_burned 385
apple_stand_time 14
```
Четыре метрики из шести — накопительные, которые HAE досчитывает задним числом
(находка 10): там «меньшее» это систематический недосчёт, порядка 0.4%
координат. Но самая крупная группа, `heart_rate`, — мгновенная, и там «большее»
не правильнее, там просто пересэмплирование.
Отсюда вывод, которого не было до замера: **правильный тай-брейк зависит от
рода метрики**, а род по замыслу проекта измеряется сверкой слоёв между собой.
Значит выбирать тай-брейк до каталога рода агрегации — значит угадывать ровно
то, что через задачу станет известно точно.
## Инструмент ## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная