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) — как область сравнения категория, наоборот, слишком широка. Пока роль одна и та же, любое движение дерева ради витрины меняет выдачу аналогов, и наоборот.

Решение

  1. Один глобальный канонический каталог. Отраслевых каталогов, отдельных индексов и отраслевых веток кода нет. Отрасль входит в систему как данные: профили совместимости и прикладные контексты.

  2. Product family — первоклассная сущность и единственная область совместимости. Семья отвечает ровно на один вопрос: «с чем этот товар в принципе сравним». Канон принадлежит ровно одной активной семье. Членство задаётся data-driven правилами отбора (state = 'auto') и перекрывается решением человека (state = 'manual_override') — та же машина состояний, что у значимости (0282).

  3. Семья ≠ категория. Дерево категорий остаётся навигацией, витриной и разрезом аналитики; область подбора оно больше не задаёт. У канона — одна primary category (навигация, отчёты, ретеншен категорийных метрик) и множество secondary application contexts (где изделие применяется: щитовое, слаботочка, водоснабжение, фасад). Контексты влияют на поиск, фасеты и подсказки и не влияют на область совместимости.

  4. Профиль совместимости семьи — версионируемый документ на данных. Профиль перечисляет оси семьи и их роли. Активная версия одна на семью, она иммутабельна; правка порождает новую версию через draft → in_review → active. Каждая ось несёт evidence: покрытие в семье, число различных значений, примеры канонов, источник (manual / llm / derived), модель и версию промта, кто и когда утвердил.

  5. Три роли осей с разными правами. identity — расхождение делает изделие непригодным, сравнение точное, ось вправе отсекать пул кандидатов. range — расхождение допустимо в одну сторону и ограничено запасом (max_ratio / max_delta), ось отсекает пул направленно. score — влияет только на порядок, отсекать не вправе. Право повысить ось до identity или range есть только у человека и у LLM, прошедшего ревью; расчёт по данным вправе назначать score, ignored и понижать роль, но не повышать (перенос правила canHardenFromRole на уровень семьи).

  6. Числовое сравнение — только в СИ и только внутри одного рода величины. Нормализация значений — по ADR-0053; read-model и проекция хранят СИ-значение вместе с кодом рода величины (quantity_kind), и этот код входит в ключ сравнения. Термин выбран намеренно: у активной и полной мощности физическая размерность одна и та же, но это разные измеряемые величины, и сравнивать их нельзя — коэффициента между Вт и ВА не существует, он зависит от коэффициента мощности изделия. Значит это разные оси профиля, а не разные единицы одной оси. Единицы одного рода величины (мм против дюймов) приводит нормализация на входе, подбор их не видит. compare_params хранит только доменные допуски (direction, max_ratio, max_delta, missing_means_reject, enum-группы) и никаких преобразований единиц. Ось range без подтверждённого рода величины не публикуется — остаётся score.

  7. Три режима — метка на кандидате, а не отдельный запрос. Каждый кандидат выдачи несёт mode: exact (точный аналог), substitute (допустимая замена «не хуже»), review (неточный кандидат, требует подтверждения) — с машинно читаемой причиной. Недопустимые кандидаты в выдачу не попадают вовсе. Существующие потребители продолжают читать тот же список; метка аддитивна.

    Пропущенные и неразобранные значения не дают точности. exact и substitute возможны, только когда каждая активная ось identity и range опубликованного профиля сравнима у якоря и у кандидата — значение есть, род величины известен, разбор удался. Набор осей не сужается признаком «у якоря значения нет»: такое сужение молча выводило бы незаполненную identity-ось из-под проверки. Любой пропуск опускает кандидата до review; отсутствие данных никогда не читается ни как совпадение, ни как расхождение, а полнота карточки якоря становится измеримым качеством данных.

    Метка не является разрешением выбрать. Автоматический выбор кандидата (смета, конвейер цен, интеграции) допустим только для exact; substitute требует явной политики потребителя или подтверждения человеком; review не выбирается автоматически никогда. Флаг семьи включается только после e2e-проверки этих правил.

  8. Условия совместимости выполняются на этапе поиска кандидатов, а не только post-rank. Оси identity, range и направленные запреты обязаны отсекать кандидатов до усечения пула; post-rank оставляет за собой объяснение, метку режима и порядок. Причина: пул усекается по весу и по потолку на ключ, поэтому условие, применённое после среза, теряет годных кандидатов молча. Несравнимость на этапе отбора не отсекает — кандидат обязан дойти до метки review.

  9. Путь подбора выбирается один раз и без смешения. Нет семьи, нет опубликованного профиля или флаг семьи выключен → старый путь по категории, поведение сегодняшнее, режимов в ответе нет. Семья включена и профиль опубликован → только family-path и режимы; категорийные правила и значимость к таким канонам не применяются. review — исход нового пути при неполных данных, а не заглушка для неготовой семьи. Рантайм читает только версию профиля в статусе active; draft и in_review живут в авторинге и на family-path не попадают, поэтому статус профиля не входит в правило вывода режима.

  10. 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 (неразобранные назначения)