feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
+67
View File
@@ -1658,6 +1658,73 @@ apple_stand_time 14
Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус
отвечает на вопрос «разобрано ли всё», список — «что именно осталось».
## 51. Тренировка досчитывается задним числом, но поля у неё только прибывают
Замер по всем 118 доставкам архива, группировка элементов секций по `id`:
| сущность | копий | различных содержимых | набор полей рос | набор полей убывал |
|---|---:|---:|---|---|
| тренировка A | 26 | 3 | да | нет |
| тренировка B | 18 | 1 | — | — |
| `stateOfMind` #1 | 26 | 1 | — | — |
| `stateOfMind` #2 | 26 | 1 | — | — |
Что менялось у тренировки A между версиями:
```
версия 0 → 1 +stepCadence, +stepCount, изменилось значение ряда activeEnergy
версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy
```
Два вывода, и оба вошли в правило замены версии.
**Тренировка правится задним числом ровно так же, как минутное ведро**
(находка 10): при неизменном наборе полей значения досчитываются. Значит
правило «при равной полноте побеждает тот, чья каноническая форма меньше» —
то, что действует для точек, — заморозило бы тренировку на произвольной версии
навсегда, вместе с недосчитанной энергией.
**Набор полей за весь корпус ни разу не уменьшился.** Обеднённая версия —
событие, которого поток не производит; но маршрут это 95% веса тренировки
(находка 22), а восстановление требует пересборки всего журнала. Поэтому
удержание сохранённой версии стоит одного сравнения множеств, а событие делается
наблюдаемым — счётчиком и `WARN`, — вместо необратимого.
**Правило полноты, написанное для точек, здесь неприменимо.** Оно требует, чтобы
значения общих содержательных ключей совпали, иначе отношение включения гасится
до «равенства». У точки это верно (надмножество имён при других значениях
означает другое измерение), у сущности — нет: значения между версиями
расходятся всегда. Проверено на копии пакета `canon`:
```
сохранённая с маршрутом vs обеднённая, значения общих полей те же : superset
сохранённая с маршрутом vs обеднённая, значения общих полей иные : equal
сохранённая vs версия с усечённым маршрутом (2 точки → 1) : equal
```
Отсюда же второй разряд правила: усечённый ряд ключа не теряет, поэтому
сравнивается ещё и длина верхнеуровневых массивов.
## 52. Половина потока — не `metrics`: перемер на 118 доставках
Пересчёт находки 50 на выросшем корпусе. Набор верхнеуровневых ключей `data`:
| набор ключей `data` | доставок |
|---|---|
| `metrics` | 65 |
| `workouts` | 27 |
| `stateOfMind` | 26 |
Пропорция та же, что была на 99 доставках (51/24/24), и наблюдение «ни одна
доставка не несла двух секций сразу» держится: автоматизация HAE шлёт одну
секцию за раз. Полагаться на это в правилах удаления данных по-прежнему нельзя —
за двое суток наблюдения смешанная доставка просто не успела бы случиться.
С покрытием `workouts` и `stateOfMind` разбором эти 53 доставки перестали быть
`partial`. Прогон живого архива после изменения: 118 тел, свёрнуто 118, отказов
ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и
2 записи; повторное проигрывание дало тот же отпечаток.
## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная