Каталог разрезов и измеренный род агрегации

- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
This commit is contained in:
av
2026-08-02 19:23:59 +03:00
parent 98e0772ec5
commit 03edf1087d
39 changed files with 4744 additions and 58 deletions
+167 -7
View File
@@ -215,6 +215,7 @@ HRV); у накопительных — только `date`. Поэтому то
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
| `fold` | свёртка одной доставки в часовые объекты |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
| `catalog` | каталог разрезов и измерение рода агрегации |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
| `httpapi` | приём и read API |
@@ -616,7 +617,8 @@ hour метки выровнены на час heart_rate 00:00:00
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
агрегация по метрике не предлагается вовсе.
агрегация по метрике не предлагается вовсе. Правило целиком — ниже, «Измерение
рода агрегации».
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
@@ -798,6 +800,140 @@ hour метки выровнены на час heart_rate 00:00:00
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
значит он и станет известен точно, вместо того чтобы быть угаданным.
### Измерение рода агрегации
Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой
минутного слоя с часовым. Правило целиком:
```
час пригоден, если
у метрики есть объекты обоих слоёв за этот час
час не позже текущего времени плюс час
единицы обоих объектов совпадают
часовой объект несёт ровно одну точку, и она несёт значение
метка этой точки совпадает с началом часа
у минутного объекта не меньше двух точек со значением
сумма минутных отличима от их среднего
вердикт пригодного часа
часовое ≈ сумма минутных → cumulative
часовое ≈ среднее минутных → instant
иначе → свидетельства нет
вердикт метрики
≥3 согласных часа и ни одного противоречащего → род
иначе → unknown
```
Измерено на живом архиве (123 доставки, 31 метрика): 7 накопительных,
9 мгновенных, 15 неизвестных, **противоречащих часов ноль**. Каталог собирается
за 68 мс.
**Горизонт обязателен, и это условие корректности, а не защита от вредителя.**
Час объекта берётся из метки в теле доставки, а тело не наше: без верхней
границы одна доставка с метками в будущем занимает окно целиком и подменяет
измеренный род — путь построен и прогнан, мгновенная метрика объявлялась
накопительной при нуле противоречащих часов. Данные, помеченные будущим, пишутся
`WARN`: сбитые часы телефона и чужое тело в приёме лечатся не кодом.
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
минутным слоем и в `count/hour` часовым даёт в полном часе
`часовое = 60 · среднее = сумма`, то есть уверенный ложный `cumulative`.
Единогласие такого случая не ловит: противоречия нет, есть молчание.
Каждая часть правила стоит своей причины.
**Различимость суммы и среднего — не украшение.** В часе, где все значения нули,
сумма равна среднему, и «сходится с суммой» выполняется тождественно: без этого
условия `walking_asymmetry_percentage` давала 4 часа «накопительная» против
3 «мгновенная», причём конфликт целиком состоял из нулевых часов.
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
который покрывают минутные точки того же объекта.
**Единогласие, а не большинство.** Противоречащий час означает, что одна из
гипотез для метрики ложна; большинство голосов объявляло бы род при известном
контрпримере. Измеренная цена — ноль. Наличие противоречащих часов пишется
`WARN`: род — свойство, на котором Read API строит арифметику года.
**Порог в три часа** — потому что один совпавший час остаётся свидетельством
одного часа. Цена измерена: порог уводит в `unknown` метрики с единственным
согласным часом.
**Допуск сравнения — относительный, `1e-9`, и один на все три сравнения.**
Разные допуски у «сходимости» и «различимости» породили бы час, подтверждающий
обе гипотезы, и его исход определил бы порядок веток кода. Величина названа
числом, потому что от неё зависят счётчики основания в ответе: вердикты
одинаковы при допуске от `1e-9` до `1e-3`, а число согласных часов у
`heart_rate` при этом меняется вдвое. Абсолютного порога нет: около нуля
относительное сравнение вырождается в сторону «не сходится», то есть даёт
«свидетельства нет», а не ложный род.
**Родов два, а не четыре.** HealthKit различает `cumulative`,
`discreteArithmetic`, `discreteTemporallyWeighted` (пульс) и
`discreteEquivalentContinuousLevel` (аудиоэкспозиция). Взять весь словарь
напрашивалось и отвергнуто измерением: часовой слой HAE считается
арифметически, а не по Apple. Прямое свидетельство — `environmental_audio_exposure`,
которую Apple усредняет логарифмически: её часовое значение сходится с обычным
арифметическим средним минутных. Стили, которые в наших данных ничем не
проявляются, можно было бы только разметить руками — то есть вернуться к тому,
от чего уходит вся конструкция.
**Род нигде не хранится**, а считается на запрос по окну в 48 самых свежих
общих часов. Хранимое значение было бы вторым производным состоянием рядом с
витриной: его пришлось бы пересчитывать после каждой свёртки, вносить в перечень
непереносимого пересборкой и объяснять, на каком составе данных оно снято, —
причём устаревшее выглядело бы ровно как свежее. Вычисленный на запрос род есть
функция витрины, а витрина — функция журнала.
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
может сменить объявленный род без единой новой доставки за спрошенный период.
Поэтому каталог отдаёт род **вместе с основанием** — сколько часов сравнено,
сколько пригодно, сколько согласны и противоречат, на каких границах окна.
**Второй предел названный вслух: окно измеряется в общих часах, а не в часах
календаря.** Выключи минутную автоматизацию — множество общих часов перестаёт
пополняться, и окно замирает на последних сорока восьми, когда она ещё работала.
Род продолжает объявляться, и единственный след этого — `last_hour` в ответе.
Календарного ограничения нет намеренно: оно уводило бы в `unknown` редкие
метрики, у которых общие часы копятся месяцами, — то есть лечило бы честный
случай ценой другого честного.
#### Как это решают другие и почему не подошло
Prior art здесь обширный, и весь он про **объявление** рода, а не про измерение.
- **HealthKit** зашивает `HKQuantityAggregationStyle` в тип метрики, а
`HKStatistics` возвращает `nil` на свёртку, не отвечающую стилю. Второе взято
как принцип («род не тот — свёртки нет»), первое неприменимо: HAE тип не шлёт.
- **Home Assistant** получает `state_class` от интеграции и при его смене
требует **удалить** долгосрочную статистику вручную. Взято признание, что
смена рода — событие, а не уточнение поля; отвергнуто объявление: объявить
некому.
- **Graphite** выводит `aggregationMethod` регуляркой по имени метрики.
Отвергнуто: противоречит инварианту «форма Apple не транслируется» и не
работает на именах HAE вовсе.
- **Prometheus и остальные** принимают тип от отправителя; заголовок HAE врёт
уже про слой, оснований верить ему про род нет. Детекция сброса счётчика
(`rate`, `total_increasing`) отвечает на другой вопрос — «был ли рестарт у
известного счётчика», — и к данным Apple неприменима: монотонного накопителя в
них нет.
- **`xFilesFactor`** (Graphite) и **`xff`** (RRDtool) — доля заполненности, ниже
которой свёртка не делается. Измерению порог не нужен: у него две
конкурирующие гипотезы, и неполный час не сходится ни с одной сам собой (у
`step_count` 41 час пригоден и 24 дали вердикт — остальные и есть неполные).
Свёртке в ответе порог понадобится, и вместе с ним выбор полярности: Graphite
задаёт долю **обязательно известных** (0.5 при роллапе и 0 при рендере),
RRDtool — долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины
выглядят как «0.5», означая противоположное. Решение принимает задача Read API.
Готовой практики вывода рода **из данных** не нашлось ни одной: у всех
перечисленных есть привилегия, которой нет у нас — поставщик объявляет тип на
входе, — и все за неё платят (Prometheus теряет тип на remote write, Home
Assistant требует ручного удаления статистики). Мы платим измерением.
### Категориальные значения
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
@@ -1085,15 +1221,38 @@ GET /healthz
за какой период:
```json
{"metric": "heart_rate", "units": "count/min", "aggregation": "instant",
{"metric": "heart_rate", "units": ["count/min"],
"aggregation": {"style": "instant", "hours": 48, "compared": 48,
"agreeing": 20, "conflicting": 0,
"first_hour": "2026-07-31T09:00:00Z",
"last_hour": "2026-08-02T14:00:00Z"},
"layers": [
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078},
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203}
{"layer": "minute", "from": "2026-07-25T00:01:00Z", "to": "2026-08-01T23:59:00Z", "points": 14203},
{"layer": "raw", "from": "2026-07-30T00:00:07Z", "to": "2026-08-01T23:59:58Z", "points": 2078}
]}
```
`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него
зависит, что вообще можно спросить.
`aggregation.style` — измеренный род (`cumulative` / `instant` / `unknown`), от
него зависит, что вообще можно спросить. Рядом лежит **основание**: сколько
общих часов попало в окно, сколько из них оказалось пригодными, сколько дали
преобладающий вердикт и сколько противоречили. Одного числа не хватало —
«часов было 48, а пригодным не оказалось ни одного» и «часов не было вовсе»
разные события, и различать их клиент обязан без второго запроса. Поле названо
`style`, а не `kind`: `kind` в проекте уже занят родом секции записи.
`units` — множество: единицы на живом потоке не менялись ни разу (находка 48),
но одна форма поля для обоих случаев честнее строки, которая при расхождении
молча выберет одно из двух. На слой при этом приходится ровно один элемент
`layers`.
Границы слоя — метки **первой и последней точки**, включительно; `first_hour` и
`last_hour`**ярлыки часов** окна измерения. Имена разные потому, что разная
семантика: одно имя для двух смыслов в одном ответе стоило бы клиенту ошибки на
час, заметной только расхождением сумм.
Границы слоя — это границы **данных, а не обещание покрытия**: внутри диапазона
законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты
запрошенного диапазона, а не на каталожную пару границ.
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
@@ -1126,7 +1285,8 @@ GET /healthz
Свёртка применяет род из каталога: `cumulative` — сумма, `instant`
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются
`bucket` отвергается ошибкой. Порог заполненности ведра (`xFilesFactor`) и его
полярность выбирает эта же задача — см. «Измерение рода агрегации». Накопительные метрики никогда не сворачиваются
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
### Форма ответа
+3 -1
View File
@@ -18,9 +18,10 @@
либо берётся, либо отвергается с названной причиной.
## блокеры
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Сегодняшний порядок канонических форм берёт меньшее значение в 96% случаев — для накопительных это систематический недосчёт
- [Цена первого читающего маршрута: память, WAL и повторный опрос](cena-chitayushchego-marshruta.md) — Один запрос каталога способен выесть память процесса и раздуть WAL — а OOM здесь стоит доставок, которых телефон не перешлёт
## высокий
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
@@ -47,6 +48,7 @@
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [Остановка и миграция: раздельные бюджеты и следы в логе](ostanovka-i-migraciya-sledy.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
## низкий
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
@@ -0,0 +1,83 @@
# Цена первого читающего маршрута: память, WAL и повторный опрос
**Приоритет:** блокеры
## Что решить
Чем ограничить стоимость маршрута чтения, у которого нет ни предела ответа, ни
собственного дедлайна, ни условного запроса. Вопрос поднялся на каталоге
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`), но
принадлежит не ему: тот же ответ понадобится Read API точек и MCP, и решать его
трижды нельзя.
Три измеренных проявления одной причины.
**Память.** Снимок каталога держит разжатые точки окна по всем метрикам сразу,
хотя измерение идёт по одной метрике. Замер враждебного прохода ревью: 20 метрик
× 8 часов × 5000 точек — 693 мс и +153 МиБ живой кучи на один запрос.
Предварительный отбор по учётным колонкам (сделан) снял разжатие заведомо
непригодных часов, но множители «метрики × окно × точки × одновременные запросы»
остались без потолка. Приём живёт в том же процессе и уже даёт пик 768 МиБ на
теле 40 МиБ; OOM убивает приём, а доставка, не попавшая в архив, телефоном не
переприсылается.
**WAL.** Замер эксплуатационного прохода на копии с драйвером и PRAGMA проекта:
непрерывная запись плюс четыре читающих транзакции внахлёст дают рост `-wal`
около 7 МБ/с без верхней границы (40 МБ за пять секунд), тогда как тот же
писатель без читателей стабилизируется на 4 МБ. Пассивный чекпойнт SQLite не
продвигается дальше снимка самого старого активного читателя, и ошибки при этом
нет — виден только растущий файл. `PRAGMA wal_checkpoint` в проекте не
вызывается нигде.
**Повторный опрос.** Спека каталога требует побайтового совпадения двух ответов
на неизменившейся витрине — то есть ресурс по построению пригоден для условного
запроса, а `ETag`/`304` не выставляется. Потребителей трое (агент-медик, трекер,
игра), и самый частый их запрос — повтор неизменившегося.
## Варианты и цена
**а. Предел и дедлайн у маршрута.** Потолок числа метрик и точек в одном ответе,
собственный `context.WithTimeout`, честный отказ при превышении. Цена: клиент
обязан уметь читать частичный каталог, то есть появляется пагинация — контракт
чтения усложняется на первой же ручке.
**б. Измерение потоком по метрике внутри той же транзакции.** Точки метрики
освобождаются сразу после вердикта; требование «один снимок» не нарушается. Цена:
хранилище перестаёт возвращать снимок значением и начинает отдавать его
последовательно (итератор или колбэк) — то есть меняется форма границы
`store`/`catalog`, ради случая, которого живой поток пока не производит.
**в. Условный запрос: `ETag` по `PRAGMA data_version`.** Снимает и стоимость
повтора, и большую часть читающих транзакций разом: клиент с непротухшим `ETag`
получает `304`, и снимок не открывается вовсе. Цена: один лишний запрос к базе на
каждый вызов и обещание клиенту, что версия витрины меняется не чаще, чем данные.
**г. Периодический `wal_checkpoint(PASSIVE)` по таймеру рядом с воркером.**
Лечит только WAL, зато дёшево и без изменения контракта. Память и повтор
остаются.
**д. Кеш ответа на короткий TTL.** Закрывает всё сразу, но заводит третье
представление того же факта, и его инвалидация становится новым местом, где можно
ошибиться молча. Дизайн каталога отверг кеш именно поэтому.
## Что заблокировано
Ничего сегодня: на живом корпусе каталог собирается за 45 мс, потребителей у него
пока нет, а маршрут живёт в доверенной сети. Блокировано будущее — Read API
точек, где объёмы на порядок больше, и выкладка наружу, где опрос станет
непрерывным.
## Рекомендация
**г + в, именно в таком порядке.** Чекпойнт по таймеру закрывает единственное
проявление, которое ломает приём (диск), и стоит одной горутины без изменения
контракта. `ETag` по `data_version` — один запрос к базе, снимает и повтор, и
большую часть читающих транзакций, и делает это без кеша ответа.
Вариант «а» откладывать до Read API точек: там предел размера ответа всё равно
проектируется (`read-api-tochki.md`), и делать его дважды не нужно. Вариант «б»
не брать, пока счётчик не заговорит: он меняет форму границы ради случая,
которого поток не производит. Вариант «д» — последним, если «в» окажется мало.
Связано: `docs/architecture.md` → «Измерение рода агрегации», `read-api-tochki.md`,
`stats-nablyudaemost.md`.
@@ -0,0 +1,41 @@
# Остановка и миграция: раздельные бюджеты и следы в логе
**Приоритет:** средний
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
существовали и раньше, но достижимыми их сделал первый маршрут чтения:
`GET /api/v1/metrics` — первый обработчик, способный законно работать заметное
время.
**Бюджет остановки один на оба этапа.** `shutdownCtx` в `runServe` передаётся и
в `srv.Shutdown`, и в ожидание фонового воркера. `Shutdown` ждёт, пока
обработчики вернутся; контексты обработчиков он при этом не отменяет
(`BaseContext` не задан), так что долгий запрос каталога может съесть бюджет
целиком. Дальше `select` видит два готовых случая и выбирает равновероятно: база
закрывается или нет от запуска к запуску, а в лог уходит
`shutdown budget exceeded stage=fold-worker` — обвинение воркеру, который бюджета
не превышал. Цена именно в диагнозе: этот `WARN` означает «доставка осталась
`pending`, данные под вопросом», и ложное срабатывание обесценивает настоящее.
Чинится двумя движениями: собственный `context.WithTimeout` второму этапу вместо
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
чтобы долгий запрос об остановке узнавал.
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
долго, владелец не отличит «ещё мигрирует» от «зависло» и от «упало»: тишина
одинакова во всех трёх случаях. Плюс `migrate` работает на `context.Background()`,
то есть `SIGTERM` она не видит и ждать придётся 30-секундного `SIGKILL`.
Порчи данных при этом нет: goose оборачивает миграцию в транзакцию, обрыв
откатывает её целиком, и следующий старт повторяет с нуля. Замер на синтетической
копии годового объёма (260 тысяч объектов, 483 МБ): `CREATE INDEX` миграции
`00009` — 297 мс тёплым кешем. То есть сегодня окно тишины — доли секунды;
опасность в том, что оно растёт вместе с витриной незаметно.
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
старта видно, что миграции накатывались и сколько это заняло.
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`,
`cena-chitayushchego-marshruta.md`.
+32 -1
View File
@@ -26,5 +26,36 @@
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
видно `layer`, `bucket` и `aggregation`.
Связано: `docs/architecture.md` → «Read API», план → шаг «Read API».
**Порог неполного ведра решается здесь, и вместе с ним — его полярность.**
Каталог и род агрегации сделаны (change `2026-08-02-katalog-i-rod-agregacii`), и
измерению порог заполненности не понадобился: у него две конкурирующие гипотезы,
и неполный час не сходится ни с одной сам собой. Свёртке в ответе он нужен, а
готовые решения задают его **противоположно**: Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере, один
параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
**Предел размера ответа тоже здесь.** У каталога его нет намеренно: правило
размера — общее для маршрутов чтения, и задавать его мимоходом на первой ручке
значило бы решить контракт до того, как известна форма тяжёлого ответа. Каталог
станет первым его потребителем.
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
типы `internal/catalog` сами несут json-теги, а транспорт владеет только
обёрткой: переименование поля в домене меняет публичный контракт без касания
`httpapi`. Держит это один байтовый тест непустого ответа. Либо объявить в
`architecture.md`, что типы чтения и есть форма провода для всех транспортов
(HTTP и MCP отдают её байт в байт), либо завести DTO в транспорте — но выбрать до
того, как образец скопирует эта задача.
**Клиент обязан смотреть на границы окна измерения.** Род метрики измерен по
48 самым свежим ОБЩИМ часам, а не по последним 48 часам календаря: если минутная
автоматизация HAE выключена, множество общих часов не пополняется и окно
замирает. Род при этом продолжает объявляться, и единственный след — `last_hour`
в ответе. Правило выбора свёртки в Read API обязано это учитывать (или явно
объявить, что не учитывает).
Связано: `docs/architecture.md` → «Read API», «Измерение рода агрегации»,
план → шаг «Read API».
-30
View File
@@ -1,30 +0,0 @@
# Измеренный род агрегации и каталог разрезов
**Приоритет:** высокий
Решено (вариант «б» груминга): свёртка живёт в ответе, но род метрики
**измеряется**, а не размечается руками. Форма точки рода не выдаёт —
`Avg`/`Min`/`Max` есть только у `heart_rate`, всё остальное приходит в `qty`
(находка 40). Единицы дают процентов девяносто и ломаются на краях.
Метод: одна метрика лежит в минутном и часовом разрезе одновременно. Часовое
значение сходится с суммой минутных — накопительная; со средним — мгновенная;
данных не хватило — `unknown`, и свёртка по такой метрике не предлагается вовсе.
Жёсткое правило: накопительные метрики никогда не сворачиваются из нижнего слоя
HAE. Он не сэмплы, а посекундная развёртка (находка 34), сумма по нему завышена.
Готово, когда каталог отдаёт по каждой метрике единицы, род и список слоёв с
диапазонами, а род проставлен измерением на живой истории.
От этой задачи зависит ещё одно решение: тай-брейк при равной полноте точек.
Измерено (находка 49), что сегодняшний лексикографический порядок берёт меньшее
значение в 96% случаев — для накопительных это недосчёт, для мгновенных
безразлично. Пока рода нет, выбирать нечем; когда каталог появится, тай-брейк
доделывается по нему. Остальное правило слияния уже сделано — структурная часть
закрыта задачей `pravilo-sliyaniya-tochek` (архив change
`2026-08-01-polnota-tochki-mnozhestvom-klyuchey`), здесь остался только выбор
победителя при РАВНОЙ полноте.
Связано: `docs/architecture.md` → «Слои гранулярности», план → шаг «Каталог и род агрегации».
+16
View File
@@ -32,3 +32,19 @@
Активное уведомление — отдельная задача, здесь только факт.
**Что добавил каталог рода агрегации.** Реальный сценарий поломки измерения — не
противоречие свидетельств (его на корпусе не бывает), а их исчезновение: владелец
переставил автоматизацию HAE, минутный слой перестал приходить, метрики одна за
другой уезжают в `unknown`, Read API перестаёт агрегировать — и в логах ноль
событий. Сюда же вторая половина: пять разных причин непригодности часа
(две точки у часового объекта, невыровненная метка, нет числа, мало минутных,
неразличимость) схлопнуты в одну разность `hours compared`, поэтому «HAE
переименовал поле точки» неотличимо от «данных мало». Оба сигнала естественно
живут в `/stats`: число метрик по родам и число метрик с `compared == 0` при
непустом окне.
**Корреляция у контура чтения.** В записи `http request` нет ни идентификатора
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
корреляция есть (`delivery_id`), у чтения аналога нет.
@@ -0,0 +1,76 @@
# Тай-брейк при равной полноте точек
**Приоритет:** блокеры
## Что решить
Какое правило выбирает победителя, когда по одним координатам приехали две точки
с **равными** наборами содержательных полей и разными значениями. Структурная
часть правила слияния закрыта (`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-journal.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.
+8
View File
@@ -7,6 +7,14 @@
правильно, но это же делает выезд наружу опасным: одна забытая настройка
открывает историю здоровья всему интернету.
**Контуров теперь два, а не один.** С появлением каталога
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`) заработал
токен чтения, и цена у контуров разная: открытый приём означает мусор во входе,
открытое чтение — выгрузку всей истории здоровья любому, кто нашёл порт. Сервис
предупреждает на старте обоими сообщениями (`write auth disabled`,
`read auth disabled`), образцы конфига цену называют комментарием — но отказа
старта нет, и это решение осталось здесь.
Решается перед деплоем, не раньше — так договорились.
Шаги:
+10
View File
@@ -103,6 +103,16 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и
лишний уровень косвенности через rowid ни разу не нужен.
Индекс `bucket_catalog` (`metric, layer, hour_utc, first_ts, last_ts, points,
units`) — **покрывающий**, и это следствие той же формы таблицы: у `WITHOUT
ROWID` строка целиком, вместе со сжатым `payload`, живёт в дереве первичного
ключа, поэтому агрегат «какие слои есть у метрики и за какой период» без индекса
тащил бы страницы содержимого — сотни мегабайт чтения на запрос каталога при
260 тысячах объектов за год. По нему же идёт поиск часов, за которые у метрики
есть объекты сразу в двух слоях. Цена — около 60 байт на объект и одна вставка в
дерево на запись; платит её только настоящее изменение, потому что при совпавшем
хеше объект не переписывается вовсе.
**Идентичность точки внутри объекта** — координаты
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
+61
View File
@@ -1725,6 +1725,67 @@ apple_stand_time 14
ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и
2 записи; повторное проигрывание дало тот же отпечаток.
## 53. Род агрегации измерен: 16 метрик из 31, противоречий ноль
Правило из находки 40 доведено до кода и прогнано на всём архиве (123 доставки,
31 метрика, витрина 2342 объекта). Сверка идёт по парам «минутный объект —
часовой объект за тот же час»; час участвует, только если у часового объекта
ровно одна точка со значением на границе часа, у минутного не меньше двух точек,
а сумма минутных отличима от их среднего.
| исход | метрик |
|---|---|
| `cumulative` | 7 |
| `instant` | 9 |
| `unknown` | 15 |
```
cumulative active_energy, basal_energy_burned, step_count,
walking_running_distance, apple_stand_time, apple_exercise_time,
time_in_daylight
instant heart_rate, respiratory_rate, blood_oxygen_saturation,
environmental_audio_exposure, walking_speed, walking_step_length,
walking_double_support_percentage, walking_asymmetry_percentage,
stair_speed_up
```
**Противоречащих часов ноль на всём корпусе** — ни у одной метрики свидетельства
не разошлись. Это и есть главный результат: правило не «чаще всего работает», а
не дало ни одного контрпримера.
### Что выяснилось по дороге
**Нулевой час обязан отбрасываться, иначе правило конфликтует само с собой.**
Первый прогон дал у `walking_asymmetry_percentage` 4 часа «накопительная» против
3 «мгновенная». Разбор: в часе, где все значения нули, сумма равна среднему, и
проверка «сходится с суммой» выполняется тождественно. Условие «сумма отличима
от среднего» убирает весь конфликт.
**Часовой слой HAE считается арифметически, а не по Apple.** HealthKit относит
`environmental_audio_exposure` к логарифмическому усреднению по энергии, а пульс
— к среднему, взвешенному по длительности. На наших данных часовое значение
аудиоэкспозиции сходится с обычным арифметическим средним минутных в 59 часах из
62, а у пульса — точно в 29 часах из 63 и с точностью 0.1% в 49. Значит четыре
стиля агрегации HealthKit в потоке ничем не различимы, и родов ровно два.
**Допуск сравнения на вердикты не влияет, а на счётчики влияет вдвое.** Прогон
сеткой: при относительном допуске от `1e-9` до `1e-3` роды всех метрик
одинаковы; число согласных часов у `heart_rate` при этом меняется с 29 на 49, у
`step_count` — с 25 на 35. Взят строгий `1e-9`: канонизация округляет числа до
12 значащих цифр, то есть всё крупнее `1e-12` представлением не объясняется.
**Окно в 48 часов обходится дешевле, чем кажется, но редкие метрики уводит в
`unknown`.** Полный обход всех 696 пар часов занимал 123 мс, окно даёт 68 мс и
перестаёт расти вместе с журналом. Плата: у `physical_effort` за всю историю
было 5 согласных часов, а в последних 48 — только 2, и метрика уходит в
`unknown`. Это честный исход: свидетельств в свежем окне действительно мало.
**Неполные часы видны в основании и ничего не ломают.** У `step_count` из 48
часов окна пригодны 41, а вердикт дали 24 — остальные не сошлись ни с суммой, ни
со средним, потому что минутный слой за них неполон. Отдельного порога
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
отсеивают неполный час сами.
## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
+7 -2
View File
@@ -24,7 +24,12 @@
Тренировки и записи со своими `id` разбираются: `workouts` и `stateOfMind`
половина потока — перестали лежать неразобранными. От разбора остался словарь
категориальных значений; дальше — каталог и род агрегации.
категориальных значений.
**Род агрегации измерен**: сверка минутного слоя с часовым разложила метрики
живого корпуса на накопительные и мгновенные, не сойдясь ни на одной. Каталог
разрезов отдаётся первым маршрутом чтения — дальше Read API, которому теперь
есть на чём строить свёртку.
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
проверены на живом потоке, выводы — в [local-research.md](local-research.md).
@@ -35,7 +40,7 @@
- [x] **2. Приём без разбора.****подключаем телефон по локальной сети**
- [~] **3. Разбор и хранилище.** Метрики, тренировки и записи со своими `id`,
`reindex` — сделано; словарь категориальных значений — нет.
- [ ] **4. Каталог и род агрегации.**
- [x] **4. Каталог и род агрегации.**
- [ ] **5. Read API.**
- [ ] **6. Самоописание.**
- [ ] **7. MCP.**