- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
73 KiB
parsing Specification
Purpose
TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after archive.
Requirements
Requirement: Разбор секции метрик
Система SHALL разбирать секцию data.metrics тела доставки Health Auto Export
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
том виде, в каком его прислал HAE.
Хранимая форма точки — исходные байты, как они пришли в теле доставки.
Система MUST NOT пересобирать содержимое повторной сериализацией разобранных
значений: обход через map[string]any теряет литерал (1.0 становится 1,
целые больше 2^53 сдвигаются, невалидный UTF-8 заменяется на U+FFFD), и потеря
не видна тестам на фикстурах — они сравнивают разобранное с разобранным.
Отсюда же следует, что «служебных» полей у точки нет: отбрасывать нечего, нормализованное время добавляется рядом с исходным содержимым, а не вместо.
Scenario: Метрика с точками разбирается в точки
- WHEN тело содержит
data.metrics[]с непустымdata[] - THEN каждая точка с непустым
dateстановится точкой хранилища - AND её содержимое сохраняется исходными байтами, без пересборки
Scenario: Незнакомая метрика не ломает разбор
- WHEN приходит метрика с именем, которого разбор не знает
- THEN её точки разбираются наравне с остальными
- AND разбор не завершается ошибкой
Scenario: Точка без метки времени пропускается
- WHEN точка не содержит
dateлибоdateне разбирается ни одним из поддерживаемых форматов - THEN точка не попадает в хранилище
- AND факт учитывается в итоге разбора доставки
Scenario: Точка с нечитаемым концом интервала пропускается
- WHEN точка несёт
end, который не разбирается - THEN точка не попадает в хранилище
- AND факт учитывается отдельным счётчиком
Вырождать такую точку в мгновенную нельзя: две записи с общим началом получили бы одну координату, и одна исчезла бы молча. Тело остаётся в архиве.
Requirement: Вывод слоя гранулярности
Система SHALL выводить слой точки из выравнивания меток времени, а не из
заголовка доставки. Заголовок automation-aggregation непригоден: значение
Default соответствует трём разным режимам выгрузки.
Выводимые слои: raw (метка на произвольной секунде), minute (секунды
нулевые), hour (секунды и минуты нулевые). Слой day в перечисление входит,
но не выводится — он назначается схемам с фиксированной гранулярностью
(см. «Разделение схем под одним именем метрики»).
Выравнивание SHALL считаться по метке в исходной зоне, а не по метке в UTC.
HAE строит сетку по местному времени; ровный местный час в зоне со смещением на
половину (+0530, +0545, +0930) даёт UTC-метку на середине часа, и часовая
выгрузка целиком уехала бы в слой minute — где столкнулась бы с настоящей
минутной автоматизацией и завысила сумму минутного слоя вдвое.
Слоем метрики SHALL становиться самое мелкое выравнивание среди её меток, а
не преобладающее. Измерено: у плотных метрик выравнивания перемешаны
(active_energy — 1320 минутных меток и 21 часовая, heart_rate — 654
посекундных и 10 минутных), потому что метка ровно на часе одновременно
является и минутной. Метрика, у которой хоть одна метка стоит на середине часа,
часовой не является.
Разделение схем под одним именем выполняется до вывода слоя, и точки с
назначенным слоем в определении преобладающего слоя доставки не участвуют:
суточных сводок сна бывает больше порога плотности, и их полуночные метки
иначе назначили бы всей доставке слой hour.
Классификация MUST быть по метрике внутри доставки, а не по доставке целиком: при перенастройке автоматизации приезжают смешанные доставки, и отнесение такой доставки к одному слою складывает минутные точки с посекундными.
Scenario: Плотная метрика классифицируется сама
- WHEN в доставке у метрики не меньше десяти точек с метками
- THEN слой определяется выравниванием её собственных меток
Scenario: Зона с получасовым смещением не делает часовую выгрузку минутной
- WHEN метки стоят на ровном местном часе, а смещение зоны равно
+0530 - THEN слой метрики
hour
Scenario: Одна метка на середине часа делает метрику минутной
- WHEN у плотной метрики десять меток стоят ровно на часе, а одна — на середине часа
- THEN слой метрики
minute, а неhour
Scenario: Редкая метрика наследует преобладающий слой
- WHEN в доставке у метрики меньше десяти точек
- THEN она получает самый мелкий слой среди плотных метрик этой доставки
- AND её собственное выравнивание во внимание не принимается
Scenario: Доставка, где всем метрикам слой назначен схемой
- WHEN в доставке нет метрик, которым слой надо выводить, — все точки принадлежат схемам с фиксированной гранулярностью
- THEN доставка сохраняется, а слой доставки не выводится и не требуется
Иначе доставка автоматизации, настроенной только на сон, отвергалась бы целиком — и необратимо: исход детерминирован, и пересборка повторяла бы его вечно.
Scenario: В доставке нет плотных метрик
- WHEN ни у одной метрики доставки нет десяти точек
- THEN слой наследуется от последнего надёжно выведенного слоя той же
автоматизации (
automation-id) среди доставок, предшествующих этой - AND если наследовать нечего, слой берётся из надёжного заголовка
(
Minutes→minute,Hours→hour)
Граница «предшествующих» обязательна: слой обязан быть функцией от префикса журнала. Наследование от последней доставки вообще делает свёртку зависящей от истории, и пересборка даёт не то состояние, что живой приём — измерено на архиве, 1737 объектов против 1742.
Scenario: Пересборка журнала даёт то же состояние
- WHEN те же доставки сворачиваются повторно в том же порядке
- THEN число объектов и их содержимое не меняются
Scenario: Наследовать нечего и заголовок ненадёжен
- WHEN плотных метрик нет, предыдущего слоя автоматизации нет, а заголовок
равен
Default - THEN точки доставки не сохраняются, а исход учитывается счётчиком и
записью
WARN - AND тело остаётся в архиве, откуда доставку подберёт пересборка
Молчаливый выбор raw в этой ветке недопустим: заголовок Default наблюдался
одновременно у посекундного, минутного и часового режимов, поэтому он не
доказывает ничего, а призрачный raw-разрез попадёт в каталог и в правило
Read API «самый мелкий слой, покрывающий диапазон».
Scenario: Выведенный слой расходится с надёжным заголовком
- WHEN выведенный слой не совпадает с заголовком
MinutesилиHours - THEN система пишет запись уровня
WARN - AND сохраняет точки по выведенному слою, а не по заголовку
Scenario: Заголовок Default в сравнении не участвует
- WHEN заголовок доставки равен
Default - THEN расхождение не фиксируется и
WARNне пишется
Иначе сигнал утонул бы в собственном шуме: Default не означает режима, и
сравнение с ним давало бы WARN на каждой доставке потока в пять минут.
Requirement: Разбор форматов времени
Система SHALL разбирать метку формата 2026-07-31 21:03:51 +0300 и приводить
её к UTC, сохраняя офсет исходной зоны. В секции data.metrics других форматов
меток не встречается.
Система SHALL разбирать вторым форматом RFC 3339 в UTC
(2026-07-31T18:03:51Z): им приходят метки секции data.stateOfMind, тогда
как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься у
любой метки сущности, а не приписываться секции жёстко: формы однозначны и не
пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений —
stateOfMind уже шлёт стабильные коды HealthKit там, где старые секции шлют
переводы. Приписанный секции формат ломался бы молча в день такого выравнивания.
Метка RFC 3339 в UTC даёт офсет 0, и это MUST означать «источник прислал
UTC», а не «человек находился в нулевой зоне»: местной зоны у секции
stateOfMind в потоке нет вовсе.
Метка точки при этом остаётся строгой — один формат, — и асимметрия намеренная. По метке точки выводится слой, причём по метке в исходной зоне; терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час). У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и пересборка вернёт его, когда формат станет известен.
Unix-эпоха дробным числом (1785446196.4132624) встречается внутри
heartbeatSeries и меткой точки не является. Система MUST NOT преобразовывать
её: элементы серии проходят как исходные байты. Преобразование во time.Unix
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
heart_rate_variability.
Время внутри маршрута тренировки (route[].timestamp) меткой сущности тоже не
является и MUST проходить дословно, не разбираясь.
Scenario: Локальное время со смещением
- WHEN метка имеет вид
2026-07-31 21:03:51 +0300 - THEN точка получает время в UTC и офсет
+10800секунд
Scenario: RFC 3339 в UTC
- WHEN метка сущности имеет вид
2026-07-31T18:03:51Z - THEN сущность получает время в UTC и офсет
0
Scenario: Тренировка со временем в формате метрик
- WHEN тренировка несёт
startвида2026-08-01 10:04:31 +0300 - THEN сущность получает время в UTC и офсет
+10800секунд
Scenario: Время внутри серии ударов
- WHEN точка метрики
heart_rate_variabilityсодержитheartbeatSeries - THEN элементы серии сохраняются исходными байтами вместе с их эпохой
- AND серия не разворачивается в отдельные точки
- AND эпоха внутри серии не разбирается и не преобразуется
Scenario: Время внутри маршрута не разбирается
- WHEN тренировка содержит
routeс полемtimestampу каждой точки - THEN точки маршрута сохраняются исходными байтами
- AND их метки не разбираются и не преобразуются
Requirement: Разделение схем под одним именем метрики
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto Export шлёт под одним именем, чтобы одно имя означало одну схему.
Под именем sleep_analysis приезжают две несовместимые схемы: поэпизодная
(start/end/value/qty) и суточная сводка
(totalSleep/core/rem/deep/awake с меткой на местной полуночи). Общих
полей, кроме date и source, у них нет.
Схема точки SHALL определяться по самой точке, а не по её месту в исходном
массиве. Список разобранных точек отфильтрован пропусками, и соответствие по
индексу съезжало бы от одной пропущенной точки: эпизод уезжал бы под имя
суточной сводки со слоем day, сводка — под имя эпизода. Метрика и слой входят
в координату, поэтому ошибка необратима — точки из объекта не удаляются, и
пересборка воспроизвела бы её.
Scenario: Пропущенная точка не сдвигает разметку схем
- WHEN в метрике
sleep_analysisперед суточной сводкой стоит точка без разбираемой метки - THEN сводка всё равно сохраняется под именем
sleep_analysis_summaryсо слоемday
Scenario: Поэпизодная запись сна
- WHEN точка
sleep_analysisсодержит полеvalue - THEN она сохраняется под именем
sleep_analysis
Scenario: Суточная сводка сна
- WHEN точка
sleep_analysisсодержит полеtotalSleep - THEN она сохраняется под именем
sleep_analysis_summary - AND её слой фиксирован как
day, а не выводится из выравнивания
Requirement: Канонизация содержимого
Система SHALL вычислять каноническую форму содержимого точки для сравнения и хеширования: сортировка ключей и округление чисел до двенадцати значащих цифр.
Каноническая форма существует только в момент вычисления хеша и хранимую
форму не заменяет никогда: хранится исходные байты (см. «Разбор секции
метрик»). Числа при канонизации читаются литералом, а не через float64, —
иначе округление применится к уже испорченному значению.
Сортировка ключей — свойство encoding/json, своей реализации не требует.
Собственным остаётся только округление.
Без округления сравнение бесполезно: 63% повторно приехавших точек различались последним разрядом double при одинаковом измерении.
Scenario: Повтор с иным порядком ключей опознаётся как тот же
- WHEN та же точка приезжает с другим порядком ключей в JSON
- THEN её каноническая форма совпадает с сохранённой
Scenario: Дребезг последнего разряда не считается изменением
- WHEN значение отличается только за пределами двенадцатой значащей цифры
- THEN каноническая форма совпадает с сохранённой
Requirement: Разбор не влияет на код ответа приёма
Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не меняет код ответа на доставку.
Разбор идёт после ответа, поэтому исход виден не в ответе и не в момент
ответа, а асинхронно — в delivery.parse_status и в записи лога. Когда именно
отдаётся ответ и кто сворачивает принятое, определяет capability ingest;
здесь нормируется только то, что от разбора код ответа не зависит.
Scenario: Содержимое не разобралось
- WHEN тело сохранено в архив, но разбор его содержимого не удался
- THEN ответ на приём остаётся
200 - AND исход виден в
delivery.parse_statusи в записи лога
Requirement: Перечисление непокрытых секций доставки
Разбор SHALL перечислять верхнеуровневые ключи объекта data и возвращать
вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
Покрытых ключей сегодня три — metrics, workouts и stateOfMind. Разбор и
перечисление MUST ходить по одному объявленному множеству покрытых имён:
состояние «секция разбирается, но числится непокрытой» невыразимо по построению.
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает. Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между доставками.
Отсутствие непокрытых ключей и отсутствие секции metrics — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).
Scenario: Незнакомая секция попадает в список непокрытых
- WHEN тело содержит
data.ecgнаряду сdata.metrics - THEN разбор возвращает
ecgв списке непокрытых ключей - AND точки секции
metricsразбираются как обычно
Scenario: Доставка из одних тренировок непокрытых ключей не даёт
- WHEN тело содержит только
data.workouts - THEN разбор завершается без ошибки, точек нет, тренировки разобраны
- AND список непокрытых ключей пуст
Scenario: Доставка из одного состояния разума непокрытых ключей не даёт
- WHEN тело содержит только
data.stateOfMind - THEN разбор завершается без ошибки, записи разобраны
- AND список непокрытых ключей пуст
Scenario: Доставка из одних метрик непокрытых ключей не даёт
- WHEN единственный ключ
data—metrics - THEN список непокрытых ключей пуст
Scenario: Один и тот же набор секций даёт один и тот же список
- WHEN два тела несут те же секции в разном порядке, а одно из них повторяет непокрытый ключ дважды
- THEN списки непокрытых ключей у них совпадают
Scenario: Содержимое непокрытой секции не удерживается в памяти
- WHEN тело в десятки мегабайт состоит преимущественно из непокрытой секции
- THEN после разбора удержано не больше четырёх размеров тела — та же граница, что и для тела из метрик
- AND содержимое непокрытой секции в результат разбора не попадает
Requirement: Границы списка непокрытых секций
Список непокрытых ключей MUST быть ограничен — не больше 32 имён и не больше 64 байт на имя: имена приходят из тела, которым отправитель управляет целиком. Срабатывание любой из границ MUST быть видно вызывающему — молчаливое усечение превратило бы список в уверенный, но неполный ответ на вопрос «что останется потерянным, если тело удалить».
Число имён сверх предела отдаётся счётчиком. Имя длиннее предела обрезается по
границе рун, к обрезанному приписывается маркер … — сверх предела, а не внутри
него. Обрезка не инъективна, поэтому обрезанное имя сравнению со словарём
известных секций не подлежит.
Предел длины считается по байтам декодированного имени: escape- последовательности JSON к этому моменту уже разобраны.
Scenario: Ключей больше предела
- WHEN объект
dataсодержит 40 непокрытых ключей - THEN список содержит 32 имени
- AND число отброшенных имён отдано отдельным счётчиком
Scenario: Имя ключа длиннее предела
- WHEN непокрытый ключ длиннее 64 байт
- THEN в списке лежит имя, обрезанное по границе рун, с маркером
…
Requirement: Отказ разбора остаётся всё или ничего
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная после того, как покрытая секция уже разобрана (обрезанное тело, мусор в следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей — доставка считается неразобранной целиком.
Иначе часть данных оказалась бы в витрине под статусом, по которому доставку никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть
метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя
у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же
«всё или ничего» — доставка получила бы failed при частично записанной
витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если
такая доставка когда-нибудь принесёт stateOfMind, его записи доедут не сразу,
а пересборкой; тело при этом остаётся в архиве, и failed ретеншену трогать
нельзя.
Повтор ключа покрытой секции в одном объекте data SHALL давать объединение
секций, а не победу последней: молча терять данные нельзя. То же SHALL
относиться к повтору самого члена data в теле — результаты накапливаются,
включая список непокрытых ключей.
Scenario: Тело оборвано после секции метрик
- WHEN тело содержит целую секцию
metrics, а следующий членdataоборван - THEN разбор завершается ошибкой и точек не отдаёт
Scenario: Тело оборвано после секции тренировок
- WHEN тело содержит целую секцию
workouts, а следующий членdataоборван - THEN разбор завершается ошибкой и сущностей не отдаёт
Scenario: Невыводимый слой не сохраняет и сущностей
- WHEN доставка несёт метрики, слой которых не определяется, и вместе с
ними секцию
stateOfMind - THEN разбор завершается ошибкой, ни точек, ни записей не отдаёт
- AND список непокрытых ключей переживает отказ
Scenario: Секция метрик встречается дважды
- WHEN объект
dataсодержит два ключаmetrics - THEN точки обеих секций попадают в результат
Scenario: Секция тренировок встречается дважды
- WHEN объект
dataсодержит два ключаworkouts - THEN сущности обеих секций попадают в результат
Scenario: Член data встречается дважды
- WHEN тело содержит два члена
data, из которых первый несёт непокрытую секцию, а второй — покрытую - THEN данные покрытой секции попадают в результат
- AND имя непокрытой секции остаётся в списке непокрытых ключей
Requirement: Разбор секций с собственными идентификаторами
Система SHALL разбирать секции тела, элементы которых несут собственный id, в
сущности, а не в точки: у сущности нет ни слоя, ни координатного ключа
метрика + слой + начало + конец — её адресует сам id.
Покрываются две такие секции: data.workouts и data.stateOfMind. Секции
ecg, symptoms, cycleTracking, medications и heartRateNotifications
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
с ними остаётся partial.
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
остальное — включая маршрут, внутренние ряды (heartRateData,
activeEnergy, heartRateRecovery) и сводки — MUST храниться содержимым
сущности дословно, теми же байтами, какими пришло. Раскладывать структуру
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
дублируют ряды (distance — это сумма walkingAndRunningDistance), а набор
полей зависит от типа тренировки (у уличной есть route, avgSpeed,
flightsClimbed, у домашней — temperature, humidity, intensity).
Значение заголовка не того ТИПА SHALL стоить одного поля, а не сущности.
Каждое поле заголовка читается мягко: строка берётся, когда значение является
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
сущность») и распространяется на весь заголовок. Иначе name, приехавшее
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
разобранной.
Мягкость MUST достигаться конструкцией, которая не полагается на дозаполнение
остальных полей библиотекой разбора: encoding/json при несовпадении типа
«skips that field and completes the unmarshaling as best it can», но тут же
оговаривает, что дозаполнение полей после проблемного не гарантировано.
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
функцией тела: одна и та же тренировка давала бы разный заголовок.
Граница правила называется вслух: оно закрывает смену типа значения, но не смену формата строки. Наблюдавшийся дрейф — формата дат (разбор дат уже зависит от секции пакета), и метка в незнакомом формате по-прежнему уносит сущность целиком; закрыть это может только хранение сущности с неразобранной меткой, а это отдельная задача. Пропуск при этом перестаёт быть невидимым: он доходит до учётной записи доставки.
Идентификатор исключением из мягкости MUST быть: сущность без строкового id
не адресуема, и приведение чужого нестрокового значения к строке было бы
выдумыванием идентичности за источник. Такая сущность пропускается тем же
счётчиком, что и сущность без id.
Началом сущности при присутствующем, но непрочитанном start подставляться
date MUST NOT — включая start: null.
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на date
существует для сущностей, у которых start не прислан вовсе, а подстановка
другого поля вместо непонятого даёт метку другого момента времени, ничем не
отличимую от настоящей. Такой start SHALL считаться неразбираемой меткой —
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
надо именно «ключ был», а не «значение не той формы»: null тоже не даёт
строки, и без этого различения он молча уводил бы тренировку на другой момент
времени.
Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой «побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы заголовок функцией истории вызовов, а не тела.
Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
разобралось как объект» — включая null. Разбор в структуру на null ошибки не
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет id»,
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
шлёт 91.746 секунды при интервале в 91 секунду, и вычисленное значение молча
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
отсутствием значения, а не нулём — ноль является законной длительностью, и
потребитель не отличил бы «источник не прислал» от «измерено ноль».
Началом сущности SHALL быть start, при его отсутствии — date. Конец берётся
из end; при отсутствии или неразбираемости конца он SHALL равняться началу, а
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
точки: ключ сущности — id, схлопывать координаты нечем. Офсет исходной зоны
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
два разных.
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
секции HAE дословно (stateOfMind, не state_of_mind): инвариант «форма
Apple не транслируется» относится и к именам секций.
Длина идентификатора SHALL быть ограничена, и сущность с более длинным id
SHALL пропускаться тем же счётчиком, что и сущность без id. Идентификатор
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
секций.
Ряд пульса внутри тренировки MUST NOT попадать в метрику heart_rate:
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
внутри тренировки, — и смешение задвоило бы ряд.
Сущность без id либо без разбираемой метки времени SHALL пропускаться со
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL
доходить до учётной записи доставки, а не только до лога, — иначе ретеншен,
решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка.
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних метрик — большинство потока.
Scenario: Тренировка разбирается вместе с маршрутом
- WHEN тело содержит
data.workoutsс тренировкой, несущейroute - THEN разбор отдаёт сущность с идентификатором, именем, началом, концом, офсетом и длительностью
- AND её содержимое несёт маршрут и внутренние ряды исходными байтами
Scenario: Ряд пульса тренировки не становится метрикой
- WHEN тренировка содержит
heartRateData - THEN точки этого ряда не попадают в точки метрик
- AND остаются внутри содержимого сущности
Scenario: Запись состояния разума разбирается
- WHEN тело содержит
data.stateOfMindс элементом, несущимidиstart - THEN разбор отдаёт запись с родом
stateOfMind, идентификатором, меткой времени и содержимым исходными байтами
Scenario: Имя не той формы не уносит тренировку
- WHEN у тренировки с корректными
idиstartполеnameприехало числом - THEN тренировка попадает в результат разбора с пустым именем
- AND её содержимое сохраняется дословно, включая маршрут
Scenario: Конец не той формы не уносит тренировку
- WHEN у тренировки с корректными
idиstartполеendприехало числом - THEN тренировка попадает в результат разбора, а конец равен началу
Scenario: Сущность без идентификатора пропускается
- WHEN элемент покрытой секции не несёт
id, либоidпуст, либоidприехал не строкой - THEN сущность в результат разбора не попадает
- AND факт учитывается счётчиком, а разбор остальных сущностей продолжается
Scenario: Элемент секции не является объектом
- WHEN элемент покрытой секции не разбирается как объект JSON
- THEN сущность в результат разбора не попадает
- AND факт учитывается отдельным счётчиком, а соседние сущности разбираются как обычно
Отдельным, а не общим с «нет id»: доставка, где не разобрался сам элемент, —
это сменившаяся форма секции, а доставка без id — сменившаяся форма
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
failed фоновая свёртка не подбирает никогда, и вместе с одной кривой
тренировкой в него уехали бы записи stateOfMind той же доставки.
Scenario: Элемент секции не объект — свой счётчик
- WHEN элемент покрытой секции пришёл как
null, строка, число или массив - THEN факт учитывается счётчиком «не разобралось как объект»
- AND счётчик «нет
id» не растёт
Scenario: Повтор ключа метки решается последним значением
- WHEN у элемента ключ
startвстречается дважды, и валидная метка стоит последней - THEN сущность попадает в результат разбора с этой меткой
Scenario: Сущность без разбираемой метки времени пропускается
- WHEN элемент покрытой секции несёт
id, но его метка времени не разбирается ни одним из поддерживаемых форматов либо пришла не строкой (включаяnull) - THEN сущность в результат разбора не попадает
- AND факт учитывается счётчиком
- AND поле
dateвместо непонятогоstartне подставляется
Scenario: Сущность со слишком длинным идентификатором пропускается
- WHEN элемент покрытой секции несёт
idдлиннее предела - THEN сущность в результат разбора не попадает
- AND факт учитывается тем же счётчиком, что и отсутствие
id
Scenario: Длительность берётся из тела, а не из интервала
- WHEN тренировка несёт
durationравный91.746при интервалеstart/endв 91 секунду - THEN длительность сущности равна
91.746
Scenario: Тренировка без длительности сохраняется без неё
- WHEN тренировка не несёт
durationлибо оно не является числом - THEN сущность сохраняется, а её длительность остаётся незаполненной
- AND нулём она MUST NOT становиться
Scenario: Нечитаемый конец тренировки не отбрасывает её
- WHEN тренировка несёт
end, который не разбирается - THEN конец сущности равен её началу
- AND исходное значение остаётся в содержимом дословно
Scenario: Незнакомое поле тренировки переживает разбор
- WHEN тренировка несёт поле, которого разбор не знает
- THEN оно сохраняется в содержимом сущности дословно
- AND разбор не завершается ошибкой
Scenario: Непокрытая секция с собственными id остаётся непокрытой
- WHEN тело содержит
data.ecg - THEN
ecgпопадает в список непокрытых ключей - AND сущностей из неё разбор не отдаёт
Requirement: Диагностика разбора не несёт значений из тела
Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно SHALL называть тип встреченного токена и смещение во входе — по смещению место находится в теле, лежащем в архиве, а значение из тела в логе не имеет права быть в принципе.
Тип SHALL называться словарём JSON (object, array, string, number,
bool, null), а не именем типа языка реализации: имя типа для делимитера не
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
поэтому печатается значением.
Смещение SHALL указывать на место перед виновным токеном и от длины его значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего возвращённого токена, поэтому взятая после чтения она отличалась бы от начала проблемы ровно на длину значения — то есть на восемь мегабайт в том самом случае, ради которого требование написано, и обещание «место находится в теле» не выполнялось бы.
Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
чувствительнее токенов, тела запросов пишутся только на DEBUG и с обрезкой.
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
8 МиБ, который уходит атрибутом error на уровень WARN — то есть содержимое
доставки оказывается в логе полностью и без обрезки.
Предел SHALL держаться самим сообщением, а не обрезкой на стороне логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
Правило SHALL распространяться и на чужие причины: ошибка библиотеки разбора
кладёт в текст литерал значения, поэтому причина, приходящая извне, обрезается по
названной длине на границе. Тот же предел SHALL действовать на проверке формы
конверта при приёме — она пользуется той же библиотекой, и её отказ логируется на
DEBUG, где инвариант тоже требует обрезки.
Scenario: Огромное значение не доезжает до текста ошибки
- WHEN тело содержит на месте ожидаемого объекта строку в несколько мегабайт
- THEN разбор завершается ошибкой
- AND длина текста ошибки не зависит от длины этого значения
- 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 идти тремя разрядами:
- пара
(локаль, строка)есть в словаре — её код; - локали нет либо пары нет, но строка известна в других локалях и все они дают один код — этот код;
- иначе — пустой код.
Второй разряд обязателен, а не удобен. Заголовки запроса в сыром архиве не
лежат: они были заголовками запроса, а не телом. Доставка, восстановленная из
осиротевшего тела, приезжает без 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 содержимое точки сохранено дословно и целиком