добавлен словарь категориальных значений 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** содержимое точки сохранено дословно и целиком
+27 -10
View File
@@ -323,10 +323,13 @@
или нет.
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
отличит появление двадцати семи тренировок от пропажи двух.
часовые объекты, тренировки, записи и строки реестра категориальных значений.
Отпечаток отвечает «да/нет» за витрину целиком, поэтому единица, которой нет в
счётчиках, делает расхождение безадресным: человек увидит «не совпало» при
неизменившемся числе объектов и не отличит появление двадцати семи тренировок от
пропажи двух. Перечень пополняется **тем же изменением**, которое заводит
единицу хранения: список закрытый, и единица, не внесённая в него, молчит ровно
там, где расхождение впервые становится заметным.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
@@ -362,13 +365,17 @@
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
разбором новая секция**, **появившаяся единица хранения**) SHALL называться
отдельно от самого факта расхождения.
Класс «покрыта новая секция» назван потому, что первый прогон после такого
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
оракул ровно тогда, когда по нему принимается необратимое решение.
оракул ровно тогда, когда по нему принимается необратимое решение. Класс
«появилась единица хранения» — та же причина в общем виде: реестр категориальных
значений пуст у витрины, свёрнутой прежним бинарём, и первая сверка после
выкатки расходится по нему одному, при неизменившихся объектах и сущностях.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
@@ -383,7 +390,9 @@
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
MUST NOT: отпечаток берёт содержимое хешем. Строки реестра при этом счётом, а не
перечислением: наблюдённое значение — данные о здоровье наравне со значением
точки. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
@@ -402,7 +411,15 @@ SHALL идти в поток ошибок, а не смешиваться с о
- **WHEN** пересборка завершилась
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
тренировок и записей
тренировок, записей и строк реестра категориальных значений
#### Scenario: Пустой реестр рабочей витрины назван ожидаемым классом
- **WHEN** рабочая витрина свёрнута прежним бинарём и строк реестра не имеет, а
пересобранная их получила
- **THEN** отчёт называет «появилась единица хранения» ожидаемым классом
расхождения
- **AND** печатает число строк реестра «до и после»
#### Scenario: Расхождение отпечатков не является отказом
@@ -444,7 +461,8 @@ SHALL идти в поток ошибок, а не смешиваться с о
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств,
ни наблюдённых категориальных строк
### Requirement: Отказ на одной доставке не останавливает пересборку
@@ -497,4 +515,3 @@ SHALL идти в поток ошибок, а не смешиваться с о
- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой
теряет содержание сохранённой
- **THEN** отчёт пересборки называет число удержанных версий больше нуля
+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** счётчики значений без кода в записи присутствуют