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 | замена без запаса |
| Кандидат строго шире | rangeWithinCap → substitute | запас есть, точность потеряна |
| Кандидат не покрывает (уже, смещён, пересекается частично) | rangeViolated → rejected | доказанное расхождение |
| Кандидат шире потолка запаса | rangeOverCap → rejected | запас за пределом допустимого |
Потолок запаса необязателен, в отличие от осей 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-оси,
перечислять роли заново.