## ADDED Requirements

### 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** содержимое точки сохранено дословно и целиком
