ADR-0077: Направленное вложение интервалов как отдельный вид сравнения

Status: accepted Date: 2026-08-08 Deciders: владелец каталога, backend/catalog

Контекст

Замер семьи ИБП 2026-08-08 (200 якорей, профиль v2) дал exact 1, substitute 4, review 4171. Один из трёх блокеров — интервальные значения: 959 фактов входного напряжения хранятся диапазоном ({"unit":"V","values_canonical":["140","265"]}), и ни один существующий вид сравнения их не берёт.

Почему не берёт:

  • Точная ось сравнивает хеш значения. Интервал — не точка, и хеша, равного хешу другого интервала, у него не будет ни при каком совпадении смысла.
  • Направленная ось (range + direction) написана под сравнение ЧИСЕЛ: read-model кладёт в неё si_value_lo = si_value_hi = value_canonical, а compareRange спрашивает «граница кандидата не хуже границы якоря». К интервалу это неприменимо: у диапазона 140–265 В нет «одной границы», по которой он лучше или хуже диапазона 187–242 В.

При этом требование к таким характеристикам сформулировано физикой, а не статистикой: изделие с рабочим диапазоном 140–265 В заменяет изделие, рассчитанное на 187–242 В, потому что покрывает весь его диапазон. Обратная замена недопустима: в точке 150 В кандидат с более узким диапазоном выключится.

Соблазн свести интервал к точке (взять середину, нижнюю границу, ширину) даёт внешне работающее сравнение и физически неверный ответ: середина 202,5 В у обоих диапазонов совпадает, а замена в одну сторону смертельна для нагрузки.

Решение

Вводится третий вид сравнения — interval_contains, значение поля compare_kind у оси с ролью range.

Семантика (fail-closed, направленная): кандидат допустим тогда и только тогда, когда его рабочий интервал ПОЛНОСТЬЮ покрывает интервал якоря:

candidate.lo <= anchor.lo  AND  candidate.hi >= anchor.hi

Совпадение границ допустимо и означает точное соответствие. Более узкий интервал эквивалентом не считается никогда — ни при каком перекрытии, ни при какой доле общей длины.

Градация исхода повторяет остальные направленные оси:

СоотношениеИсходСмысл
Границы совпалиrangeEqual → право на exactзамена без запаса
Кандидат строго ширеrangeWithinCapsubstituteзапас есть, точность потеряна
Кандидат не покрывает (уже, смещён, пересекается частично)rangeViolatedrejectedдоказанное расхождение
Кандидат шире потолка запасаrangeOverCaprejectedзапас за пределом допустимого

Потолок запаса необязателен, в отличие от осей ge/le. Причина в устройстве отношения: у направленной оси «не хуже» без потолка автомат на 16 А получал бы в аналоги автомат на 250 А, потому что «больше» не ограничено ничем. Вложение ограничено самим требованием покрытия с двух сторон: чтобы стать абсурдно широким, кандидат должен быть абсурдным изделием. Если для конкретной характеристики это всё же нужно, max_ratio ограничивает отношение ШИРИН (candidate_width <= anchor_width * max_ratio), а max_delta — превышение ширины в единицах СИ. Вырожденный интервал якоря (нулевая ширина) делает кратный потолок неприменимым — ровно как у ge/le, и тогда нужен max_delta.

Fail-closed действует внутри сравнимости, а не вместо неё. Отсутствие значения, многозначность, несовпадение рода величины и нечисловое значение по-прежнему дают review, а не отказ: «не знаю» — это не «отличается» (ADR-0076). Fail-closed означает другое: любая СРАВНИМАЯ пара, для которой покрытие не доказано, отклоняется. Право на отказ появляется только там, где обе стороны разобраны и сопоставимы.

Направление (direction) у этого вида не требуется и игнорируется. Направленность заложена в само отношение: покрывает кандидат якорь или нет — вопрос несимметричный, и второй параметр направления только создавал бы противоречивые комбинации.

Read-model хранит настоящие границы факта. Интервальный факт (values_canonical длиной ≥ 2) кладётся как si_value_lo = первый элемент, si_value_hi = последний; точечный факт по-прежнему как lo = hi. Схема canonical_family_axis_lookup (0350) это уже допускает — новых столбцов не нужно, интервал отличается от точки условием lo <> hi.

Доступность ограничена черновиками и теневым замером. Публикация профиля, содержащего ось interval_contains, отвергается, пока не поднят deploy-managed рычаг FAMILY_INTERVAL_CONTAINMENT_PUBLISHABLE (по умолчанию false). Авторинг вправе предлагать такую ось, теневой замер вправе её мерить, клиентская выдача её не увидит до отдельного человеческого решения.

Последствия

Плюсы

  • 959 фактов напряжения в семье ИБП перестают быть невидимыми для отбора: сегодня они не попадают в read-model вовсе, то есть дают review там, где ответ физически определён.
  • Отношение записано так, как оно есть в предметной области, а не сведено к числу. Ошибка «взяли середину» становится невозможной по устройству.
  • Отказ по этому виду доказуем: обе границы известны и сопоставимы, иначе сравнение не состоялось бы.

Минусы

  • Третий вид сравнения — третья ветка в четырёх местах (вывод режима, read-model, pre-limit SQL, авторинг). Расхождение между ними даст выдачу, где отбор и разметка спорят друг с другом; удерживается дифференциальным тестом.
  • Авторинг обязан различать «диапазон рабочего напряжения» и «номинальное напряжение»: неверно выбранный вид сравнения превращает физическое требование в бессмыслицу. Обоснование оси словами остаётся обязательным.

Нейтральные последствия

  • Профили, не использующие interval_contains, не меняются никак: ветка включается значением поля, которого у них нет.
  • Существующие точечные факты продолжают жить как lo = hi и сравниваются тем же кодом.

Рассмотренные альтернативы

Свести интервал к точке (середина/нижняя граница/ширина). Отвергнуто: две разные по смыслу пары дают одинаковую точку, и замена проходит в сторону, где изделие выключится. Ошибка при этом молчаливая — выдача выглядит наполненной.

Считать эквивалентом любое пересечение диапазонов. Отвергнуто: пересечение не гарантирует работу в оставшейся части диапазона якоря, а именно там и находится случай отказа.

Расширить ge/le до интервалов, не вводя нового вида. Отвергнуто: направление у покрытия не имеет смысла, а обязательный потолок запаса — тем более. Совмещение дало бы ось, у которой половина полей игнорируется в зависимости от данных, и это ровно та молчаливая неоднозначность, из-за которой интервалы выпали в первый раз.

Отдельная роль (role = 'interval') вместо вида сравнения. Отвергнуто: роль отвечает на вопрос «участвует ли ось в допустимости», а не «как сравнивать». Расширение ролей заставило бы каждое место, различающее eligibility-оси, перечислять роли заново.

Связанные решения

  • ADR-0075 — область совместимости и профиль семьи.
  • ADR-0076 — положительная ось обязательна для exact; «не знаю» не равно «отличается».