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)
зафиксировал три системных дефекта пайплайна нормализации характеристик:
-
Вырожденные диапазоны — 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. -
Дубли канонических признаков — ≥1,237 кодов. charnorm-LLM выдумывает
canonical_nameбез знания существующих имён: 399 групп «одно русское имя — разные коды» (conductor_count/core_count/number_of_cores), 227 групп токен-перестановок (rated_ac_current/rated_current_ac). Плюс 1,638fallback_*-характеристик от битых LLM-ответов.EnsureByCanonicalNameразводил коллизии hex-суффиксом — дубль вместо переиспользования. -
Разное написание одного значения дробит голосование. Стратегии выбора (
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 (backfillcharacteristics.name). - Канонизация значений за пределами регистра (синонимы, транслит) — valuenorm.
Последствия
- Новые ингесты не плодят вырожденные диапазоны и словопорядковые дубли.
- Голосование стратегий устойчиво к вариантам написания; после склейки legacy-данных победители станут стабильнее (меньше latest-fallback’ов).
- Сигнатурный гард может ложно склеить два РАЗНЫХ признака с одинаковым
набором токенов — риск принят: на 14k кодов таких пар не найдено
(
current_measurement/measurement_currentи аналоги — один смысл). - Expression index строится CONCURRENTLY — деплой не блокирует воркеров; сигнатурный SELECT в provisioner попадает в индекс.