ADR-0070: Идентичность матчинга = (manufacturer_id, артикул); матч по одному артикулу не является точным

Status: accepted Date: 2026-07-08 Deciders: Maxim Belkanov

Поправка (2026-07-09): первопричина коллизии «10127» — НЕ матчер, а провижинер каноника на ингесте: canonicalIdentityKey строит "mpn|"+article (производитель игнорируется), и EnsureForOffer кладёт офферы в детерминированный canonical_id = uuid5(ns, ключ). Доказано: uuid5(ns, "mpn|10127") = прод-каноник 18f21aa4. exact_mpn-решения матчера — no-op поверх уже склеенного. Масштаб: ~98k слитых каноников / 220k офферов. Этот ADR (matcher identity) — part 1 (необходим, недостаточен). Первопричина и full re-key — ADR-0071 + спека 2026-07-09-canonical-identity-manufacturer-scoped-design.md.

Контекст

Матчер склеивал в один канонический товар офферы разных товаров разных производителей, совпавшие лишь по номеру артикула. Живой прод-кейс: артикул «10127» — не мусор, а настоящий, но неуникальный между производителями номер (Конкорд-кабель, Тахион-прожектор, Maytoni-лента, ПТК-защёлка, Helukabel-кабель независимо используют его). Пять из шести офферов получили статус exact_mpn (confidence exact, LLM не вызывался), хотя это физически разные товары.

Три сцепленных дефекта в BC матчинга:

  1. Ключ бренда — внутренний код, а не бренд. Матчер брал производителя из manufacturer_code (пуст у russvet, «10127»-мусор у etm), а не из manufacturer_name/manufacturer_id.
  2. ASCII-нормализация обнуляет кириллицу. NormalizeForMatch и SQL regexp_replace(..., '[^a-z0-9]', ...) режут кириллические бренды (Конкорд, Тахион) в пустую строку с обеих сторон. Строить идентичность на нормализации имени нельзя.
  3. Пустой бренд короткозамыкал в матч по одному артикулу, и статус exact/ strong терминален (не переигрывается), поэтому ошибка не исправлялась даже после дозаполнения бренда.

manufacturer_id — резолвенный FK из manufacturers (auto_mfr по lower(name), миграция 0178), script-agnostic и alias-способный, — доступен матчеру и является корректным ключом идентичности бренда.

Решение

  1. Ключ точной идентичности — пара (manufacturer_id, нормализованный артикул). Артикул производителя уникален только внутри производителя. Матч по артикулу без непустого совпадающего manufacturer_id не может быть exact. Идентичность бренда строится на manufacturer_id (равенство UUID), не на нормализации строки имени.
  2. Пустой бренд → не exact, не strong. Tier1 exact не выполняется при пустом manufacturer_id; fingerprint даёт strong только при согласованном непустом бренде (ManufacturerAgreed), иначе (совпал артикул/категория, но не бренд) остаётся probable → Tier3 LLM. Оффер без бренда остаётся unmatched (переигрываемо), а не терминально ошибочным.
  3. Гард по категории. Даже при совпадении артикула кандидат отклоняется, если у обеих сторон известна категория и она различается.
  4. manufacturer_id входит в evidence_hash. Дозаполнение бренда (charnorm/ ETIM резолвит manufacturer_id позже первичного матча) меняет hash → hash-гейт переигрывает решение → корректный матч после обогащения.

Последствия

  • Точность матчинга выше: неуникальный короткий артикул больше не склеивает разные бренды. Полнота авто-матча временно ниже (пустобрендовые офферы уходят в Tier3/ unmatched вместо терминального exact), но recheck доматчивает их после дозаполнения бренда; часть нагрузки смещается на Tier3 LLM.
  • Изменения замкнуты в BC матчинга (impact LOW): OfferCandidate.ManufacturerID, FingerprintCandidate.ManufacturerAgreed, EvidenceSignals.ManufacturerID, порт FindCanonicalsByNormalizedMPN(mpn, manufacturerID, categorySlug, …). Схема БД без миграций — manufacturer_id уже существует.
  • Ремедиация уже склеенных каноников — отдельный runbook: недостаточно удалить терминальные решения (оффер сохранит canonical_id, а currentCanonicalDecision переподтвердит его). Нужен split/detach: обнулить supplier_offers.canonical_id, удалить решения, откатить canonical_aliases/status='replaced' от прежних слияний, затем ре-матч + пометка каноников dirty (пере-проекция характеристик).

Известное ограничение

Категорийный гард опирается на CategorySlug. Идентификаторы ОКПД2 / ТН ВЭД (предложенные как дополнительный негативный сигнал) сейчас не проецируются в факты: identity-коды выбрасываются до offer_characteristic_facts (bulk_snapshot_pipeline.go, charvalue_role.go). Усиление гарда этими кодами — отдельный follow-up, требующий их проекции.

Связано

  • ADR-0045 (Tier 3 LLM matcher) — LLM теперь реально арбитрирует неоднозначные article-only совпадения (раньше exact его пропускал).
  • ADR-0044 (charnorm) — источник дозаполнения бренда (manufacturer_id) после первичного матча.