ADR-0078: Провал всех тиров — не матч

Status: accepted Date: 2026-08-11 Deciders: belkanov, agent-claude

Контекст

ADR-0076 закрыл вырождение правила точности на пустом множестве проверок: условие «каждая активная ось сравнима» выполнялось тривиально, когда сравнивать было нечего, и кандидат объявлялся точным аналогом, не пройдя ни одной проверки.

Тот же класс ошибки обнаружился уровнем ниже — в матчере. Когда Tier1 (exact MPN), Tier2 (fingerprint) и характеристический путь не дали ни одного кандидата, currentCanonicalDecision записывала решение с match_confidence = 'strong' и tier = 'fingerprint', указывающее на пер-офферный каноник самого оффера. Пустое множество кандидатов записывалось как уверенный матч.

Последствие — терминальность. strong исключён из всех трёх источников рематча: очередь решений пропускает exact/strong, ридер расхождений требует несовпадения решения и оффера (у fallback они совпадают), ридер несматченных требует отсутствия решения. Оффер выпадал из матчинга навсегда.

Провижинер каноника пер-офферный (ключ supplier|<supplier>|<sku>), кросс-офферная склейка целиком передана матчеру. Значит fallback штамповал пожизненную бронь на заведомо одиночный каноник именно на том пути, который обязан был склеивать.

Замер прода 2026-08-11: 1 255 586 решений fingerprint+strong против 260 157 exact_mpn и 24 probable. В выборке 20 000 последних fingerprint+strong одиночек 19 975 против 25 настоящих кросс-поставщиковых матчей. Из 1 283 724 канонов с офферами 1 091 700 (85%) держат офферы ровно одного поставщика.

Решение

  1. Решение, записанное при провале всех тиров, имеет match_confidence = 'weak' и tier = 'none'. Оно остаётся пересматриваемым.
  2. Решение, записанное при блокировке проекции конфликтом идентичности, имеет match_confidence = 'weak' и тир предыдущего решения: кандидат был, переставить не дали. Различие тиров сохраняет причину в данных.
  3. Терминальными остаются только exact и strong, и получить их можно лишь предъявив кандидата: Tier1 при точном совпадении (manufacturer_id, нормализованный артикул) либо Tier2 при согласованном непустом бренде.
  4. Очередь рематча делится на две ветви с разными окнами: конфликты и обычные не-терминальные решения обходятся штатным MATCHER_RECHECK_COOLDOWN, ветвь tier='none' — своим MATCHER_WEAK_RECHECK_COOLDOWN и порядком от давно не проверявшихся.
  5. Источник рематча ограничен долей батча MATCHER_RECHECK_BATCH_SHARE; невыбранный остальными источниками остаток возвращается ему обратным добором.

Последствия

Плюсы

  • Оффер, которому не нашлось двойника, возвращается на пересмотр, когда меняются входные данные — когда дозаполняется бренд, появляются характеристики или у поставщика появляется двойник.
  • Состояние «кандидата нет» становится отличимым в SQL по tier='none' и наблюдаемым метрикой tracium_match_no_candidate_backlog.
  • Оффер-огрызок без характеристик, приклеенный к канону с полным набором осей, получает их и становится пригодным для подбора аналогов без ожидания поставщика.

Минусы

  • weak не проходит shouldProjectCanonical, поэтому ранний выход по совпадению evidence_hash для таких решений не срабатывает и тиры прогоняются целиком: каждый прогон стоит два запроса по supplier_offers. Отсюда квота, расширенное окно и обязательный порядок работ — распечатывать прежние решения только когда пару есть кому разрешить. Совпадение (производитель, артикул) подтверждением тождества не является: одинаковый артикул под разными брендами регулярно принадлежит разным изделиям, поэтому сведение бренд-псевдонимов как способ склейки отвергнуто (решение владельца продукта, 2026-08-11). Разрешает такие пары арбитр Tier3 (ADR-0079), поэтому гейт ремедиации проверяет его живость (--min-llm-decisions-24h), а не наполнение осей role='identity': миграция 0356 понизила алгоритмические оси до significant, и арбитражу они не нужны.
  • Очередь рематча становится многочисленной, поэтому порядок обхода требует отдельного частичного индекса.

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

  • Существующие 1,25 млн решений остаются терминальными до разовой ремедиации (cmd/matcher-fallback-unlock), которая переводит в новое состояние только одиночки и не трогает офферы под ручным замком (ADR-0073).
  • Привязка оффера к канонику при распечатке не меняется, поэтому выдача в момент ремедиации остаётся прежней.
  • cmd/matcher-fallback-unlock не меняет match_decisions.updated_at: для tier = 'none' это момент последнего просмотра матчером, от которого считается MATCHER_WEAK_RECHECK_COOLDOWN. Распечатка не является просмотром; её timestamp отложил бы рематч на всё окно.

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

Новое значение no_candidate вместо weak

Семантически точнее: weak буквально значит «слабый матч», а не «матча нет». Отвергнуто из-за цены — миграция CHECK и ревизия каждого места, где перечисляются confidence-значения, при том же поведении. weak в этом коде уже занимает дно шкалы: classifyLLMConfidence отдаёт его при LLM-уверенности ниже 0.70.

Не писать решение вовсе

Оффер уехал бы в ридер несматченных (md.offer_id IS NULL), где нет cooldown, то есть прогон каждый тик вместо раза в окно. Дополнительно теряются tried_tiers и наблюдаемость.

Ремедиация всех 1,25 млн решений сразу

Отвергнуто: переигрывает и верные склейки (0,1% выборки), часть из них может уйти в probable и сменить каноник через Tier3. Скоуп ограничен одиночками — у них склейки не было по определению.

Ссылки

  • docs/superpowers/specs/2026-08-11-matcher-terminal-strong-fallback-design.md
  • docs/superpowers/plans/2026-08-11-matcher-terminal-strong-fallback.md
  • ADR-0070 — ключ идентичности (manufacturer_id, артикул)
  • ADR-0073 — ручная модерация идентичности каноников
  • ADR-0076 — тот же класс ошибки на уровне подбора аналогов