Files
healthlog/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/design.md
T
av 03edf1087d Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
2026-08-02 19:23:59 +03:00

41 KiB
Raw Blame History

Context

Витрина уже держит одну метрику в нескольких слоях одновременно (sample/raw/minute/hour/day) и своей агрегации при записи не делает. Read API обязан уметь свести ряд к запрошенной сетке — но правильная свёртка зависит от рода метрики, а рода в потоке нет:

  • форма точки его не выдаётAvg/Min/Max есть только у heart_rate, заведомо мгновенные walking_speed и blood_oxygen_saturation приходят в qty ровно так же, как шаги (находка 40);
  • заголовок доставки не описывает данныеautomation-aggregation принимает значение Default у трёх разных режимов (находка 31);
  • единицы дают процентов девяносто и ломаются на краяхsix_minute_walking_test_distance в метрах складывать нельзя, а walking_running_distance в километрах можно.

Зато витрина содержит собственную сверку: у одной метрики есть и минутный, и часовой разрез за один и тот же час, и они приходят из разных автоматизаций. Часовое значение либо равно сумме минутных, либо их среднему — и это наблюдаемое различие.

Prior art по этому вопросу богат, но весь он про объявление рода, а не про измерение: HealthKit зашивает HKQuantityAggregationStyle в тип метрики, Home Assistant получает state_class от интеграции, Graphite выводит aggregationMethod регуляркой по имени метрики, Prometheus/Datadog/Splunk принимают тип от отправителя. Прецедента вывода рода из значений нет ни одного — и паспорт проекта предупреждал об этом заранее.

Goals / Non-Goals

Goals:

  • Род метрики (cumulative / instant / unknown) выводится измерением на накопленных данных, без ручной разметки и без списка имён в коде.
  • Потребитель одним запросом узнаёт, что в хранилище есть: метрики, единицы, слои с диапазонами и числом точек, род и основание, на котором он измерен.
  • Каталог отдаёт наблюдаемое состояние витрины и ничего не досчитывает: период, у которого верхние слои не пересобираемы, честно объявляет то, что есть.
  • Стоимость каталога не растёт вместе с историей.

Non-Goals:

  • Свёртка ряда в ответе. Каталог отвечает «какая свёртка осмысленна», сама свёртка — задача Read API.
  • Правило неполного ведра (xFilesFactor). Оно нужно свёртке, а не измерению — см. решение 6.
  • Тай-брейк при равной полноте точек. Вынут блокером — решение 8.
  • Каталог сущностей (workout, record). Тренировки и записи адресуются своим id, слоёв у них нет; их перечисление приезжает вместе с их же маршрутами Read API.
  • Хранение измеренного рода. Решение 2.

Decisions

1. Род измеряется двумя конкурирующими гипотезами и единогласием

Для каждого часа, за который у метрики есть и минутный, и часовой объект:

час пригоден, если
   час не позже текущего времени плюс час
   единицы обоих объектов совпадают
   часовой объект несёт ровно одну точку, и она несёт значение
   метка этой точки совпадает с началом часа
   у минутного не меньше двух точек со значением
   сумма минутных и их среднее сами различимы

вердикт пригодного часа:
   часовое ≈ сумма минутных    → cumulative
   часовое ≈ среднее минутных  → instant
   иначе                       → час свидетельства не даёт

Вердикт метрики: не меньше трёх согласных часов и ни одного противоречащего. Иначе — unknown, и свёртка по метрике не предлагается вовсе. Наличие противоречащих часов пишется WARN: род — свойство, на котором Read API строит арифметику года, и его смена не имеет права проходить молча.

Все три сравнения — один предикат с одним допуском, относительным, величиной 1e-9. Это не аккуратность, а устранение целого класса: напиши «различимость» точным неравенством, а «сходимость» с допуском — появится час, подтверждающий обе гипотезы сразу, и его исход молча определит порядок веток if. При одном предикате такой час невыразим.

Величина названа числом, потому что от неё зависят счётчики основания в ответе. Измерено на живом корпусе: вердикты метрик одинаковы при допуске от 1e-9 до 1e-3, а число согласных часов у heart_rate при этом меняется с 29 на 49 — то есть выбор не влияет на вывод, но влияет на то, что мы о нём сообщаем. Взято строгое: канонизация содержимого округляет числа до 12 значащих цифр, значит всё крупнее 1e-12 представлением не объясняется; 1e-9 оставляет три порядка запаса и остаётся на шесть порядков строже любого содержательного расхождения — сумма и среднее при n ≥ 2 различаются не меньше чем вдвое.

Абсолютного порога нет намеренно: второй константы, которую пришлось бы объяснять, задача не заводит. Цена названа вслух — при обоих нулях предикат истинен, и от нулевого часа защищает не он, а проверка различимости. Полагаться на «около нуля не сходится» нельзя: там ровно наоборот.

Горизонт закрывает подделку и сбитые часы. Час объекта берётся из метки в теле доставки, а тело не наше: без верхней границы одна доставка с метками в будущем занимает окно целиком и подменяет измеренный род. Путь построен и прогнан — мгновенная метрика объявлялась накопительной при нуле противоречащих часов, то есть с виду безупречным основанием. Часы позже now + час в окно не входят, а сам факт данных из будущего пишется WARN: сбитые часы телефона и чужое тело в приёме лечатся не кодом.

Единицы обеих сторон обязаны совпасть. Мгновенная метрика в count/min минутным слоем и в count/hour часовым даёт в полном часе часовое = 60 · среднее = сумма — уверенный ложный cumulative, которого правило единогласия не ловит по построению: противоречия нет, есть молчание. Единицы лежат в том же покрывающем индексе, так что проверка не стоит ничего.

Выравнивание часовой метки закрывает получасовые пояса. Слой выводится по выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне +0530 часовая точка попадает на середину часа UTC и описывает не тот интервал, который покрывают минутные точки того же объекта. Сравнивать их нельзя. Условие стоит копейки, а закрывает класс целиком — на корпусе одной зоны его не воспроизвести, и потому оно и записано правилом, а не оставлено на «когда поедем».

Измерено на живом архиве (123 доставки, 31 метрика, окно — все общие часы):

исход метрик
cumulative 7 (active_energy, basal_energy_burned, step_count, walking_running_distance, apple_stand_time, apple_exercise_time, time_in_daylight)
instant 9 (heart_rate, respiratory_rate, blood_oxygen_saturation, environmental_audio_exposure, walking_speed, walking_step_length, walking_double_support_percentage, walking_asymmetry_percentage, stair_speed_up)
unknown 15
противоречащих часов 0 на всём корпусе

Числа сняты при допуске 1e-9 и окне в 48 часов. Полный обход всей истории дал бы instant ещё и physical_effort (5 согласных часов за всё время против 2 в свежем окне) — окно честно уводит редкие метрики в unknown, и это то же правило, а не издержка.

Три части правила стоят каждая своей причины.

Фильтр различимости — не украшение. Без него walking_asymmetry_percentage давала 4 часа «накопительная» против 3 «мгновенная»: в нулевом часе сумма равна среднему, и «сходится с суммой» выполняется тождественно. Час, в котором гипотезы неразличимы, свидетельством не является.

Порог в три часа. Один совпавший час — свидетельство одного часа, а на роде потом суммируют год. Цена измерена: порог уводит в unknown ровно одну метрику (headphone_audio_exposure, один согласный час).

Единогласие, а не большинство. Противоречие означает, что одна из гипотез ложна для этой метрики; большинство голосов позволило бы объявить род при известном контрпримере. Измеренная цена этого решения — ноль: конфликтов нет.

Альтернативы отвергнуты:

  • Разметка руками (так делают все, кроме нас) — она и есть то, от чего задача уходит: список из сотни метрик Apple, который устареет в день появления новой.
  • Вывод по имени метрики (Graphite, pattern = \.count$) — противоречит инварианту «форма Apple не транслируется» и не работает на именах HAE вовсе.
  • Вывод по единицам — ломается на краях (находка 40).
  • Заголовок доставки — врёт уже про слой, оснований верить про род нет.
  • Детекция сброса счётчика (Prometheus rate, Home Assistant total_increasing с допуском 10%) — отвечает на другой вопрос: «был ли рестарт у известного счётчика», а не «счётчик ли это». К данным Apple неприменима: монотонного накопителя в них нет, накопительная метрика приходит уже поинтервальными значениями.

2. Родов два, а не четыре — потому что больше нечем измерить

HealthKit различает четыре стиля: cumulative, discreteArithmetic, discreteTemporallyWeighted (пульс) и discreteEquivalentContinuousLevel (аудиоэкспозиция, логарифмическое усреднение по энергии). Взять весь словарь напрашивалось — и отвергнуто измерением: часовой слой HAE считается арифметически, а не по Apple.

Прямое свидетельство даёт environmental_audio_exposure: Apple усредняет её логарифмически, а часовое значение HAE сошлось с обычным арифметическим средним минутных в 59 часах из 62. У heart_rate, который Apple взвешивает по длительности, часовое значение сходится с арифметическим средним точно в 29 часах из 63 и с точностью 0.1% — в 49; с суммой не сошлось ни разу.

Значит четвёртый и третий стили в наших данных ничем не проявляются, и ввести их можно было бы только разметкой руками — то есть тем, от чего задача уходит. Правило проекта прежнее: род, который нечем измерить, не объявляется. Появится источник, различающий больше родов (родной экспорт Apple несёт интервалы сэмплов), — словарь расширится тем же измерением.

3. Род не хранится, а считается на запрос по ограниченному окну

Хранить измеренный род означало бы завести второе производное состояние рядом с витриной: колонку, которую надо пересчитывать после каждой свёртки, переносить или не переносить пересборкой (перечень в architecture.md), мигрировать и объяснять, на каком составе данных она измерена. Цена ошибки здесь — молчаливая: устаревшее значение выглядит ровно как свежее.

Считанный на запрос род — по построению функция текущей витрины, а витрина есть функция журнала. Устареть нечему.

Плата — стоимость чтения, и она ограничена окном в 48 самых свежих общих часов метрики. Измерено на живом корпусе: полное измерение по всем 696 парам часов всех 31 метрики — 123 мс; окно даёт тот же результат и не даёт стоимости расти вместе с историей (за год окно ограничивает работу 1488 парами вместо 270 тысяч).

Кеш в памяти сознательно не заводится: он вводит третье представление того же факта, а вопрос его инвалидации («изменился ли хоть один объект окна») стоит дороже самого измерения. Появится профиль нагрузки, показывающий обратное, — кеш добавится с числом в руках.

4. В измерении участвуют только minute и hour

Нижний слой HAE — посекундная развёртка настоящих сэмплов с инфляцией 2.4× (находка 20) и до 478× у базального обмена (находка 34); его сумма завышена, и в сверке он не сходится. Слой sample из родного экспорта в витрине пока пуст, а его точки несут собственные интервалы — их сверка с часовым слоем это другая задача (healthlog import).

day в измерении не участвует: суточная сводка сна — не разрез часов, а другая схема под тем же именем (находка 38).

5. Значение точки — qty, а при его отсутствии Avg

Единственное место, знающее, какое поле точки HAE несёт число, — пакет hae. Порядок именно такой: qty несут все метрики, Avg — только heart_rate (находка 40), и без второго кандидата самая важная метрика потока не измерялась бы вовсе. Точка, не несущая ни того, ни другого, в сумму не входит и число точек часа не увеличивает.

Это чтение, а не интерпретация: значение никуда не пишется и ничего не подменяет.

Ноль — значение. Словарь пустоты из canon сюда не годится и применяться не должен: там ноль объявлен пустотой, чтобы точка без измерений не вытесняла настоящее измерение при столкновении координат, — вопрос другой. Взяв его, измерение не увидело бы точки {"qty":0}, час выпал бы из счётчиков ещё до правила различимости, и сценарий «нулевой час свидетельством не является» позеленел бы по неверной причине. Поэтому разбор здесь свой: *json.Number для обоих полей, отсутствие ключа и null — «нет значения», ноль — значение.

Бесконечность — не значение. json.Number("1e400").Float64() возвращает +Inf вместе с ErrRange; проглоченная ошибка отравила бы и сумму, и среднее всего часа. Значение, не разобравшееся в конечное число, считается неприсланным.

6. xFilesFactor здесь не нужен, и это сказано вслух

Graphite и RRDtool закрывают вопрос «что делать со свёрткой неполного ведра» долей заполненности: ниже порога — не число, а пусто. Паспорт называл это готовым ответом на вопрос, который у нас ещё не задан.

Измерению порог не нужен, потому что у него две конкурирующие гипотезы, а не одна: неполный минутный час не сходится ни с суммой, ни со средним и свидетельства не даёт сам собой. Это видно в измерении — у step_count 25 часов согласны и 17 не дали ничего; ровно эти 17 и есть неполные часы.

Свёртке в ответе порог понадобится, и вместе с ним — выбор полярности: Graphite xFilesFactor задаёт долю обязательно известных (умолчание 0.5 при роллапе и 0 при рендере — один параметр с двумя умолчаниями), RRDtool xff — долю допустимо неизвестных, то есть ровно наоборот. Обе величины будут выглядеть как «0.5», означая противоположное. Решение и его полярность принимает задача Read API; здесь оно названо, чтобы не решалось дважды.

7. Разрезы отвечают по индексу, окно читается пакетом, ответ — из одного снимка

Границу надо назвать точно, иначе она запрещает то, ради чего задача есть: не разжимается содержимое ради разрезов и границ; объекты окна измерения разжимаются обязательно — сумма минутных значений иначе невычислима. Разжатых объектов не больше 2 × 48 на метрику.

Диапазоны и число точек лежат учётными колонками объекта (first_ts, last_ts, points), но bucket — таблица WITHOUT ROWID, то есть строка целиком, вместе с payload, живёт в самом дереве первичного ключа. Обход всех строк ради агрегата тащил бы за собой страницы сжатого содержимого: при 260 тысячах объектов за год это сотни мегабайт на каждый запрос каталога.

Поэтому миграция 00009 заводит покрывающий индекс bucket(metric, layer, hour_utc, first_ts, last_ts, points, units): и агрегат разрезов, и поиск общих часов двух слоёв читают только его. Цена — около 60 байт на объект (≈16 МБ за год) и одна вставка в дерево на запись объекта.

Данных индекс не меняет, поэтому в перечне того, что не переносит пересборка, ему места нет.

Содержимое разжимается только у часов, прошедших отбор по учётным колонкам. Число точек и единицы обеих сторон лежат в покрывающем индексе, а условия пригодности «у крупного слоя ровно одна точка, у мелкого не меньше двух» проверяются по ним. Замер на раздутой витрине: 109 МиБ аллокаций при нуле пригодных часов — вся работа шла до того, как выяснялось, что вердикта не будет. Отбор ПРЕДВАРИТЕЛЬНЫЙ и строго слабее правила вердикта: числа задаёт домен, хранилище лишь выбирает по ним строки.

Объекты окна берутся пакетом и в одной транзакции чтения со всем остальным. Существующий Store.Bucket открывает собственную read-only транзакцию на каждый вызов: окно в 48 часов дало бы под сотню транзакций на метрику, а ответ собрался бы из смеси снимков — разрезы одного состояния витрины, род другого, причём под непрерывным приёмом и неотличимо от обычного свежего ответа. Проект уже записал это рассуждение у отпечатка витрины, и второй раз оно разошлось бы молча.

Поэтому хранилище отдаёт каталогу один снимок: агрегат разрезов, общие часы каждой метрики и объекты её окна — за одну транзакцию чтения, двумя запросами на метрику плюс один общий. Число обращений к базе перестаёт зависеть от размера окна. В WAL длинная транзакция чтения писателей не блокирует, а измеренные 130 мс на живом корпусе — цена, которую видно.

8. Тай-брейк при равной полноте точек не трогаем — вынут блокером

Задача обещала доделать его «по каталогу», и измерение действительно подтвердило посылку: четыре из шести метрик, где тай-брейк системно берёт меньшее значение (находка 49), измерены как накопительные — то есть там это недосчёт, а у heart_rate (самая крупная группа) род мгновенный, и выбор безразличен.

Но сделать тай-брейк зависящим от измеренного рода нельзя: род есть функция витрины, витрина — результат слияния, и правило слияния, читающее собственную выдачу, повторяет ровно тот дефект, на котором свёртка уже переставала быть функцией префикса журнала (docs/review-journal.md, 2026-08-01). Остаются варианты, не зависящие от рода, и выбор между ними — развилка с ценой; она уходит блокером вместе с измеренным основанием.

9. Каталог живёт под токеном чтения

GET /api/v1/metrics — первый маршрут, который отдаёт данные наружу, поэтому здесь же появляется проверка auth.read_tokens. Правило то же, что у приёма: пустой список означает выключенную проверку, и о ней сервис предупреждает на старте. Токен приёма каталог не открывает — раздельность контуров объявлена архитектурой, и «пишущий умеет читать» её бы отменило.

Цена симметрии названа вслух, потому что она несимметрична: у приёма открытый контур означает мусор во входе, у чтения — выгрузку истории здоровья любому, кто нашёл порт. Отказ старта при пустом списке рассматривался и не взят здесь: сегодня оба образца конфига в репозитории идут с пустыми списками сознательно (доверенная локальная сеть), и такой отказ сломал бы task up до правки конфигов, заведя асимметрию с приёмом, которую пришлось бы объяснять. Вопрос принадлежит задаче об управлении секретами — он там уже стоит, и эта задача добавляет ему второй контур, а не заводит третье место для того же решения.

Проверка одна на оба контура, параметризованная списком: копия отличалась бы одним полем и несла бы три решения сразу — сравнение за постоянное время, «пустой список = выключено» и текст 401, — правка любого из них в одном месте не дала бы ни ошибки компиляции, ни красного теста.

Схема строгая: токеном считается только Authorization: Bearer <значение>. Снисходительности к голому значению у приёма нет и не было; заводить её на контуре чтения, клиенты которого свои, тем более не за чем.

Отдельно — редакция заголовков: сохраняемые заголовки доставки чистятся подстановкой по списку токенов, и сегодня в этом списке только токены приёма. Токен чтения, посланный заголовком с произвольным именем, осел бы в базе; список становится общим.

10. Форма ответа

{"metrics": [
  {"metric": "step_count",
   "units": ["count"],
   "aggregation": {"style": "cumulative",
                   "hours": 48, "compared": 40, "agreeing": 25, "conflicting": 0,
                   "first_hour": "2026-07-31T09:00:00Z",
                   "last_hour": "2026-08-02T14:00:00Z"},
   "layers": [
     {"layer": "minute", "from": "2026-07-30T21:48:00Z",
      "to": "2026-08-02T14:59:00Z", "points": 1102},
     {"layer": "raw", "from": "…", "to": "…", "points": 25636}]}]}
  • unitsмассив: единицы метрики на живом потоке не менялись ни разу (находка 48), но одна форма поля для обоих случаев честнее строки, которая при расхождении молча выберет одно из двух. Та же форма, что у самоописания. На слой при этом приходится ровно один элемент layers: строки выборки, разошедшиеся единицами, схлопываются в общий диапазон и общую сумму точек, а различие видно множеством единиц метрики. Не поручить это схлопывание явно значило бы отдать клиенту два элемента с одинаковым layer в тот единственный день, ради которого units и сделали массивом.
  • aggregation — объект, а не строка: он несёт основание, и числа в нём подобраны так, чтобы их разности были осмысленны. hours — сколько общих часов попало в окно, compared — сколько из них оказалось пригодными, agreeing и conflicting — вердикты пригодных. hours compared — часы, отброшенные проверкой пригодности; compared agreeing conflicting — часы, не сошедшиеся ни с одной гипотезой. Одного числа не хватало: «часов было 48, а пригодным не оказалось ни одного» и «часов не было вовсе» — разные события.
  • Поле называется style, а не kind: слово kind в проекте уже занято родом секции записи (record.kind), и два смысла под одним именем в одном API — это сноска в документации навсегда. style — слово HealthKit (HKQuantityAggregationStyle) для ровно этого понятия.
  • Значения рода остаются cumulative / instant / unknown. cumulative совпадает со словарём HealthKit; instant не совпадает ни с чьим (у Apple discrete, у Prometheus gauge, у Home Assistant measurement) — и взят сознательно: discrete описывает природу сэмпла, а мы называем то, что измерили, — свёртку средним. Архитектура пользуется словом «мгновенная» с самого начала, и менять словарь ради чужого сходства значило бы переименовать понятие, не изменив его.
  • first_hour/last_hour вместо from/to — потому что это ярлыки часов, а не метки данных: у слоя to — метка последней точки (…14:59:00Z), у окна — начало последнего часа окна (…14:00:00Z), включая непригодные. Одно имя для двух семантик в одном ответе стоило бы клиенту ошибки на час, заметной только расхождением сумм.
  • layers — только то, что есть. Числа часовых объектов в ответе нет: объект — деталь хранения, клиент про него не знает.
  • Поля присутствуют всегда, в том числе со значением null: клиент не должен выводить смысл из наличия или отсутствия ключа. Это требует внимания к нулевым значениям Go: nil-срез сериализуется в null, а нулевой time.Time — в правдоподобную метку 0001-01-01T00:00:00Z, неотличимую от данных. Поэтому срезы конструируются пустыми, границы окна — указателями, а приёмочный тест сравнивает байты ответа с литералом, а не разобранную структуру с разобранной.
  • Порядок метрик и слоёв детерминирован: два ответа на неизменившейся витрине обязаны совпасть побайтово, иначе «повторный запрос не опирается на прошлый» нечем проверить.

Risks / Trade-offs

  • Род измеряется по свежему окну, а метрика могла его сменить в прошлом → окно и его границы отдаются в ответе (from/to, compared), то есть род объявлен вместе с периодом, на котором измерен. Так же поступает Home Assistant, признавая смену state_class разрушительным событием, а не уточнением поля.
  • Метрика приходит только в одном слое — род не измерить никогда → штатный unknown с compared: 0. Сегодня это 14 метрик из 31, в том числе sleep_analysis и heart_rate_variability. Лечится не кодом, а второй автоматизацией HAE на том же наборе метрик.
  • Стоимость каталога растёт с числом метрик (~4 мс на метрику на живом корпусе) → окно ограничивает вклад каждой; при сотне метрик это порядка полусекунды. Число измерено и попадёт в отчёт; кеш заводится по профилю нагрузки, а не заранее.
  • Часовой слой HAE — тоже досчитываемое задним числом значение (находка 10) → свежайший час окна может быть неполным и вердикта не дать. На исход это не влияет: неполный час просто не свидетельствует, а окно в 48 часов заведомо содержит устоявшиеся.
  • Каталог метрик не говорит о невосстановимости stateOfMind — секция живёт в record, а не в метриках, и её единственный источник это доставки HAE (находка 46). Граница названа: за это отвечает ретеншен и перечень непокрытого, а не каталог разрезов.
  • Покрывающий индекс удорожает запись объекта → одна вставка в дерево на объект; широкий проход, у которого хеш сошёлся, объект не переписывает вовсе, поэтому цену платят только настоящие изменения.
  • Род дребезжит вместе со скользящим окном: час, въехавший в окно, может сменить cumulative на unknown без единой новой доставки за спрошенный период, и для агента это выглядит поломкой сервиса → следствие принято вслух и снабжено двумя средствами. Первое — основание измерения в ответе: клиент видит, что изменилось и почему. Второе — WARN при появлении противоречащих часов: событие адресовано владельцу, потому что лечится оно настройкой автоматизаций HAE, а не кодом. Смягчать правило долей согласных вместо единогласия отвергнуто: это объявление рода при известном контрпримере.
  • Пустой список токенов чтения открывает историю здоровья → предупреждение на старте и запись цены в образцах конфига; отказ старта рассмотрен и оставлен задаче об управлении секретами (решение 9). До выкладки наружу это домашняя сеть, после — блокирующее условие деплоя, и оно уже записано там.
  • Каталог — первая ручка, где повторный запрос стоит заметного CPU (порядка 4 мс на метрику) → предела на размер ответа и тайм-аута у него нет, потому что и то, и другое — правило Read API, которое пишется следующей задачей вместе с остальными его маршрутами. Названо, чтобы не оказалось забытым.