добавлен словарь категориальных значений 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
+251
View File
@@ -704,3 +704,254 @@ SHALL называть **тип** встреченного токена и см
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
- **AND** смещение не меняется, если то же значение сделать длиннее
### Requirement: Категориальные значения получают стабильный код
Разбор SHALL выделять из тела **категориальные значения** — строки объявленных
полей, у которых значение перечислимо, — и выводить для каждого стабильный код
HealthKit по словарю `(локаль, строка) → код`.
Объявленных полей три, и все три измерены на живом потоке (находка 37):
```
sleep_analysis value «БДГ», «Основная», «Глубокий», «Во сне», «В кровати», «Бодрствование»
heart_rate context «Не задано», «Сидячий образ жизни», «Активен»
workouts name «В помещении Ходьба», «На улице Ходьба»
```
Список полей SHALL быть объявлен явно, а не выведен из формы значения. Строк в
точке много (`date`, `start`, `source`), и «всякая строка категориальна» завело
бы в реестр метки времени и имена устройств.
Именем в наблюдении SHALL быть **то же** имя метрики или секции, которым
адресуется единица хранения: `sleep_analysis_summary` после разделения схем,
`workouts` для тренировок. Второе имя для того же понятия развело бы наблюдение
и объект по разным ключам.
Исходная строка MUST оставаться в содержимом точки или сущности **дословно и
нетронутой**: код добавляется рядом, отдельной единицей, и обратное
преобразование остаётся возможным всегда. Содержимое точки MUST NOT
пересериализовываться ради кода.
Значение SHALL читаться из исходных байтов и приниматься только как непустая
JSON-строка. Значение другого рода (число, объект, `null`), отсутствие поля и
пустая строка категориальным значением не считаются; точка при этом MUST
разбираться как обычно — новых путей потери точки требование не заводит. Пустая
строка исключена намеренно: сказать о данных ей нечего, а в счётчике строк без
кода она сидела бы вечно.
Результат разбора SHALL нести множество различных наблюдённых категориальных
значений — по одному элементу на тройку `(метрика или секция, поле, строка)`, в
детерминированном порядке. Повтор одной строки в тысяче точек даёт один элемент.
Ключ наблюдения совпадает с ключом реестра: два разных ключа на одно понятие
разошлись бы при первом же поле с одинаковым именем у двух метрик.
#### Scenario: Фаза сна из реального пакета получает код
- **WHEN** разбирается пакет HAE из `testdata`, где `sleep_analysis.value`
равно «Во сне», а локаль доставки — `ru`
- **THEN** содержимое точки в результате разбора совпадает с исходными байтами
тела, включая строку «Во сне»
- **AND** результат разбора несёт наблюдение
`sleep_analysis / value / «Во сне»` с кодом
`HKCategoryValueSleepAnalysisAsleepUnspecified`
#### Scenario: Имя тренировки попадает в наблюдённые значения
- **WHEN** разбирается пакет с тренировкой «В помещении Ходьба»
- **THEN** результат разбора несёт наблюдение `workouts / name / «В помещении
Ходьба»`
- **AND** содержимое тренировки не изменено
#### Scenario: Повтор строки не задваивает наблюдение
- **WHEN** в доставке тысяча точек `heart_rate` с одинаковым `context`
- **THEN** в результате разбора это одно наблюдение
#### Scenario: Значение не-строка точку не роняет
- **WHEN** у точки `sleep_analysis` поле `value` пришло числом
- **THEN** точка разобрана как обычно и её содержимое сохранено дословно
- **AND** наблюдения из неё не выводится
#### Scenario: Тренировка без имени наблюдения не даёт
- **WHEN** у тренировки нет поля `name` либо оно пришло пустой строкой
- **THEN** наблюдения из неё не выводится
- **AND** счётчик значений без кода не растёт
### Requirement: Незнакомая строка даёт пустой код и попадает в счётчик
Разбор MUST NOT угадывать код: строка, которой словарь не знает, SHALL получать
**пустой** код. Пустота честнее догадки — по коду сверяются с экспортом Apple, и
неверный код неотличим от верного до тех пор, пока по нему не примут решение.
Разбор SHALL считать различные наблюдения, для которых код не выведен, и
отдавать счётчик вызывающему. Считаются **различные** наблюдения, а не их
вхождения: вопрос, на который счётчик отвечает, — «сколько строк ждёт словаря»,
а не «сколько точек их несло».
Счётчик ненулевой в установившемся режиме — словарь покрывает только фазы сна, а
контекст пульса и имена тренировок объявлены категориальными заранее. Сигналом
«появилось новое» служит поэтому **новая строка в реестре**, а не ненулевой
счётчик; требование называет это, чтобы счётчик не читали как тревогу.
Незнакомая строка MUST NOT влиять на исход разбора: доставка разбирается,
точки сохраняются, статус не меняется.
#### Scenario: Выдуманная фаза сна не роняет разбор
- **WHEN** в теле встречается `sleep_analysis.value` со строкой, которой в
словаре нет
- **THEN** разбор завершается без ошибки и точка сохраняется дословно
- **AND** её наблюдение имеет пустой код
- **AND** счётчик значений без кода равен единице
#### Scenario: Известная и неизвестная строки в одной доставке
- **WHEN** доставка несёт и «Во сне», и незнакомую строку в том же поле
- **THEN** у первой код выведен, у второй пуст
- **AND** счётчик значений без кода равен единице
### Requirement: Локаль сужает поиск, но её отсутствие не отменяет вывода
Локаль доставки SHALL браться из заголовка `Accept-Language` и нормализоваться
по правилу lookup RFC 4647: берётся первый тег списка, подтеги отсекаются,
регистр свёрнут (`RU-ru,ru;q=0.9` → `ru`). Свёртка регистра обязательна: теги
BCP 47 регистронезависимы, и без неё `RU` и `ru` были бы разными языками.
Вывод кода SHALL идти тремя разрядами:
1. пара `(локаль, строка)` есть в словаре — её код;
2. локали нет либо пары нет, но строка известна в других локалях и **все** они
дают один код — этот код;
3. иначе — пустой код.
Второй разряд обязателен, а не удобен. Заголовки запроса в сыром архиве не
лежат: они были заголовками запроса, а не телом. Доставка, восстановленная из
осиротевшего тела, приезжает без `Accept-Language`, и правило «нет локали — нет
кода» сделало бы состояние функцией от того, уцелела ли строка учёта, — то есть
сломало бы `import + replay`.
Локаль MUST NOT входить в ключ наблюдения. Ключ, содержащий локаль, оставляет ту
же зависимость этажом ниже: усыновлённая доставка положила бы вторую строку с
пустой локалью, и раздел реестра в отпечатке разошёлся бы вне всякого названного
класса. На каком языке приехала строка, восстанавливается по доставке
провенанса — тем же приёмом, каким устроен провенанс часового объекта.
Расхождение локалей MUST разрешаться пустым кодом, а не выбором: если одна и та
же строка в разных локалях означает разные коды, выбирать не из чего.
#### Scenario: Локаль с подтегом, весами и в верхнем регистре приводится к базовому тегу
- **WHEN** доставка пришла с `Accept-Language: RU-ru,ru;q=0.9,en;q=0.8`
- **THEN** локалью доставки считается `ru`
- **AND** фаза сна «Во сне» получает свой код
#### Scenario: Доставка без заголовка локали код всё равно получает
- **WHEN** у доставки заголовка `Accept-Language` нет вовсе
- **THEN** «Во сне» получает тот же код, что и при локали `ru`
- **AND** наблюдение имеет тот же ключ, что и при локали `ru`
#### Scenario: Незнакомая локаль знакомой строки код не отменяет
- **WHEN** доставка пришла с локалью, которой в словаре нет, а строка известна в
единственной локали
- **THEN** код выведен
#### Scenario: Мусор в заголовке разбор не роняет
- **WHEN** заголовок пуст, состоит из `*`, из пробелов или из сотни тегов
- **THEN** разбор завершается без паники, локаль либо выведена, либо пуста
- **AND** коды выводятся вторым разрядом
### Requirement: Переименование кода самой Apple не раскалывает историю
Система SHALL держать таблицу синонимов кодов HealthKit — соответствие
устаревшего имени каноническому — и приводить к каноническому имени **всякий**
выведенный код, а не только код, пришедший из экспорта Apple.
Основание измерено (находка 43): те же 338 записей сна экспортированы как
`HKCategoryValueSleepAnalysisAsleep` в 2021 году и как
`HKCategoryValueSleepAnalysisAsleepUnspecified` в 2026-м — Apple переименовала
значение и переписывает историю при выгрузке. Код устойчивее локализованной
строки, но не абсолютен.
Приведение SHALL применяться и к словарю, поэтому «две формы сходятся в один
код» верно по построению. Таблица SHALL быть **плоской**: ни одно её значение не
является ключом, поэтому алиас разрешается ровно за один шаг. Инвариант
проверяется тестом по таблице целиком; рантайм-обхода цепочек и ограничения
глубины у системы быть MUST NOT — у них нет ни одного достижимого сценария, зато
есть собственный вырожденный случай.
#### Scenario: Устаревшее и новое имя дают один код
- **WHEN** канонизируются `HKCategoryValueSleepAnalysisAsleep` и
`HKCategoryValueSleepAnalysisAsleepUnspecified`
- **THEN** обе формы дают `HKCategoryValueSleepAnalysisAsleepUnspecified`
#### Scenario: Код без синонима остаётся собой
- **WHEN** канонизируется `HKCategoryValueSleepAnalysisAsleepREM`
- **THEN** результат равен исходному коду
#### Scenario: Таблица синонимов плоская
- **WHEN** проверяется таблица синонимов целиком
- **THEN** ни одно её значение не встречается среди её ключей
### Requirement: Границы наблюдённых категориальных значений
Число различных наблюдений одной доставки MUST быть ограничено **64**, а длина
значения — **128 байт**; отброшенное SHALL считаться. Тело контролирует
отправитель целиком: без границы одна доставка кладёт в витрину сколько угодно
строк.
Граница числа SHALL применяться **при накоплении, а не при выдаче**. Накопитель
без границы растёт по числу различных строк тела, а их контролирует
отправитель: измерено — тело в 60 МиБ из миллиона различных значений (предел
приёма 64 МиБ) поднимало пик процесса с 780 до 1002 МиБ. Лимита памяти у
контейнера нет, отказ по памяти в фоновой горутине свёртки не перехватывается,
а перезапуск берёт ту же доставку из архива: приём стоит, телефон доставку не
перешлёт.
Счётчик отброшенного считает **вхождения**, а не различные значения, и единица
MUST быть названа. Различные здесь не считаются не по недосмотру: отброшенный
ключ нигде не запоминается, а запомнить его значило бы вернуть тот самый
неограниченный рост. Счётчик отвечает на «сколько раз сработала граница»; на
«какие строки ждут словаря» отвечает реестр.
Числа названы измерением, а не аналогией: на живом потоке различных значений по
всем трём полям около одиннадцати, самое длинное — «Сидячий образ жизни»,
36 байт UTF-8. Предел в 32 байта, взятый по аналогии с именами непокрытых
секций, отбросил бы две из трёх измеренных строк контекста пульса: там имена
короткие и латинские, здесь — русские фразы.
Значение длиннее предела SHALL **отбрасываться со счётчиком, а не обрезаться**.
Обрезанная строка неотличима от настоящей и попала бы в реестр самостоятельным
значением; настоящая строка при этом остаётся в точке целиком — теряется
наблюдение, а не данные.
При переполнении границы числа уцелевший набор SHALL быть **функцией множества
наблюдений, а не порядка элементов на проводе**: наблюдения упорядочиваются по
ключу и берутся первые. Иначе то же содержимое, переприсланное в другом порядке
ключей (порядок у HAE нестабилен, находка 2), давало бы другой реестр.
#### Scenario: Слишком много различных строк
- **WHEN** доставка несёт различных наблюдений больше предела
- **THEN** в результате разбора их ровно предел
- **AND** счётчик отброшенных наблюдений положителен
- **AND** все точки доставки сохранены
#### Scenario: Уцелевший набор не зависит от порядка точек в теле
- **WHEN** два тела несут одно и то же множество категориальных строк в разном
порядке, и обоим не хватает предела
- **THEN** уцелевшие наблюдения у них совпадают
#### Scenario: Непомерно длинное значение отброшено целиком
- **WHEN** `sleep_analysis.value` длиннее предела длины
- **THEN** наблюдения из него не выводится, счётчик отброшенных положителен
- **AND** содержимое точки сохранено дословно и целиком