Оценка кандидатов¶
После того как Brave возвращает список троек (artist, title, kind), они объединяются, ранжируются по match_score, и из лучшего результата выбирается canonical пара. На этой странице объясняется алгоритм оценки и особые случаи, которые на него накладываются.
Источник: src-tauri/src/resolver/mod.rs
match_score¶
Возвращает значение в диапазоне [0, ~4.5]. Чем выше — тем лучше.
Составляющие¶
| Составляющая | +Score | Условие |
|---|---|---|
| Совпадение исполнителя | +1.0 | part_matches(query_tokens, query_norm, &cand.artist) |
| Совпадение названия | +1.0 | part_matches(query_tokens, query_norm, &cand.title) |
| Точное совпадение исполнителя | +1.0 | norm(artist_stripped) == norm(query) |
| Точное совпадение названия | +1.0 | norm(title_stripped) == norm(query) |
| Бонус за несколько источников | +0.5 × (sources.len() − 1) | Чем больше источников сообщают об одном candidate — тем выше уверенность |
Бонус за несколько источников никогда не превышает реальное текстовое совпадение: 3 источника дают +1.0, что равно одному текстовому сигналу, но не может превзойти два (artist + title = 2.0).
part_matches¶
Два сигнала, достаточно любого одного:
- Перекрытие токенов ≥ 50%: не менее половины токенов части candidate присутствуют в наборе токенов запроса.
- "Нон стоп" (2 токена) против запроса "нон стоп молли": перекрытие = 2/2 = 100% → совпадение
-
"Paranoid Android" (2 токена) против "radiohead paranoid": перекрытие = 1/2 = 50% → совпадение
-
Совпадение подстроки (нормализованное, в любом направлении):
norm(query)содержитnorm(candidate_part), ИЛИnorm(candidate_part)содержитnorm(query)- Обрабатывает варианты без пробелов, например "нонстоп" совпадает с "Нон стоп"
Удаление скобочных аннотаций¶
Перед оценкой скобочные аннотации удаляются и из исполнителя, и из названия:
Это обрабатывает заголовки Genius вида "Сукины дети (Sons of Bitches)", где скобочная часть — перевод на английский. Без удаления 5-токенная версия "сукины дети sons of bitches" снизила бы соотношение перекрытия токенов для кириллического запроса "сукины дети".
Порог канонизации¶
Минимальный score для принятия candidate в качестве canonical:
Score ≥ 2.0 означает, что запросу соответствуют и исполнитель, И название (по одному текстовому сигналу каждый = 2.0). Score 1.0 означает совпадение только одной стороны — недостаточно для уверенной канонизации.
Этот порог необходим, поскольку Brave сортирует по релевантности, но не всегда корректно. Пример сбоя без порога: запрос "первый класс" → лучший трек от Brave — "1.Kla$ - АКНЕ" (исполнитель семантически соответствует "первый класс", название — нет) → score 1.0 → корректно не канонизируется.
Перекрёстный буст от artist-hub¶
let artist_hub_norms: HashSet<String> = merged
.iter()
.filter(|c| c.kind == MatchKind::Artist)
.map(|c| norm::norm(&c.artist))
.collect();
// Для каждого Track candidate:
if c.kind == MatchKind::Track && artist_hub_norms.contains(&norm::norm(&c.artist)) {
s += 1.0;
}
Когда Brave возвращает одновременно страницу исполнителя и страницы треков для одного и того же исполнителя, track candidates получают +1.0. Это подтверждает имя исполнителя через два независимых типа источников (artist hub + страница трека) и позволяет правильному треку преодолеть порог 2.0, даже если запрос явно не называет название.
Пример: запрос "первый класс сукины дети" → Brave возвращает страницу исполнителя "1.Kla$" (+1.0 ко всем трекам 1.Kla$) и страницу трека "1.Kla$ - Сукины дети" (перекрытие исполнителя +1.0 + перекрёстный буст +1.0 = итого 2.0 от перекрытия, порог преодолён).
Принятие кандидатов Album / Artist¶
Track candidates требуют score >= 2.0. Для Album и Artist candidates планка ниже:
MatchKind::Album | MatchKind::Artist => {
if score >= 1.0 { return true; }
// Исключение при несоответствии алфавита: кириллический запрос + candidate только из латиницы
let q_cyrl = trimmed.chars().any(|ch| matches!(ch, 'а'..='я' | 'А'..='Я' | 'ё' | 'Ё'));
let c_latin_only = full.chars().all(|ch| !matches!(ch, 'а'..='я' | ...));
q_cyrl && c_latin_only
}
Score ≥ 1.0 достаточен, потому что страница исполнителя "Пошлая Молли" против запроса "нон стоп молли" набирает ровно 1.0 (совпадение исполнителя) — это всё равно сильный сигнал о правильном исполнителе.
Исключение при несоответствии алфавита (разрешён score = 0) обрабатывает ситуацию: запрос на кириллице, candidate — полностью латиница. norm() приводит обе строки к буквенно-цифровому нижнему регистру, однако кириллическое "молли" (молли) и латинское "Molly" (molly) — разные строки, и чистое строковое сравнение не может обнаружить семантическую эквивалентность. Исключение позволяет кириллическому запросу сопоставиться со страницей исполнителя только на латинице, принимая score = 0.
Предварительное извлечение кандидатов album/artist¶
До усечения ранжированного списка до 10 позиций кандидаты album и artist извлекаются отдельно:
let album_page_cand = ranked
.iter()
.find(|(_, c)| c.kind == MatchKind::Album && special_trust(c))
.map(|(_, c)| c.clone());
let artist_page_cand = ranked
.iter()
.find(|(_, c)| c.kind == MatchKind::Artist && !c.artist.is_empty() && special_trust(c))
.map(|(_, c)| c.clone());
После усечения они добавляются обратно в ранжированный список. Это не позволяет сильному совпадению Artist или Album быть молча потерянным, если оно оказалось ниже позиции 10, потому что его текстовый score был 0 (кросс-скриптовый), тогда как Track candidates с частичным текстовым совпадением численно оценивались выше.
Определение запроса только по исполнителю¶
После оценки выполняется финальная проверка: не является ли сырой запрос целиком именем исполнителя:
let q_tokens_set: HashSet<String> = tokenize(trimmed).into_iter().collect();
let artist_only_query = artist_page_cand.as_ref().is_some_and(|c| {
let stripped = strip_bracket_annotations(&c.artist);
let a_tokens: HashSet<String> = tokenize(&stripped).into_iter().collect();
q_tokens_set.iter().all(|t| a_tokens.contains(t))
});
Если каждый токен запроса присутствует в имени лучшего Artist candidate, запрос считается поиском только по исполнителю. Это переопределяет обычную оценку: canonical устанавливается в {artist: "...", title: ""}, а intent принудительно переключается на Artist.
Без этой проверки "Пошлая Молли" (чистый запрос по исполнителю) подхватила бы тот трек, который Brave случайно выдал первым (например, "АКНЕ"), потому что каждый трек этого исполнителя набирает ~2.0 через перекрёстный буст от artist-hub, а stable_sort при ничье откатывается к порядку Brave.