diff --git a/docs/backlog/CLOSED.md b/docs/backlog/CLOSED.md index 9b62a66..66adb54 100644 --- a/docs/backlog/CLOSED.md +++ b/docs/backlog/CLOSED.md @@ -5,3 +5,4 @@ - 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 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Был приоритет: блокеры. diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 8c80eac..eb907bb 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -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) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика @@ -24,6 +20,9 @@ - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [OpenAPI-спека и Swagger UI](openapi-swagger.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 их не сверить diff --git a/docs/backlog/edinicy-metriki-v-razreze.md b/docs/backlog/edinicy-metriki-v-razreze.md deleted file mode 100644 index 3ce40c9..0000000 --- a/docs/backlog/edinicy-metriki-v-razreze.md +++ /dev/null @@ -1,53 +0,0 @@ -# Единицы метрики: часть координаты или свойство объекта - -**Приоритет:** блокеры - -Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, найдено -тремя проходами независимо). - -## Что решить - -Единицы измерения живут колонкой объекта — одна на все точки часа. Что делать, -когда в тот же час приезжают точки в **других** единицах. - -## Почему это не мелочь - -Внутри точки единиц нет: проверено на 89 доставках, поле `units` не -встретилось ни разу, оно живёт только на уровне метрики. Значит у точки, -сохранённой раньше, не остаётся **ничего**, по чему её единицы восстановимы — -кроме сырого архива, пока он жив. - -Смена реальна и не требует злого умысла: переключатель единиц в настройках -HAE, смена локали телефона, переименование единицы в новой версии приложения. - -## Варианты и цена - -**(1) Единицы — часть координаты объекта** (`метрика + слой + единицы + час`). -Цена: миграция, ключ шире, каталог разрезов обязан показывать единицы. Зато -точки никогда не подписаны чужим — разные единицы просто разные ряды. - -**(2) Первое непустое побеждает, расхождение — `WARN` и счётчик** (сделано -сейчас как безопасное умолчание). -Цена: нулевая, уже работает. Но час, начавшийся в километрах, останется -километровым навсегда, даже если телефон окончательно переехал на мили. - -**(3) Хранить обе величины у объекта** (`units` и `units_seen`). -Цена: малая, но это откладывание решения: читателю всё равно придётся -выбирать. - -## Рекомендация - -**(1)**, если единицы вообще могут меняться на живом потоке — а они могут. -Сегодняшний (2) безопасен, но оставляет систематическую ложь в подписи. - -## Что стоит без решения - -Ничего не теряется: сохранённые единицы больше **не перезаписываются** молча -(было — пришедшие всегда побеждали), расхождение считается в -`MergeStats.UnitsConflicts` и даёт `WARN` в свёртке. - -Отдельная тонкость, которую надо закрыть тем же решением: единицы не входят в -хеш содержимого, поэтому доставка с теми же точками и исправленными единицами -уходит по ветке «ничего не изменилось» и колонку не трогает. То есть единицы -сегодня обновляются тогда и только тогда, когда изменилось содержимое, — -правило, которого никто не формулировал. diff --git a/docs/backlog/nerazobrannye-sekcii-dostavki.md b/docs/backlog/nerazobrannye-sekcii-dostavki.md index aff11da..c6441cc 100644 --- a/docs/backlog/nerazobrannye-sekcii-dostavki.md +++ b/docs/backlog/nerazobrannye-sekcii-dostavki.md @@ -1,58 +1,60 @@ -# Судьба доставки, у которой разобрана не вся секция data +# Непокрытые секции доставки видны в статусе разбора -**Приоритет:** блокеры +**Приоритет:** высокий -Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, проход -негативного пространства). **Решить до реализации ретеншена.** +Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль +`deep`, проход негативного пространства). **Решение принято** — ниже задача. -## Что решить +## Что не так сегодня Разбор читает только `data.metrics`. Доставка, состоящая из `workouts`, `stateOfMind`, `symptoms` или `ecg`, помечается `parse_status=parsed` с нулём точек — неотличимо от доставки с пустой секцией метрик. -## Почему это блокер, а не задача +Само по себе некритично: секции пока не разбираются сознательно, тела лежат в +архиве. Опасность в **сцепке с ретеншеном**. Ретеншен по замыслу срезает архив +до следующего проверенного экспорта. Если он будет ориентироваться на +`parse_status`, он снесёт тела, которые числятся разобранными, — а для +`stateOfMind` это необратимо: **в экспорте Apple его нет** (находка 46), +доставки HAE для него единственный источник. -Само по себе это некритично: секции пока не разбираются сознательно, тела -лежат в архиве. Опасность в **сцепке с ретеншеном**. +Цена ошибки здесь не «придётся пересобрать», а «истории состояния разума больше +не существует». -Ретеншен по замыслу срезает архив до следующего проверенного экспорта. Если он -будет ориентироваться на `parse_status`, он снесёт тела, которые числятся -разобранными, — а для `stateOfMind` это необратимо: **в экспорте Apple его -нет** (находка 46), доставки HAE для него единственный источник. +## Что решено -То есть цена ошибки здесь не «придётся пересобрать», а «истории состояния -разума больше не существует». +Вариант (1): **разбор возвращает список верхнеуровневых ключей `data`, которые +он не покрыл; статус доставки — `partial`.** -## Варианты и цена +Почему он, а не альтернативы: -**(1) Разбор возвращает список верхнеуровневых ключей `data`, которые он не -покрыл; статус `partial`.** -Цена: малая — один `json.Decoder` по верхнему уровню, без чтения содержимого. -Ретеншен и `reindex` получают честный признак, а в логе появляется момент, -когда поток принёс новую секцию (ровно то, ради чего заведена задача -`proverka-novyh-sekcij`). +- Считать `parsed` только полностью разобранную доставку, а остальные держать + в `pending` — дёшево, но `pending` перестаёт означать «ещё не смотрели», и + подбор зависших доставок теряет свой признак. А подбор `pending` как раз + появляется задачей [otvet-i-svyortka](otvet-i-svyortka.md). +- Запретить ретеншену смотреть на `parse_status` — нулевая цена сейчас, но + ретеншен становится тупым и не защищает от «тело разобрано неверно, а мы его + уже срезали». -**(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) — тот же признак закрыл -бы и её. +- [retenshen-syrogo-arhiva](retenshen-syrogo-arhiva.md) — решить **до** неё. +- [proverka-novyh-sekcij](proverka-novyh-sekcij.md) — тот же признак закрывает + и её: момент появления новой секции становится событием в логе. +- [trenirovki-i-zapisi](trenirovki-i-zapisi.md) — по мере разбора секций список + непокрытых сокращается сам. diff --git a/docs/backlog/otvet-i-svyortka.md b/docs/backlog/otvet-i-svyortka.md index 27341dc..3a8eed1 100644 --- a/docs/backlog/otvet-i-svyortka.md +++ b/docs/backlog/otvet-i-svyortka.md @@ -1,16 +1,14 @@ # Разнести ответ приёма и свёртку доставки -**Приоритет:** блокеры +**Приоритет:** высокий -Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, находка №4 -триажа, severity major). +Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль +`deep`, находка №4 триажа, severity major). **Решение принято** — ниже задача. -## Что решить +## Что не так сегодня Свёртка выполняется **синхронно внутри обработчика запроса**, поэтому время -ответа равно времени свёртки. Вопрос: разносить ли их, и какой ценой. - -## Оракул: измерено +ответа равно времени свёртки. `WriteTimeout` в Go ставится в `readRequest` — **до** чтения тела и до вызова обработчика (`net/http/server.go:993-997`, прочитано в исходниках). Значит @@ -46,28 +44,39 @@ client: elapsed=501ms err=EOF экспорт, — то есть ровно по тем, ради которых заведён инвариант «дыры закрываются сами». -## Варианты и цена +## Что решено -**(а) Отвечать `200` сразу после архивации и учёта; свёртка — воркером в -порядке журнала, с подбором `pending` при старте.** -Цена: средняя — воркер, очередь, подбор при старте. Бонусом закрываются ещё -две дыры: параллельные доставки одной автоматизации перестают гонять -наследование слоя (сейчас вторая может не найти слоя первой и уйти в -`failed`), и доставка, застрявшая в `pending` из-за сбоя записи, наконец -кем-то подбирается. +Вариант (а): **отвечать `200` сразу после архивации и учёта; свёртка — +воркером в порядке журнала, с подбором `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) — метка «ответ не уложился в + таймаут» и отставание воркера должны попасть туда. diff --git a/docs/backlog/pravilo-sliyaniya-tochek.md b/docs/backlog/pravilo-sliyaniya-tochek.md index a1e52bc..1e188fa 100644 --- a/docs/backlog/pravilo-sliyaniya-tochek.md +++ b/docs/backlog/pravilo-sliyaniya-tochek.md @@ -1,19 +1,14 @@ -# Правило выбора победителя при столкновении точек +# Полнота точки — по множеству ключей, а не по их числу -**Приоритет:** блокеры +**Приоритет:** высокий -Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, находка №7 -триажа, severity major). Трогает записанный инвариант «при столкновении -выигрывает более полная точка». +Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль +`deep`, находка №7 триажа, severity major). **Решение принято замером** +(находка 49) — ниже задача на остаток. -## Что решить +## Что не так сегодня -Как выбирать победителя, когда по одним координатам приехали разные -содержимые. Сегодняшнее правило измеримо неверно в двух местах. - -## Оракул: измерено - -**Полнота — это счётчик ключей**, чьё значение не `null`, не `""` и не пусто. +Полнота — это **счётчик** ключей, чьё значение не `null`, не `""` и не пусто. Поэтому `{}`, `[]`, `0` и `false` считаются содержательными: ``` @@ -24,42 +19,54 @@ Вторая точка — настоящее измерение — проигрывает первой и стирается безвозвратно. Восстановить можно только из сырого архива, пока он жив. -**Тай-брейк при равной полноте** — лексикографический порядок канонических -форм, то есть исход зависит от самого значения. Для накопительной метрики, -которую досчитывают задним числом, это систематическая победа **меньшего** -числа: `qty:1` бьёт `qty:100`. Недосчёт, неотличимый от нормы. +## Что решено и на каком основании -Частота на живом потоке **не измерена** — стоит прогнать по архиву до решения. +Замер по всем 99 доставкам (находка 49): настоящих столкновений 2 897 из +444 256 координат (0.65%), и среди них **ноль несравнимых наборов полей**. -## Варианты и цена +Отсюда: -**(1) Полнота по множеству ключей: побеждает надмножество; несравнимые -множества — объединять поля, а не выбирать точку целиком.** -Цена: средняя — правка спеки, `resolve` и тестов. Самое честное прочтение -инварианта «ничего не теряем молча»: при несравнимых наборах не теряется -ничего вообще. +- **Полнота — сравнение множеств ключей, побеждает надмножество.** Ровно 981 + случай из замера, и там правило уже работает верно — но работает случайно, + через счётчик, который на другом входе даст обратное. +- **Объединение полей при несравнимых наборах не нужно.** Самая дорогая часть + обсуждавшегося варианта (1) на живом потоке не срабатывает ни разу. Вместо + реализации — счётчик: несравнимый набор считается и логируется `WARN`. Если + событие когда-нибудь наступит, оно будет видно, а не додумано заранее. +- **Тай-брейк при равной полноте в эту задачу не входит.** Обоснование ниже. -**(2) Оставить счёт ключей, но не считать содержательными `{}`, `[]`, `0`, -`false`, `""`.** -Цена: малая. Правило остаётся играбельным (точку с лишними непустыми полями -никто не мешает прислать), и произвол тай-брейка не решён. +## Что делать -**(3) При равной полноте побеждает точка более поздней доставки по -`received_at`.** -Цена: малая, но спека должна признать зависимость от журнала, и нужен -детерминированный порядок **внутри** одной доставки — а именно там измерены -столкновения (31 координата в 21 доставке из 89). +1. `resolve` в `internal/store/bucket.go`: полнота — множество ключей с + непустым значением; побеждает строгое надмножество. +2. Несравнимые множества — счётчик в `MergeStats` плюс `WARN` с координатами + объекта (без значений). Точку выбирает тот же тай-брейк, что и при равной + полноте. +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` с координатами объекта -и счётчик `overwrites`. Если правило начнёт терять — это станет видно в логе, -а не через месяцы при сверке с экспортом Apple. - -Связано: `openspec/specs/storage` → «Разрешение столкновений по полноте». +Ничего. Правило работает и **наблюдаемо**: столкновение считается расхождением +канонических форм (а не байтов), даёт `WARN` с координатами объекта и счётчик +`overwrites`. diff --git a/docs/backlog/rod-agregacii-i-katalog.md b/docs/backlog/rod-agregacii-i-katalog.md index f11979a..7e25d39 100644 --- a/docs/backlog/rod-agregacii-i-katalog.md +++ b/docs/backlog/rod-agregacii-i-katalog.md @@ -17,5 +17,11 @@ HAE. Он не сэмплы, а посекундная развёртка (на Готово, когда каталог отдаёт по каждой метрике единицы, род и список слоёв с диапазонами, а род проставлен измерением на живой истории. +От этой задачи зависит ещё одно решение: тай-брейк при равной полноте точек. +Измерено (находка 49), что сегодняшний лексикографический порядок берёт меньшее +значение в 96% случаев — для накопительных это недосчёт, для мгновенных +безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк +доделывается по нему — см. [pravilo-sliyaniya-tochek](pravilo-sliyaniya-tochek.md). + Связано: `docs/architecture.md` → «Слои гранулярности», план шаг 4. diff --git a/docs/local-research.md b/docs/local-research.md index e918e04..17b570d 100644 --- a/docs/local-research.md +++ b/docs/local-research.md @@ -1541,6 +1541,78 @@ type sourceName sourceVersion creationDate startDate endDate value То есть обе распространённые схемы хранения теряют данные ровно там, где мы это измерили, а единственная принятая схема дедупликации — это `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, только стандартная