ADR-0074: Укрепление нормализации характеристик — вырожденные диапазоны, бакет-канонизация, anti-dupe признаков

Status: accepted Date: 2026-07-12 Deciders: Maxim Belkanov (autonomous execution: Claude)

Контекст

Аудит прод-базы 2026-07-12 (19.5M canonical_assignments, 14.1k characteristics) зафиксировал три системных дефекта пайплайна нормализации характеристик:

  1. Вырожденные диапазоны — 335,731 assignments (29.5% всех диапазонов). Поставщики кодируют точечное значение range-идиомой («2.2…2.2», «1000-1000 А», «от 2,2 до 2,2»), парсер charvalue_si.go строил multi-envelope без схлопывания равных границ. Отдельный подкласс — нулевой диапазон «0…0» (9,263 строк): supplier-шум «нет данных», который проходил мимо фильтра isEmptyOrZeroCharValue.

  2. Дубли канонических признаков — ≥1,237 кодов. charnorm-LLM выдумывает canonical_name без знания существующих имён: 399 групп «одно русское имя — разные коды» (conductor_count/core_count/number_of_cores), 227 групп токен-перестановок (rated_ac_current/rated_current_ac). Плюс 1,638 fallback_*-характеристик от битых LLM-ответов. EnsureByCanonicalName разводил коллизии hex-суффиксом — дубль вместо переиспользования.

  3. Разное написание одного значения дробит голосование. Стратегии выбора (strategy/chain.go) бакетировали кандидатов по сырым JSON-байтам envelope: «Светло-серый» и «светло-серый» (54k строк) — разные bucket’ы, trust_weighted/mode голосуют мимо семантики.

Решение

1. Схлопывание вырожденных диапазонов в парсере (ingestion)

normalizeCharacteristicValueToSI: если чисел >1, все равны численно (allNumbersEqual) и строка содержала распознанную range-идиому (rangeSeparatorNormalised → «;»), значение становится скаляром. Guard на габариты: «100x100» не содержит range-идиомы → остаётся multi. isEmptyOrZeroCharValue расширен zeroRangePattern — «0…0», «0-0», «0…0» (опц. с юнитом) отбрасываются как шум наравне с «0». Попутный фикс: preprocessRangeSeparators заменяет «…» до «..» (иначе «0…0» → «0;.0» и извлечение чисел ломалось).

2. Бакет-канонизация голосования (canonical assignments)

Новый strategy.BucketKey(json.RawMessage) string — единый ключ bucket’а:

  • числовой envelope → n:<unit>:<value_canonical>; легаси-вырожденный диапазон (все values_canonical равны) фолдится в тот же ключ, что и скаляр — переходный период не дробит голоса;
  • настоящий диапазон → r:<unit>:<v1|v2|...>;
  • JSON-строка → s: + lower/trim/collapse-ws/ё→е;
  • остальное → компактные сырые байты (детерминированный fallback).

PickByTrustWeighted / PickByMode / hasTrustWeightedTie используют BucketKey вместо string(c.Value). В Decision.Value уходит оригинальный envelope лидера — провенанс сырых данных сохранён. Синонимы и транслит — зона valuenorm-словаря (N4), не bucket-ключа.

3. Anti-dupe гард признаков (characteristic provisioner)

Миграция 0243: immutable SQL-функция characteristic_token_signature(code) (токены кода, отсортированные по алфавиту) + expression index CONCURRENTLY по ней (generated column отвергнут: ALTER с table-rewrite требует ACCESS EXCLUSIVE на горячей characteristics; live-проверка на проде упёрлась в lock-очередь воркеров). EnsureByCanonicalName шаг 1.5: при промахе точного кода ищет по сигнатуре — перестановка слов переиспользует существующую характеристику (первую по created_at), её code/name не переписываются. Разные наборы токенов по-прежнему рождают новые записи.

charnorm-промпт получил жёсткие naming-conventions (порядок слов subject-first, _count-суффиксы, запрет _from/_min без явной границы) — без bump PromptVersion: инвалидация 17.7k кэшированных mappings вызвала бы массовый LLM-перегон и churn имён. Конвенции работают только на новые характеристики; выравнивание существующих — merge-справочник (см. ниже).

Попутно: char_name_mappings.display_name_ru (миграция 0243) — LLM генерировал русское display-имя с prompt v7, но Upsert его выбрасывал; теперь сохраняется (5,351 характеристика в UI показывала машинный код).

Ремедиация данных (операционная часть)

  • Вырожденные диапазоны в offer_characteristic_raw/offer_characteristic_facts схлопываются батчевым SQL (та же трансформация, что парсер) + dirty-enqueue затронутых каноников — штатный воркер пересчитает assignments (DeleteAutoExcept уберёт хвосты, manual-строки защищены ADR-0073).
  • «0…0»-факты удаляются как шум.
  • 1,496 fallback_*-сирот (pending, без маппингов) удаляются.

Отложено (следующие циклы)

  • Merge-справочник дублей characteristic_merge_map (alias→survivor, source seed/llm/moderator, needs_review) + CLI с каскадом char_name_mappings.canonical_name → remapped-события → facts-projector → пересчёт. Auto-approve только safe-класса (одно имя + один юнит + токен-перестановка); семантические синонимы — LLM-предложения в модерацию. Data-driven по ADR-0072.
  • Потребление display_name_ru в provisioner (backfill characteristics.name).
  • Канонизация значений за пределами регистра (синонимы, транслит) — valuenorm.

Последствия

  • Новые ингесты не плодят вырожденные диапазоны и словопорядковые дубли.
  • Голосование стратегий устойчиво к вариантам написания; после склейки legacy-данных победители станут стабильнее (меньше latest-fallback’ов).
  • Сигнатурный гард может ложно склеить два РАЗНЫХ признака с одинаковым набором токенов — риск принят: на 14k кодов таких пар не найдено (current_measurement/measurement_current и аналоги — один смысл).
  • Expression index строится CONCURRENTLY — деплой не блокирует воркеров; сигнатурный SELECT в provisioner попадает в индекс.