ADR-0075: Единый каталог строительства; product family как область совместимости
Status: accepted Date: 2026-08-07 Deciders: belkanov, agent-claude
Контекст
Каталог строится один на всё строительство: одна канонная база, один поиск, один публичный API. Отраслевая специфика (электрика, сантехника, крепёж, отделка) обязана входить данными, а не отдельными каталогами и не ветками кода — требование ADR-0072.
Сегодня область сравнения при подборе аналогов задаёт дерево категорий:
кандидатов ищут в категории якоря плюс объявленные ей эквивалентные узлы
(миграция 0289) плюс категории с неразобранными назначениями (0291). Внутри
области кандидат обязан совпасть по всем действующим identity-осям якоря.
Набор осей собирается из двух источников: веса и identity-группы
charsemantics.DefaultAnalogAxisSQL() (in-code литерал, только
электротехнические оси — ток, полюса, кривая, ОКЗ) и роль identity в
category_characteristic_significance (миграция 0282), причём право сужать пул
кандидатов есть только у записей от человека и LLM-авторинга
(KeySignificance.CanTightenCandidateSet).
Обход качества (backend/cmd/analog-quality-sweep, статус
no_effective_identity_keys) показал: у 88% якорей действующих осей нет
вообще. В SQL это значит, что условие
shared_identity_keys = (SELECT n FROM target_identity_keys) не ставится вовсе
(ветка n = 0), и кандидатом становится любой канон области, совпавший с якорем
хотя бы одним значением характеристики. Ранкер при этом честно держит
межклассовую границу — она проходит по области категории, — но внутри области
не знает, что считать аналогом. Отсюда наблюдаемые дефекты: кабель 3×1.5 →
кандидаты 4×25 и 5×35; DC-исполнение в аналогах AC-аппарата (закрыто отдельным
правилом); дифавтоматы в аналогах автомата (закрыто видом
candidate_only_forbidden). Каждый такой случай закрывался точечным правилом,
потому что общего понятия «в какой области этот товар вообще сравним» в модели
нет.
Корень в том, что дерево категорий тянет три несовместимых роли одновременно: навигация витрины, разрез аналитики и область сравнения. Их требования расходятся. Крепёж М10 живёт в десятке категорий и в десятке отраслей — как область сравнения категория его рвёт. Внутри одной категории спецодежды 40 размерных канонов на артикул (разбор 2026-08-04) — как область сравнения категория, наоборот, слишком широка. Пока роль одна и та же, любое движение дерева ради витрины меняет выдачу аналогов, и наоборот.
Решение
-
Один глобальный канонический каталог. Отраслевых каталогов, отдельных индексов и отраслевых веток кода нет. Отрасль входит в систему как данные: профили совместимости и прикладные контексты.
-
Product family — первоклассная сущность и единственная область совместимости. Семья отвечает ровно на один вопрос: «с чем этот товар в принципе сравним». Канон принадлежит ровно одной активной семье. Членство задаётся data-driven правилами отбора (
state = 'auto') и перекрывается решением человека (state = 'manual_override') — та же машина состояний, что у значимости (0282). -
Семья ≠ категория. Дерево категорий остаётся навигацией, витриной и разрезом аналитики; область подбора оно больше не задаёт. У канона — одна primary category (навигация, отчёты, ретеншен категорийных метрик) и множество secondary application contexts (где изделие применяется: щитовое, слаботочка, водоснабжение, фасад). Контексты влияют на поиск, фасеты и подсказки и не влияют на область совместимости.
-
Профиль совместимости семьи — версионируемый документ на данных. Профиль перечисляет оси семьи и их роли. Активная версия одна на семью, она иммутабельна; правка порождает новую версию через
draft → in_review → active. Каждая ось несёт evidence: покрытие в семье, число различных значений, примеры канонов, источник (manual/llm/derived), модель и версию промта, кто и когда утвердил. -
Три роли осей с разными правами.
identity— расхождение делает изделие непригодным, сравнение точное, ось вправе отсекать пул кандидатов.range— расхождение допустимо в одну сторону и ограничено запасом (max_ratio/max_delta), ось отсекает пул направленно.score— влияет только на порядок, отсекать не вправе. Право повысить ось доidentityилиrangeесть только у человека и у LLM, прошедшего ревью; расчёт по данным вправе назначатьscore,ignoredи понижать роль, но не повышать (перенос правилаcanHardenFromRoleна уровень семьи). -
Числовое сравнение — только в СИ и только внутри одного рода величины. Нормализация значений — по ADR-0053; read-model и проекция хранят СИ-значение вместе с кодом рода величины (
quantity_kind), и этот код входит в ключ сравнения. Термин выбран намеренно: у активной и полной мощности физическая размерность одна и та же, но это разные измеряемые величины, и сравнивать их нельзя — коэффициента между Вт и ВА не существует, он зависит от коэффициента мощности изделия. Значит это разные оси профиля, а не разные единицы одной оси. Единицы одного рода величины (мм против дюймов) приводит нормализация на входе, подбор их не видит.compare_paramsхранит только доменные допуски (direction,max_ratio,max_delta,missing_means_reject, enum-группы) и никаких преобразований единиц. Осьrangeбез подтверждённого рода величины не публикуется — остаётсяscore. -
Три режима — метка на кандидате, а не отдельный запрос. Каждый кандидат выдачи несёт
mode:exact(точный аналог),substitute(допустимая замена «не хуже»),review(неточный кандидат, требует подтверждения) — с машинно читаемой причиной. Недопустимые кандидаты в выдачу не попадают вовсе. Существующие потребители продолжают читать тот же список; метка аддитивна.Пропущенные и неразобранные значения не дают точности.
exactиsubstituteвозможны, только когда каждая активная осьidentityиrangeопубликованного профиля сравнима у якоря и у кандидата — значение есть, род величины известен, разбор удался. Набор осей не сужается признаком «у якоря значения нет»: такое сужение молча выводило бы незаполненную identity-ось из-под проверки. Любой пропуск опускает кандидата доreview; отсутствие данных никогда не читается ни как совпадение, ни как расхождение, а полнота карточки якоря становится измеримым качеством данных.Метка не является разрешением выбрать. Автоматический выбор кандидата (смета, конвейер цен, интеграции) допустим только для
exact;substituteтребует явной политики потребителя или подтверждения человеком;reviewне выбирается автоматически никогда. Флаг семьи включается только после e2e-проверки этих правил. -
Условия совместимости выполняются на этапе поиска кандидатов, а не только post-rank. Оси
identity,rangeи направленные запреты обязаны отсекать кандидатов до усечения пула; post-rank оставляет за собой объяснение, метку режима и порядок. Причина: пул усекается по весу и по потолку на ключ, поэтому условие, применённое после среза, теряет годных кандидатов молча. Несравнимость на этапе отбора не отсекает — кандидат обязан дойти до меткиreview. -
Путь подбора выбирается один раз и без смешения. Нет семьи, нет опубликованного профиля или флаг семьи выключен → старый путь по категории, поведение сегодняшнее, режимов в ответе нет. Семья включена и профиль опубликован → только family-path и режимы; категорийные правила и значимость к таким канонам не применяются.
review— исход нового пути при неполных данных, а не заглушка для неготовой семьи. Рантайм читает только версию профиля в статусеactive;draftиin_reviewживут в авторинге и на family-path не попадают, поэтому статус профиля не входит в правило вывода режима. -
Elasticsearch в этом цикле — только теневой замер. Проекция получает поля совместимости, теневой прогон сравнивает состав и порядок с PostgreSQL, выдачу клиенту по-прежнему отдаёт PostgreSQL. Переключение источника кандидатов — предмет отдельного решения по итогам замера.
Последствия
Плюсы
- Область сравнения перестаёт зависеть от формы дерева: перенос узла ради витрины больше не меняет выдачу аналогов.
- Семья даёт место, где живёт профиль: версия, evidence, ревью, откат. Сегодня этого места нет, и каждое доменное знание оседает отдельным правилом категории.
- «Не знаем, что считать аналогом» становится наблюдаемым состоянием: на
family-path кандидат с неполными данными получает метку
reviewи причину. Молчаливая выдача случайных соседей по значению как точного аналога прекращается. - Отраслевое расширение — строка в данных, а не ветка кода:
charsemanticsиз источника истины становится seed-набором холодного старта. - Размерные сетки и расщеплённые каноны перестают быть проблемой подбора: они внутри одной семьи, различаются identity-осью размера и честно расходятся по режимам.
Минусы
- Новая сущность требует наполнения на 1.3 млн канонов. Без правил отбора это ручной труд; правила отбора — новый источник ошибок членства.
- Два пути сосуществуют весь переходный период: включённая семья и старый путь по категории ведут себя по-разному, и одна и та же карточка до и после включения флага выглядит иначе. Раскатка обязана идти по семьям и сопровождаться замером, иначе продуктовая видимость меняется скачком.
- Потребители обязаны различать режимы до включения первой боевой семьи: смета, публичный API и интеграции получают новую обязанность объяснять отказ от автоподстановки.
- Отбор по семье с числовыми условиями требует нового read-model. Диск и IO на DB-VPS — узкое место (QLC, аудит индексов 2026-08-02), цена обязана быть измерена до применения.
- LLM-авторинг профилей делит месячную квоту с charnorm, matcher и сметой.
Нейтральные последствия
category_analog_rulesиcategory_characteristic_significanceостаются единственным источником правил на старом пути (§решение, п. 9). На family-path источник один — профиль; шестого параллельного механизма не появляется, промежуточных состояний тоже.- Метка режима не меняет существующие поля ответа: клиент, не читающий
mode, получает тот же список, что и раньше. Обязанность различать режимы возникает только у автоматических потребителей на family-path.
Рассмотренные альтернативы
A. Отраслевые каталоги
Отдельный каталог (или индекс) на отрасль. Отвергнуто: изделие, применимое в нескольких отраслях (крепёж, кабель, метизы, трубы), придётся дублировать, а дубли разъедутся по ценам, остаткам и модерации. Единый каталог — исходное продуктовое требование.
B. Семья = группа эквивалентности категорий (category_equivalence, 0289)
Механизм уже в проде и наполняем. Отвергнуто: он лечит расщепление дерева, а не задаёт область сравнения. Семья, привязанная к узлам дерева, наследует его форму — крепёж и сантехника, разбросанные по десяткам узлов, собираются плохо, а профиль пришлось бы вешать на группу узлов вместо изделия. Эквивалентность остаётся как есть — источником кандидатов членства в семье и совместимостью на переходный период.
C. Оставить категорию областью и добить осями значимости
Продолжение текущего пути: наполнять category_characteristic_significance до
покрытия всех категорий. Отвергнуто: обход показал, что расширение таблицы
значимости само по себе охват полки не поднимает (разбор 2026-08-05), а роли
осей приходится дублировать в каждом узле дерева, где встречается изделие. Плюс
сохраняется корневая связка «форма дерева = выдача аналогов».
D. Сразу перевести отбор кандидатов на Elasticsearch
Отвергнуто на этот цикл: наполнение профилей и смена источника кандидатов — два больших риска одновременно, а сравнивать выдачу будет не с чем. Теневой замер даёт цифры для отдельного решения.
Ссылки
- Design spec:
docs/superpowers/specs/2026-08-07-product-family-compatibility-design.md - ADR-0053 (единицы СИ), ADR-0054 (pgvector-аналоги), ADR-0055 (категории и ETIM-хребет), ADR-0064 (слои и инвариант нормализации), ADR-0070 (идентичность матчинга), ADR-0072 (расширяемость вместо хардкода), ADR-0073 (ручная модерация идентичности), ADR-0074 (нормализация характеристик)
- Обход качества:
backend/cmd/analog-quality-sweep,backend/internal/core/catalog/canonical/app/qualitysweep/ - Миграции: 0234 (lookup аналогов), 0282 (единая значимость), 0289
(эквивалентность категорий), 0290 (
candidate_only_forbidden), 0291 (неразобранные назначения)