добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -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** содержимое точки сохранено дословно и целиком
|
||||
|
||||
Reference in New Issue
Block a user