добавлен словарь категориальных значений HAE → коды HealthKit

- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
This commit is contained in:
av
2026-08-04 07:32:19 +03:00
parent eb3fca77ee
commit 1b649ba3d5
40 changed files with 4317 additions and 42 deletions
+159
View File
@@ -1335,3 +1335,162 @@ MUST NOT, а закрывать базу по выходу одной из дв
- **THEN** база не закрывается, а запись лога называет этап, не указывая
виновной горутины
### Requirement: Реестр наблюдённых категориальных значений
Хранилище SHALL держать реестр категориальных значений, которые приносил поток:
одна строка на тройку `(метрика или секция, поле, значение)`, с выведенным кодом
HealthKit рядом. Значение хранится **дословно**, тем же текстом, каким пришло.
Локаль в ключ MUST NOT входить. Она живёт в заголовке доставки, которого нет в
сыром архиве, — ключ с локалью сделал бы состояние функцией от того, уцелела ли
строка учёта: усыновлённая доставка положила бы вторую строку с пустой локалью.
Язык, на котором приехала строка, восстанавливается по доставке провенанса.
Реестр SHALL нести выведенный код и провенанс **первой** встречи — метку приёма
и идентификатор доставки, в которой строка появилась впервые по порядку журнала.
Первая встреча отвечает на вопрос «когда сменился язык телефона»; счётчик встреч
не заводится вовсе — он не идемпотентен при повторной свёртке той же доставки, а
значит сделал бы состояние зависящим от числа прогонов.
Пустой код — законное состояние строки: он означает «словарь этой строки не
знает», и перечень таких строк есть заявка на пополнение словаря.
Словарь, по которому выводится код, SHALL жить в бинаре, а не в таблице базы и
не в строках миграции. Таблица, наполняемая руками, стала бы входом, которого
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно;
данные, засеянные миграцией, живут в двух местах сразу и расходятся с бинарём
молча после первой же правки словаря.
Строки реестра SHALL писаться **той же транзакцией**, что и точки доставки.
Доставка — единица свёртки; частичное состояние «точки легли, реестр нет»
повторная свёртка не чинит: хеш содержимого сойдётся, и объекты переписываться
не станут.
Обслуживания у реестра нет: строки не удаляются. Строка, единственные доставки
которой выпали из журнала подрезкой архива, переживёт их в рабочей витрине и не
появится в пересобранной. Это тот же класс, что «верхние слои за периоды с
удалёнными доставками», и он MUST называться, а не досчитываться.
#### Scenario: Фаза сна попадает в реестр с кодом
- **WHEN** свёрнута доставка с фазой сна «Во сне» и локалью `ru`
- **THEN** в реестре есть строка `sleep_analysis / value / «Во сне»` с кодом
`HKCategoryValueSleepAnalysisAsleepUnspecified`
- **AND** содержимое точки в часовом объекте не изменилось
#### Scenario: Та же строка без заголовка локали даёт ту же строку реестра
- **WHEN** та же строка приехала доставкой без `Accept-Language`
- **THEN** второй строки в реестре не появляется
#### Scenario: Строка без кода в реестре видна
- **WHEN** свёрнута доставка с `heart_rate.context`, которого словарь не знает
- **THEN** в реестре есть строка с этим значением и пустым кодом
#### Scenario: Отказ слияния не оставляет строк реестра
- **WHEN** слияние доставки отказало
- **THEN** реестр не содержит значений этой доставки
### Requirement: Реестр детерминирован по журналу
Повторная свёртка той же доставки MUST оставлять реестр без изменений, а
провенанс первой встречи MUST быть **минимумом** по порядку журнала
(`received_at`, затем идентификатор доставки), а не значением последней записи.
Проигрывание журнала в любом порядке доставок, дающем тот же префикс, SHALL
приводить реестр к тому же состоянию. Реестр — свёртка по журналу, как и всё
остальное в витрине.
Выведенный код SHALL обновляться при каждой встрече строки: словарь живёт в
бинаре, и пересборка обязана давать код по текущему словарю, а не по тому,
который действовал при первой свёртке. Строка, переставшая приезжать, держит код
прежнего словаря до пересборки — это осознанная цена материализации, и на
сходимость она не влияет, потому что код в отпечаток не входит.
#### Scenario: Повторная свёртка ничего не меняет
- **WHEN** одна и та же доставка свёрнута дважды
- **THEN** строки реестра и их провенанс совпадают с состоянием после первой
свёртки
#### Scenario: Провенанс — самая ранняя доставка
- **WHEN** одна строка приехала сперва поздней доставкой, затем ранней
- **THEN** провенансом остаётся ранняя по журналу
#### Scenario: Порядок свёртки на реестр не влияет
- **WHEN** три доставки свёрнуты во всех шести порядках
- **THEN** реестр и его раздел отпечатка совпадают у всех шести прогонов
#### Scenario: Пересборка даёт тот же реестр
- **WHEN** журнал проигран в свежую витрину
- **THEN** реестр пересобранной витрины совпадает с реестром исходной
### Requirement: Отпечаток покрывает наблюдение реестра, но не выведенный код
Отпечаток витрины SHALL покрывать реестр категориальных значений отдельным
разделом: ключ и провенанс первой встречи. Иначе недетерминированная запись
реестра прошла бы мимо единственного оракула сходимости.
Выведенный код в отпечаток входить MUST NOT. Ключ и провенанс — функция журнала;
код — функция журнала **и версии словаря в бинаре**. Включённый в отпечаток, он
заставил бы всякое пополнение словаря давать расхождение при побайтно совпавшем
журнале, а пополнение объявлено рабочим циклом. Человек, принимающий по
отпечатку необратимое решение о подмене базы, читал бы это как дефект.
Правильность вывода кода проверяется тестами словаря — это другой вопрос, и
смешение обесценило бы оракул.
Раздел SHALL быть отличим от прочих признаком впереди строки, а поля переменной
длины SHALL идти с длиной впереди: два разных состояния витрины не имеют права
дать один отпечаток.
Цена названа вслух: у витрины, свёрнутой до этого изменения, реестр пуст, и
первая же сверка отпечатков после выкатки покажет расхождение. Это законное
расхождение, а не дефект; отчёт пересборки обязан назвать его ожидаемым классом
и напечатать счётчик строк реестра.
#### Scenario: Разошедшееся наблюдение меняет отпечаток
- **WHEN** у двух витрин совпадают объекты и сущности, но в реестре одной есть
строка, которой нет в другой
- **THEN** отпечатки различны
#### Scenario: Расхождение только по коду отпечаток не двигает
- **WHEN** две витрины несут те же строки реестра с тем же провенансом, но
разными кодами
- **THEN** отпечатки совпадают
#### Scenario: Значение с разделителем границу поля не подделывает
- **WHEN** значение содержит признак раздела, цифры и нулевой байт
- **THEN** отпечаток отличается от отпечатка витрины с другим разбиением тех же
байтов по полям
#### Scenario: Пустой реестр отпечаток не ломает
- **WHEN** в витрине нет ни одной строки реестра
- **THEN** отпечаток считается и совпадает с отпечатком такой же витрины без
реестра
### Requirement: Значения категориальных строк не попадают в логи
Сами наблюдённые строки и выведенные коды MUST NOT попадать в записи лога выше
`DEBUG`; в журнал SHALL уходить только счётчики — сколько значений наблюдалось и
сколько осталось без кода. Основание: строки категориальных значений — данные о
здоровье, контекст пульса и фаза сна описывают человека не меньше, чем число.
Проверка отсутствия строки в логе SHALL разбирать запись и сравнивать значения
полей, а не искать подстроку в сыром буфере: служебная метка времени содержит
произвольные цифры, и поиск по буферу делает тест флаки по построению.
#### Scenario: Свёртка доставки со сном не пишет строк в лог
- **WHEN** свёрнута доставка с фазами сна и контекстом пульса
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
одного кода
- **AND** счётчики значений без кода в записи присутствуют