Add routing-card lexical skill selection (#34581)

## What changed

- Add a bounded `routing_card_exact_v1` selector that ranks normalized exact
  matches across skill names, tool dependencies, short descriptions, and full
  descriptions, with stronger weights for routing-specific fields.
- Run the selector in the shadow selection experiment and include catalog entry
  dependencies in selection documents.
- Report a selection as `selected` when an exact-name match succeeds even if
  the query contains no searchable terms.

## Testing

- Cover dependency ranking, stop-word skill names, exact-term matching, and
  input bounds.

GitOrigin-RevId: dfb0b668d726f64ae5da3b3ce2d8c9b23a664ac0
This commit is contained in:
jif
2026-07-21 17:18:01 +00:00
committed by copyberry
parent af71774d26
commit 51200321eb
9 changed files with 418 additions and 5 deletions
@@ -1,11 +1,14 @@
mod character_ngram;
mod fielded_bm25;
mod multi_query_lexical;
mod routing_card_lexical;
mod rrf_lexical_char;
mod weighted_lexical;
pub(crate) use character_ngram::CharacterNgramSkillSelector;
use codex_skills::SkillDependencies;
pub(crate) use fielded_bm25::FieldedBm25SkillSelector;
pub(crate) use multi_query_lexical::MultiQueryLexicalSkillSelector;
pub(crate) use routing_card_lexical::RoutingCardLexicalSkillSelector;
pub(crate) use rrf_lexical_char::RrfLexicalCharSkillSelector;
pub(crate) use weighted_lexical::WeightedLexicalSkillSelector;
@@ -17,6 +20,7 @@ pub(crate) struct SkillSelectionDocument<'a> {
pub name: &'a str,
pub short_description: Option<&'a str>,
pub description: &'a str,
pub dependencies: Option<&'a SkillDependencies>,
}
/// Bounded output from one cheap skill-selection method.
@@ -57,6 +57,7 @@ fn ngrams_report_bounded_inputs() {
name,
short_description: None,
description: "match",
dependencies: None,
})
.collect::<Vec<_>>();
@@ -73,5 +74,6 @@ fn document<'a>(id: usize, name: &'a str, description: &'a str) -> SkillSelectio
name,
short_description: None,
description,
dependencies: None,
}
}
@@ -59,6 +59,7 @@ fn bm25_reports_bounded_inputs() {
name,
short_description: None,
description: "match",
dependencies: None,
})
.collect::<Vec<_>>();
@@ -75,5 +76,6 @@ fn document<'a>(id: usize, name: &'a str, description: &'a str) -> SkillSelectio
name,
short_description: None,
description,
dependencies: None,
}
}
@@ -69,6 +69,7 @@ fn multi_query_preserves_bounded_input_signals() {
name,
short_description: None,
description: "match logs",
dependencies: None,
})
.collect::<Vec<_>>();
@@ -86,5 +87,6 @@ fn document<'a>(id: usize, name: &'a str, description: &'a str) -> SkillSelectio
name,
short_description: None,
description,
dependencies: None,
}
}
@@ -0,0 +1,258 @@
use std::collections::HashSet;
use super::CheapSkillSelection;
use super::CheapSkillSelector;
use super::SkillSelectionDocument;
const MAX_QUERY_BYTES: usize = 4 * 1024;
const MAX_QUERY_TERMS: usize = 64;
const MAX_DOCUMENT_BYTES: usize = 4 * 1024;
const MAX_FIELD_TERMS: usize = 256;
const MAX_DEPENDENCY_RECORDS: usize = 32;
const MAX_CANDIDATES: usize = 1_000;
const MAX_RESULTS: usize = 50;
const STOP_WORDS: &[&str] = &[
"a", "an", "and", "are", "as", "at", "be", "by", "do", "for", "from", "how", "i", "in", "is",
"it", "me", "my", "of", "on", "or", "please", "that", "the", "this", "to", "use", "we", "what",
"when", "where", "which", "with", "you", "your",
];
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct RoutingCardLexicalSkillSelector;
impl CheapSkillSelector for RoutingCardLexicalSkillSelector {
fn method(&self) -> &'static str {
"routing_card_exact_v1"
}
fn select(
&self,
query: &str,
documents: &[SkillSelectionDocument<'_>],
limit: usize,
) -> CheapSkillSelection {
let (query, query_bytes_truncated) = bounded(query, MAX_QUERY_BYTES);
let query_phrase = normalize(query);
let (query_terms, query_terms_truncated) = query_terms(&query_phrase);
let query_truncated = query_bytes_truncated || query_terms_truncated;
let candidate_set_truncated = documents.len() > MAX_CANDIDATES;
if query_phrase.is_empty() || limit == 0 {
return CheapSkillSelection {
query_term_count: query_terms.len(),
query_truncated,
candidate_set_truncated,
..Default::default()
};
}
let mut scored = documents
.iter()
.take(MAX_CANDIDATES)
.filter_map(|document| {
let prepared = PreparedRoutingCard::new(document);
let score = prepared.score(&query_phrase, &query_terms);
(score > 0).then_some((score, document.id, document.name))
})
.collect::<Vec<_>>();
scored.sort_by(|left, right| {
right
.0
.cmp(&left.0)
.then_with(|| left.2.cmp(right.2))
.then_with(|| left.1.cmp(&right.1))
});
CheapSkillSelection {
candidate_ids: scored
.into_iter()
.take(limit.min(MAX_RESULTS))
.map(|(_, id, _)| id)
.collect(),
query_term_count: query_terms.len(),
query_truncated,
candidate_set_truncated,
}
}
}
struct PreparedRoutingCard {
name: String,
name_has_searchable_term: bool,
fields: RoutingFields,
}
struct RoutingFields {
name: HashSet<String>,
dependencies: HashSet<String>,
short_description: HashSet<String>,
description: HashSet<String>,
}
impl PreparedRoutingCard {
fn new(document: &SkillSelectionDocument<'_>) -> Self {
let name = normalize_bounded(document.name);
Self {
name_has_searchable_term: name.split_whitespace().any(is_searchable_query_term),
name,
fields: RoutingFields {
name: field_terms(document.name),
dependencies: dependency_terms(document),
short_description: field_terms(document.short_description.unwrap_or_default()),
description: field_terms(document.description),
},
}
}
fn score(&self, query_phrase: &str, query_terms: &[&str]) -> u32 {
let mut score = 0u32;
let name_phrase_matches = contains_phrase(query_phrase, &self.name);
if !self.name.is_empty()
&& ((self.name_has_searchable_term && name_phrase_matches) || query_phrase == self.name)
{
score = score.saturating_add(256);
}
let mut matched_query_terms = 0u32;
for query_term in query_terms {
let mut matched = false;
score = score.saturating_add(score_field(
&self.fields.name,
query_term,
/*weight*/ 80,
&mut matched,
));
score = score.saturating_add(score_field(
&self.fields.dependencies,
query_term,
/*weight*/ 24,
&mut matched,
));
score = score.saturating_add(score_field(
&self.fields.short_description,
query_term,
/*weight*/ 12,
&mut matched,
));
score = score.saturating_add(score_field(
&self.fields.description,
query_term,
/*weight*/ 3,
&mut matched,
));
if matched {
matched_query_terms = matched_query_terms.saturating_add(1);
}
}
score.saturating_add(matched_query_terms.saturating_mul(matched_query_terms))
}
}
fn score_field(terms: &HashSet<String>, query_term: &str, weight: u32, matched: &mut bool) -> u32 {
if terms.contains(query_term) {
*matched = true;
weight
} else {
0
}
}
fn dependency_terms(document: &SkillSelectionDocument<'_>) -> HashSet<String> {
let mut terms = HashSet::new();
let Some(dependencies) = document.dependencies else {
return terms;
};
let mut remaining_bytes = MAX_DOCUMENT_BYTES;
for tool in dependencies.tools.iter().take(MAX_DEPENDENCY_RECORDS) {
extend_terms(&mut terms, &tool.value, &mut remaining_bytes);
if let Some(description) = tool.description.as_deref() {
extend_terms(&mut terms, description, &mut remaining_bytes);
}
if remaining_bytes == 0 || terms.len() >= MAX_FIELD_TERMS {
break;
}
}
terms
}
fn field_terms(value: &str) -> HashSet<String> {
normalized_terms(bounded(value, MAX_DOCUMENT_BYTES).0)
.into_iter()
.take(MAX_FIELD_TERMS)
.collect()
}
fn extend_terms(terms: &mut HashSet<String>, value: &str, remaining_bytes: &mut usize) {
let value = bounded(value, *remaining_bytes).0;
*remaining_bytes = remaining_bytes.saturating_sub(value.len());
for term in normalized_terms(value) {
if terms.len() == MAX_FIELD_TERMS {
return;
}
terms.insert(term);
}
}
fn query_terms(query_phrase: &str) -> (Vec<&str>, bool) {
let mut seen = HashSet::new();
let mut terms = Vec::new();
for term in query_phrase
.split_whitespace()
.filter(|term| is_searchable_query_term(term))
{
if !seen.insert(term) {
continue;
}
if terms.len() == MAX_QUERY_TERMS {
return (terms, true);
}
terms.push(term);
}
(terms, false)
}
fn is_searchable_query_term(term: &str) -> bool {
term.chars().count() >= 2 && !STOP_WORDS.contains(&term)
}
fn normalize_bounded(value: &str) -> String {
normalize(bounded(value, MAX_DOCUMENT_BYTES).0)
}
fn normalize(value: &str) -> String {
normalized_terms(value).join(" ")
}
fn normalized_terms(value: &str) -> Vec<String> {
let mut normalized = String::with_capacity(value.len());
for character in value.chars() {
if character.is_alphanumeric() {
normalized.extend(character.to_lowercase());
} else {
normalized.push(' ');
}
}
normalized.split_whitespace().map(str::to_string).collect()
}
fn contains_phrase(haystack: &str, needle: &str) -> bool {
haystack == needle
|| haystack.starts_with(&format!("{needle} "))
|| haystack.ends_with(&format!(" {needle}"))
|| haystack.contains(&format!(" {needle} "))
}
fn bounded(value: &str, max_bytes: usize) -> (&str, bool) {
if value.len() <= max_bytes {
return (value, false);
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end = end.saturating_sub(1);
}
(&value[..end], true)
}
#[cfg(test)]
#[path = "routing_card_lexical_tests.rs"]
mod tests;
@@ -0,0 +1,131 @@
use codex_skills::SkillDependencies;
use codex_skills::SkillToolDependency;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn dependency_names_outrank_description_only_matches() {
let dependencies = SkillDependencies {
tools: vec![SkillToolDependency {
r#type: "app".to_string(),
value: "slack".to_string(),
description: Some("Send messages to Slack channels.".to_string()),
transport: None,
command: None,
url: None,
}],
};
let documents = [
SkillSelectionDocument {
id: 1,
name: "team-messaging",
short_description: None,
description: "Work with team communication.",
dependencies: Some(&dependencies),
},
document(
/*id*/ 2,
"documents",
"Send messages to Slack channels.",
),
];
let selection = RoutingCardLexicalSkillSelector.select(
"send this update to Slack",
&documents,
/*limit*/ 20,
);
assert_eq!(vec![1, 2], selection.candidate_ids);
}
#[test]
fn stop_word_name_requires_an_exact_query() {
let documents = [document(/*id*/ 1, "do", "Run a task.")];
let selection = RoutingCardLexicalSkillSelector.select("do", &documents, /*limit*/ 20);
assert_eq!(vec![1], selection.candidate_ids);
assert_eq!(0, selection.query_term_count);
let selection = RoutingCardLexicalSkillSelector.select(
"how do I format Rust",
&documents,
/*limit*/ 20,
);
assert_eq!(Vec::<usize>::new(), selection.candidate_ids);
}
#[test]
fn prefixes_do_not_match_field_terms() {
let documents = [document(
/*id*/ 1,
"presentations",
"Create presentations.",
)];
let selection =
RoutingCardLexicalSkillSelector.select("present", &documents, /*limit*/ 20);
assert_eq!(Vec::<usize>::new(), selection.candidate_ids);
}
#[test]
fn dependency_records_are_bounded() {
let mut tools = (0..MAX_DEPENDENCY_RECORDS)
.map(|_| SkillToolDependency {
r#type: "app".to_string(),
value: "shared".to_string(),
description: None,
transport: None,
command: None,
url: None,
})
.collect::<Vec<_>>();
tools.push(SkillToolDependency {
r#type: "app".to_string(),
value: "unbounded".to_string(),
description: None,
transport: None,
command: None,
url: None,
});
let dependencies = SkillDependencies { tools };
let document = SkillSelectionDocument {
id: 1,
name: "dependency-test",
short_description: None,
description: "Test dependencies.",
dependencies: Some(&dependencies),
};
assert_eq!(
HashSet::from(["shared".to_string()]),
dependency_terms(&document)
);
}
#[test]
fn selector_reports_bounded_inputs() {
let documents = [document(
/*id*/ 1,
"presentations",
"Create visual decks.",
)];
let query = "presentations ".repeat(MAX_QUERY_BYTES);
let selection = RoutingCardLexicalSkillSelector.select(&query, &documents, /*limit*/ 20);
assert_eq!(vec![1], selection.candidate_ids);
assert!(selection.query_truncated);
}
fn document<'a>(id: usize, name: &'a str, description: &'a str) -> SkillSelectionDocument<'a> {
SkillSelectionDocument {
id,
name,
short_description: None,
description,
dependencies: None,
}
}
@@ -23,6 +23,7 @@ fn selector_reports_the_combined_input_bounds() {
name: "presentations",
short_description: None,
description: "Create visual decks.",
dependencies: None,
}];
let query = "presentation ".repeat(4 * 1024);
@@ -9,18 +9,21 @@ fn lexical_selector_prioritizes_an_exact_skill_name() {
name: "slides-helper",
short_description: None,
description: "Create presentations and visual decks.",
dependencies: None,
},
SkillSelectionDocument {
id: 20,
name: "presentations",
short_description: None,
description: "Create or edit PowerPoint presentations.",
dependencies: None,
},
SkillSelectionDocument {
id: 30,
name: "spreadsheets",
short_description: None,
description: "Analyze tabular data.",
dependencies: None,
},
];
@@ -43,12 +46,14 @@ fn lexical_selector_uses_descriptions_and_drops_zero_score_candidates() {
name: "ci-helper",
short_description: Some("Diagnose continuous integration failures."),
description: "Inspect failing GitHub Actions checks and logs.",
dependencies: None,
},
SkillSelectionDocument {
id: 2,
name: "document-editor",
short_description: None,
description: "Edit Word documents.",
dependencies: None,
},
];
@@ -74,6 +79,7 @@ fn lexical_selector_respects_requested_limit() {
name,
short_description: None,
description: "Fix lint errors.",
dependencies: None,
})
.collect::<Vec<_>>();
@@ -97,6 +103,7 @@ fn lexical_selector_reports_bounded_inputs() {
name,
short_description: None,
description: "match",
dependencies: None,
})
.collect::<Vec<_>>();
@@ -124,6 +131,7 @@ fn lexical_selector_caps_query_terms() {
name: "term0",
short_description: None,
description: "term0",
dependencies: None,
}];
let selection = WeightedLexicalSkillSelector.select(&query, &documents, /*limit*/ 20);
@@ -139,6 +147,7 @@ fn lexical_selector_returns_nothing_for_stop_words_only() {
name: "anything",
short_description: None,
description: "Do anything.",
dependencies: None,
}];
let selection =
@@ -157,6 +166,7 @@ fn selector_can_be_used_behind_the_shared_trait() {
name: "code-review",
short_description: None,
description: "Review code.",
dependencies: None,
}],
/*limit*/ 20,
)
@@ -16,6 +16,7 @@ use crate::dynamic_skill_selector::CheapSkillSelection;
use crate::dynamic_skill_selector::CheapSkillSelector;
use crate::dynamic_skill_selector::FieldedBm25SkillSelector;
use crate::dynamic_skill_selector::MultiQueryLexicalSkillSelector;
use crate::dynamic_skill_selector::RoutingCardLexicalSkillSelector;
use crate::dynamic_skill_selector::RrfLexicalCharSkillSelector;
use crate::dynamic_skill_selector::SkillSelectionDocument;
use crate::dynamic_skill_selector::WeightedLexicalSkillSelector;
@@ -45,6 +46,7 @@ impl ShadowSelectionExperiment {
Box::new(CharacterNgramSkillSelector),
Box::new(MultiQueryLexicalSkillSelector),
Box::new(RrfLexicalCharSkillSelector),
Box::new(RoutingCardLexicalSkillSelector),
],
metrics_client,
}
@@ -76,6 +78,7 @@ impl ShadowSelectionExperiment {
name: entry.name.as_str(),
short_description: entry.short_description.as_deref(),
description: entry.description.as_str(),
dependencies: entry.dependencies.as_ref(),
})
.collect::<Vec<_>>();
let eligible_ids = documents
@@ -239,12 +242,12 @@ fn sanitize_selected_ids(
}
fn selection_status(selection: &CheapSkillSelection, selected_entry_count: usize) -> &'static str {
if selection.query_term_count == 0 {
"no_query_terms"
} else if selected_entry_count == 0 {
"no_matches"
} else {
if selected_entry_count > 0 {
"selected"
} else if selection.query_term_count == 0 {
"no_query_terms"
} else {
"no_matches"
}
}