use crate::session::by_score_descending;
use std::collections::BTreeMap;
use lgwks_std::similarity::{EditDistance, Jaccard, Similarity};
use crate::session::{
AnswerDomain, MatchTier, PolicyVersion, Provenance, Question, Resolution, Verdict,
decode_integer,
};
pub const MAX_UTTERANCE_CHARS: usize = 512;
const INPUT_BOUND_CHARS: u32 = 512;
const TOKEN_WEIGHT: f64 = 0.5;
const DISTANCE_WEIGHT: f64 = 0.5;
pub const MATCH_THRESHOLD: f64 = 0.55;
pub const MATCH_MARGIN: f64 = 0.08;
const POLICY_LABEL: &str = "lexicon";
#[must_use]
pub fn normalize(text: &str) -> String {
let mut folded = String::with_capacity(text.len());
let mut pending_space = false;
for character in text.chars() {
let mapped = if character.is_alphanumeric() {
fold_diacritic(character)
} else {
if folded.is_empty() {
continue;
}
pending_space = true;
continue;
};
if pending_space {
folded.push(' ');
pending_space = false;
}
for lower in mapped.to_lowercase() {
folded.push(lower);
}
}
folded
}
fn fold_diacritic(character: char) -> char {
match character {
'à'..='å' | 'ā' | 'ă' | 'ą' => 'a',
'À'..='Å' | 'Ā' | 'Ă' | 'Ą' => 'A',
'è'..='ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => 'e',
'È'..='Ë' | 'Ē' | 'Ĕ' | 'Ė' | 'Ę' | 'Ě' => 'E',
'ì'..='ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' | 'ı' => 'i',
'Ì'..='Ï' | 'Ĩ' | 'Ī' | 'Ĭ' | 'Į' => 'I',
'ò'..='ö' | 'ø' | 'ō' | 'ŏ' | 'ő' => 'o',
'Ò'..='Ö' | 'Ø' | 'Ō' | 'Ŏ' | 'Ő' => 'O',
'ù'..='ü' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' => 'u',
'Ù'..='Ü' | 'Ū' | 'Ŭ' | 'Ů' | 'Ű' | 'Ų' => 'U',
'ç' | 'ć' | 'ĉ' | 'ċ' | 'č' => 'c',
'Ç' | 'Ć' | 'Ĉ' | 'Ċ' | 'Č' => 'C',
'ñ' | 'ń' | 'ņ' | 'ň' => 'n',
'Ñ' | 'Ń' | 'Ņ' | 'Ň' => 'N',
'ý' | 'ÿ' | 'ŷ' => 'y',
'Ý' | 'Ÿ' | 'Ŷ' => 'Y',
'ß' => 's',
'ś' | 'ŝ' | 'ş' | 'š' => 's',
'Ś' | 'Ŝ' | 'Ş' | 'Š' => 'S',
'ź' | 'ż' | 'ž' => 'z',
'Ź' | 'Ż' | 'Ž' => 'Z',
'ĝ' | 'ğ' | 'ġ' | 'ģ' => 'g',
'Ĝ' | 'Ğ' | 'Ġ' | 'Ģ' => 'G',
'ł' => 'l',
'Ł' => 'L',
'ř' => 'r',
'Ř' => 'R',
'ţ' | 'ť' | 'ŧ' => 't',
'Ţ' | 'Ť' | 'Ŧ' => 'T',
'ď' | 'đ' => 'd',
'Ď' | 'Đ' => 'D',
'æ' => 'a',
'Æ' => 'A',
'œ' => 'o',
'Œ' => 'O',
other => other,
}
}
#[must_use]
pub fn phonetic_key(text: &str) -> String {
let normalized = normalize(text);
let mut key = String::with_capacity(4);
let mut previous = '0';
for character in normalized.chars() {
if !character.is_ascii_alphabetic() {
continue;
}
let code = soundex_digit(character.to_ascii_uppercase());
if key.is_empty() {
key.push(character.to_ascii_uppercase());
previous = code;
continue;
}
if code == '0' || code == previous {
previous = code;
continue;
}
key.push(code);
previous = code;
if key.chars().count() >= 4 {
break;
}
}
if key.is_empty() {
return key;
}
while key.chars().count() < 4 {
key.push('0');
}
key
}
fn soundex_digit(letter: char) -> char {
match letter {
'B' | 'F' | 'P' | 'V' => '1',
'C' | 'G' | 'J' | 'K' | 'Q' | 'S' | 'X' | 'Z' => '2',
'D' | 'T' => '3',
'L' => '4',
'M' | 'N' => '5',
'R' => '6',
_ => '0',
}
}
fn tokens(text: &str) -> Vec<String> {
normalize(text)
.split(' ')
.filter(|token| !token.is_empty())
.map(str::to_owned)
.collect()
}
const fn tier_rank(tier: MatchTier) -> u8 {
match tier {
MatchTier::Exact => 0,
MatchTier::Phonetic => 1,
MatchTier::Fuzzy => 2,
MatchTier::Semantic => 3,
}
}
fn winning_tier(mut scored: Vec<(usize, MatchTier, f64)>) -> Vec<(usize, MatchTier, f64)> {
let Some(best) = scored.iter().map(|candidate| tier_rank(candidate.1)).min() else {
return scored;
};
scored.retain(|candidate| tier_rank(candidate.1) == best);
scored
}
fn score_all(
utterance: &str,
question: &Question<'_>,
aliases: &BTreeMap<String, BTreeMap<String, Alias>>,
distance: &EditDistance,
) -> Vec<(usize, MatchTier, f64)> {
let options = question.options();
let spoken = normalize(utterance);
let spoken_key = phonetic_key(utterance);
let spoken_tokens = tokens(utterance);
let overlap = Jaccard::<String>::new();
let bound = aliases
.get(question.id())
.and_then(|table| table.get(&spoken))
.map(Alias::option);
let mut scored: Vec<(usize, MatchTier, f64)> = Vec::with_capacity(options.len());
for (index, option) in options.iter().enumerate() {
let canonical = normalize(option);
let tier_and_score = if bound == Some(option.as_str()) {
Some((MatchTier::Exact, 1.0))
} else if spoken == canonical {
Some((MatchTier::Exact, 1.0))
} else if !spoken_key.is_empty() && spoken_key == phonetic_key(option) {
Some((MatchTier::Phonetic, 0.9))
} else {
let lexical = distance.score(&spoken, &canonical);
let set = overlap.score(&spoken_tokens, &tokens(option));
Some((
MatchTier::Fuzzy,
(TOKEN_WEIGHT * set) + (DISTANCE_WEIGHT * lexical),
))
};
if let Some((tier, score)) = tier_and_score {
scored.push((index, tier, score));
}
}
let mut scored = winning_tier(scored);
scored.sort_by(by_score_descending);
scored
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Alias {
question: String,
utterance: String,
option: String,
}
impl Alias {
#[must_use]
pub fn new(question: &str, utterance: &str, option: &str) -> Self {
Self {
question: question.to_owned(),
utterance: normalize(utterance),
option: option.to_owned(),
}
}
#[must_use]
pub fn question(&self) -> &str {
&self.question
}
#[must_use]
pub fn utterance(&self) -> &str {
&self.utterance
}
#[must_use]
pub fn option(&self) -> &str {
&self.option
}
}
fn decide_integer(utterance: &str, question: &Question<'_>, margin: f64) -> Resolution {
let Some(spoken) = decode_integer(utterance) else {
return Resolution::Absent { best_score: 0.0 };
};
let scored: Vec<(usize, MatchTier, f64)> = question
.options()
.iter()
.enumerate()
.filter_map(|(index, option)| {
(decode_integer(option) == Some(spoken)).then_some((index, MatchTier::Exact, 1.0))
})
.collect();
decide(&scored, MATCH_THRESHOLD, margin)
}
pub(crate) fn decide(
scored: &[(usize, MatchTier, f64)],
threshold: f64,
margin: f64,
) -> Resolution {
let Some(&(index, tier, score)) = scored.first() else {
return Resolution::Absent { best_score: 0.0 };
};
if score < threshold {
return Resolution::Absent { best_score: score };
}
let lead = match scored.get(1) {
Some(&(_, _, next)) => score - next,
None => score,
};
if lead >= margin {
return Resolution::Resolved {
index,
tier,
score,
lead,
};
}
let mut tied: Vec<usize> = scored
.iter()
.take_while(|candidate| score - candidate.2 < margin)
.map(|candidate| candidate.0)
.collect();
tied.sort_unstable();
Resolution::Ambiguous { tied, tier, score }
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct LanguageResolver {
aliases: BTreeMap<String, BTreeMap<String, Alias>>,
distance: EditDistance,
}
impl LanguageResolver {
#[must_use]
pub fn new() -> Self {
Self {
aliases: BTreeMap::new(),
distance: EditDistance::new(MAX_UTTERANCE_CHARS),
}
}
#[must_use]
pub fn with_aliases(aliases: Vec<Alias>) -> Self {
let mut resolver = Self::new();
for alias in aliases {
let table = resolver.aliases.entry(alias.question.clone()).or_default();
table.insert(alias.utterance.clone(), alias);
}
resolver
}
pub fn learn(&mut self, question: &str, utterance: &str, option: &str) -> Option<Alias> {
let alias = Alias::new(question, utterance, option);
self.aliases
.entry(alias.question.clone())
.or_default()
.insert(alias.utterance.clone(), alias)
}
pub fn forget(&mut self, question: &str, utterance: &str) -> Option<Alias> {
let spoken = normalize(utterance);
let removed = self
.aliases
.get_mut(question)
.and_then(|table| table.remove(&spoken));
if self.aliases.get(question).is_some_and(BTreeMap::is_empty) {
self.aliases.remove(question);
}
removed
}
#[must_use]
pub fn learned(&self) -> usize {
self.aliases.values().map(BTreeMap::len).sum()
}
#[must_use]
pub fn aliases(&self) -> Vec<Alias> {
self.aliases
.values()
.flat_map(BTreeMap::values)
.cloned()
.collect()
}
fn superseded<'a>(&'a self, question: &Question<'_>, utterance: &str) -> Option<&'a Alias> {
let spoken = normalize(utterance);
let alias = self.aliases.get(question.id())?.get(&spoken)?;
if question
.options()
.iter()
.any(|option| option == alias.option())
{
return None;
}
Some(alias)
}
#[must_use]
pub fn decide_for(&self, utterance: &str, question: &Question<'_>) -> Resolution {
if question.domain() == AnswerDomain::Integer {
return decide_integer(utterance, question, MATCH_MARGIN);
}
let verdict = decide(
&score_all(utterance, question, &self.aliases, &self.distance),
MATCH_THRESHOLD,
MATCH_MARGIN,
);
if !matches!(verdict, Resolution::Absent { .. }) {
return verdict;
}
match self.superseded(question, utterance) {
Some(alias) => Resolution::StaleAlias {
question: alias.question().to_owned(),
option: alias.option().to_owned(),
},
None => verdict,
}
}
#[must_use]
pub fn policy_version(&self) -> PolicyVersion {
PolicyVersion::new(
POLICY_LABEL,
&[
MATCH_THRESHOLD,
MATCH_MARGIN,
TOKEN_WEIGHT,
DISTANCE_WEIGHT,
f64::from(INPUT_BOUND_CHARS),
],
)
}
}
impl Default for LanguageResolver {
fn default() -> Self {
Self::new()
}
}
impl crate::session::Resolver for LanguageResolver {
fn resolve(&self, utterance: &str, question: &Question<'_>) -> Verdict {
Verdict::new(
self.decide_for(utterance, question),
Provenance::without_model(self.policy_version()),
)
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_digest_input_bound_is_the_shipped_bound() {
assert_eq!(
usize::try_from(u64::from(INPUT_BOUND_CHARS)).ok(),
Some(MAX_UTTERANCE_CHARS),
"INPUT_BOUND_CHARS and MAX_UTTERANCE_CHARS are one bound under two \
spellings; if they differ, the policy digest names a bound the \
resolver does not apply"
);
}
use super::*;
use crate::session::Resolver;
const IDENTICAL: &str = "a monthly account statement should be sent to my email address";
const PHONETIC_TWIN: &str = "a monthlee account statement should be sent to my email address";
const FUZZY_TWIN: &str = "monthly a account statement should be sent to my email address";
fn options() -> Vec<String> {
vec![
String::from("Yes, continue"),
String::from("No, go back"),
String::from("Speak to a person"),
]
}
fn ask(options: &[String]) -> Question<'_> {
Question::new("ask", options)
}
fn selected(verdict: &Verdict) -> Option<(usize, MatchTier)> {
match *verdict.resolution() {
Resolution::Resolved { index, tier, .. } => Some((index, tier)),
_ => None,
}
}
fn tied(verdict: &Verdict) -> Option<(Vec<usize>, MatchTier)> {
match *verdict.resolution() {
Resolution::Ambiguous { ref tied, tier, .. } => Some((tied.clone(), tier)),
_ => None,
}
}
fn assert_close(left: f64, right: f64) {
assert!(
(left - right).abs() < 1e-9,
"expected {right}, observed {left}"
);
}
#[test]
fn normalization_folds_case_punctuation_and_accents() {
assert_eq!(normalize(" Yes, CONTINUE! "), "yes continue");
assert_eq!(normalize("Café"), "cafe");
assert_eq!(
normalize("Œuvre"),
"ouvre",
"a ligature folds to its base letter, not its expansion"
);
assert_eq!(normalize("Straße"), "strase");
assert_eq!(normalize("!!!"), "");
}
#[test]
fn normalization_treats_unsupported_scripts_as_passthrough() {
assert_eq!(normalize("日本"), "日本");
}
#[test]
fn phonetic_keys_collapse_spelling_variation() {
assert_eq!(phonetic_key("Smith"), phonetic_key("Smyth"));
assert_eq!(phonetic_key("there"), phonetic_key("their"));
assert_eq!(phonetic_key("Straße"), phonetic_key("Strasse"));
assert_eq!(phonetic_key("Kathryn"), phonetic_key("Kathrin"));
}
#[test]
fn phonetic_keys_preserve_the_initial_and_so_do_not_collapse_catherine() {
assert_eq!(phonetic_key("Catherine"), "C365");
assert_eq!(phonetic_key("Kathryn"), "K365");
assert_ne!(phonetic_key("cat"), phonetic_key("kat"));
assert_ne!(phonetic_key("cat"), phonetic_key("sat"));
}
#[test]
fn phonetic_keys_distinguish_unrelated_words() {
assert_ne!(phonetic_key("continue"), phonetic_key("person"));
}
#[test]
fn an_input_with_no_letters_has_an_empty_phonetic_key() {
assert_eq!(phonetic_key("123"), "");
assert_eq!(phonetic_key("!!!"), "");
}
fn clears_the_margin(lead: f64) -> bool {
lead >= MATCH_MARGIN
}
#[test]
fn an_exact_normalized_match_resolves_at_the_exact_tier() {
let resolution = LanguageResolver::new()
.resolve(" yes, CONTINUE ", &ask(&options()))
.into_resolution();
assert!(
matches!(
resolution,
Resolution::Resolved {
index: 0,
tier: MatchTier::Exact,
score,
lead,
} if (score - 1.0).abs() < 1e-9
&& (lead - score).abs() < 1e-9
&& clears_the_margin(lead)
),
"expected an exact resolution of option 0 over a field with no exact rival, got {resolution:?}"
);
}
#[test]
fn a_spelling_variation_resolves_at_the_phonetic_tier() {
let choices = vec![String::from("Smyth"), String::from("Marcus")];
let resolution = LanguageResolver::new()
.resolve("Smith", &ask(&choices))
.into_resolution();
assert_eq!(
resolution,
Resolution::Resolved {
index: 0,
tier: MatchTier::Phonetic,
score: 0.9,
lead: 0.9,
}
);
}
#[test]
fn an_unrecognized_answer_is_absent() {
let resolution = LanguageResolver::new()
.resolve("maybe later", &ask(&options()))
.into_resolution();
assert!(
matches!(resolution, Resolution::Absent { .. }),
"expected Absent, got {resolution:?}"
);
}
#[test]
fn two_equally_close_options_are_ambiguous_and_both_are_tied() {
let choices = vec![
String::from("Accept the offer"),
String::from("Accept the order"),
];
let verdict = LanguageResolver::new().resolve("accept the", &ask(&choices));
assert_eq!(
tied(&verdict),
Some((vec![0, 1], MatchTier::Phonetic)),
"both options must stay in play, and the tie names its tier: {verdict:?}"
);
}
#[test]
fn a_fuzzy_tie_is_reported_at_the_fuzzy_tier() {
let choices = vec![
String::from(FUZZY_TWIN),
String::from("monthly a account statement should be sent to my email address!"),
];
let resolution = LanguageResolver::new().resolve(IDENTICAL, &ask(&choices));
assert_eq!(
tied(&resolution),
Some((vec![0, 1], MatchTier::Fuzzy)),
"two equally fuzzy candidates stay ambiguous: {resolution:?}"
);
}
#[test]
fn a_learned_alias_resolves_exactly_and_is_revocable() {
let mut resolver = LanguageResolver::new();
assert_eq!(resolver.learned(), 0);
assert_eq!(
resolver.learn("ask", "the usual", "Speak to a person"),
None,
"a fresh alias"
);
assert_eq!(resolver.learned(), 1);
assert_eq!(
resolver
.resolve("The usual!", &ask(&options()))
.into_resolution(),
Resolution::Resolved {
index: 2,
tier: MatchTier::Exact,
score: 1.0,
lead: 1.0,
},
"an alias is a confirmed exact match, not a fuzzy guess"
);
let replaced = resolver.learn("ask", "the usual", "No, go back");
assert_eq!(
replaced.as_ref().map(Alias::option),
Some("Speak to a person"),
"re-teaching returns the binding it replaced, so a correction is \
distinguishable from a first lesson"
);
assert_eq!(
resolver
.forget("ask", "the usual")
.as_ref()
.map(Alias::option),
Some("No, go back"),
"forgetting a learned alias returns the row it revoked"
);
assert_eq!(
resolver.forget("ask", "the usual"),
None,
"forgetting a learned alias twice revokes nothing"
);
assert_eq!(resolver.learned(), 0);
}
#[test]
fn a_shipped_alias_table_is_normalized_on_load() {
let resolver = LanguageResolver::with_aliases(vec![Alias::new(
"ask",
" The Usual ",
"Speak to a person",
)]);
assert_eq!(
resolver
.resolve("the usual", &ask(&options()))
.into_resolution(),
Resolution::Resolved {
index: 2,
tier: MatchTier::Exact,
score: 1.0,
lead: 1.0,
}
);
}
#[test]
fn an_alias_follows_its_option_to_a_new_position() {
let resolver = LanguageResolver::with_aliases(vec![Alias::new(
"ask",
"the usual",
"Repeat last order",
)]);
for (choices, expected) in [
(vec!["Repeat last order", "Cancel"], 0_usize),
(vec!["Cancel", "Repeat last order"], 1),
] {
let options = choices
.iter()
.map(|name| (*name).to_owned())
.collect::<Vec<_>>();
let verdict = resolver.resolve("the usual", &ask(&options));
assert_eq!(
selected(&verdict),
Some((expected, MatchTier::Exact)),
"the confirmation must travel with its option, not its index: \
{choices:?} gave {verdict:?}"
);
}
}
#[test]
fn an_alias_is_not_visible_from_another_question() {
let resolver = LanguageResolver::with_aliases(vec![Alias::new(
"ask",
"the usual",
"Repeat last order",
)]);
let choices = vec![
String::from("Repeat last order"),
String::from("Cancel"),
String::from("Delete account"),
];
let elsewhere = resolver.resolve("the usual", &Question::new("some_other_ask", &choices));
assert_eq!(
selected(&elsewhere),
None,
"another question must not consume this question's confirmation: {elsewhere:?}"
);
assert!(
matches!(
elsewhere.resolution(),
Resolution::Absent { best_score }
if *best_score > 0.0 && *best_score < MATCH_THRESHOLD
),
"the phrase is simply unrecognized there — measured, and nowhere near \
the threshold — not quietly bound to row 0: {elsewhere:?}"
);
}
#[test]
fn a_superseded_alias_is_reported_rather_than_spent_on_the_new_option() {
let resolver = LanguageResolver::with_aliases(vec![Alias::new(
"ask",
"the usual",
"Repeat last order",
)]);
let choices = vec![String::from("Delete account"), String::from("Keep account")];
let verdict = resolver.resolve("the usual", &ask(&choices));
assert_eq!(
verdict.resolution(),
&Resolution::StaleAlias {
question: String::from("ask"),
option: String::from("Repeat last order"),
},
"the binding names the option that went away, not the one at its old index"
);
assert!(
!matches!(verdict.resolution(), Resolution::Resolved { .. }),
"a withdrawn confirmation must never select an option"
);
}
#[test]
fn a_visible_option_outranks_a_superseded_alias() {
let resolver =
LanguageResolver::with_aliases(vec![Alias::new("ask", "yes", "No, go back")]);
let choices = vec![String::from("Yes")];
let verdict = resolver.resolve("yes", &ask(&choices));
assert_eq!(
selected(&verdict),
Some((0, MatchTier::Exact)),
"the phrase is on screen and the reading does not come from the \
alias at all; refusing an answer the person can see to report a \
fact about the alias table would be the tail wagging the dog: \
{verdict:?}"
);
}
#[test]
fn one_phrase_can_mean_two_things_in_two_questions() {
let mut resolver = LanguageResolver::new();
resolver.learn("ask_one", "the usual", "Repeat last order");
assert_eq!(
resolver.learn("ask_two", "the usual", "Cancel"),
None,
"a second question's first lesson has no predecessor in *that* question"
);
assert_eq!(resolver.learned(), 2, "both questions kept their own row");
let choices = vec![String::from("Repeat last order"), String::from("Cancel")];
assert_eq!(
selected(&resolver.resolve("the usual", &Question::new("ask_one", &choices))),
Some((0, MatchTier::Exact)),
"in the first question the phrase means the first option"
);
assert_eq!(
selected(&resolver.resolve("the usual", &Question::new("ask_two", &choices))),
Some((1, MatchTier::Exact)),
"in the second question it means the second"
);
}
#[test]
fn a_normalized_collision_rebinds_one_row_and_returns_the_replaced_one() {
let mut resolver = LanguageResolver::new();
resolver.learn("ask", "The Usual", "Speak to a person");
let replaced = resolver.learn("ask", "the usual!", "No, go back");
assert_eq!(
replaced.as_ref().map(Alias::option),
Some("Speak to a person"),
"the collision is a rebinding of one phrase, and it is visible"
);
assert_eq!(resolver.learned(), 1, "one phrase is one row");
assert_eq!(
selected(&resolver.resolve("the usual", &ask(&options()))),
Some((1, MatchTier::Exact)),
"the later binding is the live one"
);
}
#[test]
fn an_exported_table_reloads_unchanged() {
let mut resolver = LanguageResolver::new();
resolver.learn("ask", "the usual", "Repeat last order");
resolver.learn("ask", "nah", "Cancel");
let table = resolver.aliases();
assert_eq!(table.len(), 2, "both rows export");
assert!(
table.iter().any(|alias| alias.question() == "ask"
&& alias.utterance() == "the usual"
&& alias.option() == "Repeat last order"),
"a row is readable without knowing how it was stored: {table:?}"
);
let reloaded = LanguageResolver::with_aliases(table);
assert_eq!(reloaded.learned(), resolver.learned());
let choices = vec![String::from("Repeat last order"), String::from("Cancel")];
for utterance in ["the usual", "nah"] {
assert_eq!(
reloaded.resolve(utterance, &ask(&choices)),
resolver.resolve(utterance, &ask(&choices)),
"a migrated table resolves exactly as the one it came from"
);
}
}
#[test]
fn the_fuzzy_weights_account_for_the_whole_score() {
assert_close(TOKEN_WEIGHT + DISTANCE_WEIGHT, 1.0);
}
#[test]
fn a_long_utterance_degrades_rather_than_panicking() {
let long = "yes ".repeat(MAX_UTTERANCE_CHARS);
let resolution = LanguageResolver::new()
.resolve(&long, &ask(&options()))
.into_resolution();
assert!(
matches!(
resolution,
Resolution::Resolved { .. } | Resolution::Absent { .. }
),
"an over-limit input must produce a verdict, got {resolution:?}"
);
}
#[test]
fn an_empty_option_list_is_absent_not_a_panic() {
assert_eq!(
LanguageResolver::new()
.resolve("yes", &ask(&[]))
.into_resolution(),
Resolution::Absent { best_score: 0.0 }
);
}
#[test]
fn the_fuzzy_competitor_really_outscores_the_phonetic_tier() {
let alone = vec![String::from(FUZZY_TWIN)];
let verdict = LanguageResolver::new().resolve(IDENTICAL, &ask(&alone));
assert!(
matches!(
verdict.resolution(),
Resolution::Resolved {
tier: MatchTier::Fuzzy,
score,
..
} if *score > 0.9
),
"the fuzzy competitor must score above the phonetic tier's 0.9: {verdict:?}"
);
}
#[test]
fn an_exact_answer_is_not_vetoed_by_a_fuzzy_competitor() {
let choices = vec![String::from(IDENTICAL), String::from(FUZZY_TWIN)];
let verdict = LanguageResolver::new().resolve(IDENTICAL, &ask(&choices));
assert_eq!(
selected(&verdict),
Some((0, MatchTier::Exact)),
"a unique exact answer must not be vetoed by a lower tier: {verdict:?}"
);
}
#[test]
fn an_exact_answer_is_not_vetoed_by_a_phonetic_competitor() {
let choices = vec![String::from(IDENTICAL), String::from(PHONETIC_TWIN)];
let verdict = LanguageResolver::new().resolve(IDENTICAL, &ask(&choices));
assert_eq!(
selected(&verdict),
Some((0, MatchTier::Exact)),
"the phonetic tier ranks below the exact tier: {verdict:?}"
);
}
#[test]
fn a_phonetic_candidate_outranks_a_higher_scoring_fuzzy_candidate() {
let choices = vec![String::from(PHONETIC_TWIN), String::from(FUZZY_TWIN)];
let verdict = LanguageResolver::new().resolve(IDENTICAL, &ask(&choices));
assert_eq!(
selected(&verdict),
Some((0, MatchTier::Phonetic)),
"precedence is the tier order, not the numeric score: {verdict:?}"
);
}
#[test]
fn two_normalized_equal_exact_options_tie_rather_than_taking_the_first() {
let choices = vec![String::from("Yes, continue"), String::from("yes continue!")];
let verdict = LanguageResolver::new().resolve("yes continue", &ask(&choices));
assert_eq!(
tied(&verdict),
Some((vec![0, 1], MatchTier::Exact)),
"an exact collision stays ambiguous at the exact tier: {verdict:?}"
);
}
#[test]
fn a_learned_alias_conflicting_with_a_normalized_exact_option_ties() {
let mut resolver = LanguageResolver::new();
resolver.learn("ask", "yes", "No");
let choices = vec![String::from("Yes"), String::from("No")];
let verdict = resolver.resolve("yes", &ask(&choices));
assert_eq!(
tied(&verdict),
Some((vec![0, 1], MatchTier::Exact)),
"a conflicting confirmation is a tie, not an override: {verdict:?}"
);
}
#[test]
fn tier_precedence_survives_option_permutation() {
let resolver = LanguageResolver::new();
for (choices, expected) in [
(
vec![String::from(IDENTICAL), String::from(FUZZY_TWIN)],
(0_usize, MatchTier::Exact),
),
(
vec![String::from(FUZZY_TWIN), String::from(IDENTICAL)],
(1, MatchTier::Exact),
),
(
vec![String::from(PHONETIC_TWIN), String::from(FUZZY_TWIN)],
(0, MatchTier::Phonetic),
),
(
vec![String::from(FUZZY_TWIN), String::from(PHONETIC_TWIN)],
(1, MatchTier::Phonetic),
),
] {
let verdict = resolver.resolve(IDENTICAL, &ask(&choices));
assert_eq!(
selected(&verdict),
Some(expected),
"the winning option must follow its text, not its position: {choices:?} gave {verdict:?}"
);
}
}
fn amounts(choices: &[&str]) -> Vec<String> {
choices.iter().map(|choice| (*choice).to_owned()).collect()
}
fn numeric(options: &[String]) -> Question<'_> {
Question::new("amount", options).with_domain(AnswerDomain::Integer)
}
#[test]
fn integer_decoding_trims_surrounding_space_and_reads_a_leading_sign() {
for (raw, expected) in [
("5", Some(5_i64)),
("+5", Some(5)),
("-5", Some(-5)),
(" 5 ", Some(5)),
("0", Some(0)),
("-0", Some(0)),
("007", Some(7)),
("", None),
("five", None),
("5.0", None),
("5,000", None),
("9223372036854775807", Some(i64::MAX)),
("-9223372036854775808", Some(i64::MIN)),
("9223372036854775808", None),
("-9223372036854775809", None),
] {
assert_eq!(
decode_integer(raw),
expected,
"{raw:?} must decode to {expected:?} and nothing else"
);
}
}
#[test]
fn the_label_fold_is_exactly_why_a_number_needs_its_own_domain() {
assert_eq!(
normalize("-5"),
normalize("5"),
"the fold collapses the sign, which is why it must not be applied to a value"
);
let options = amounts(&["-5", "5"]);
let verdict = LanguageResolver::new().resolve("-5", &ask(&options));
assert_eq!(
tied(&verdict),
Some((vec![0, 1], MatchTier::Exact)),
"under the label domain the sign is punctuation: {verdict:?}"
);
}
#[test]
fn a_numeric_question_selects_the_value_and_never_the_spelling() {
let resolver = LanguageResolver::new();
let options = amounts(&["-5", "5", "0", "10"]);
let question = numeric(&options);
for (utterance, expected) in [
("-5", 0_usize),
("5", 1),
("+5", 1),
(" 5 ", 1),
("0", 2),
("10", 3),
] {
let verdict = resolver.resolve(utterance, &question);
assert_eq!(
selected(&verdict),
Some((expected, MatchTier::Exact)),
"{utterance:?} must select the option holding that value: {verdict:?}"
);
}
}
#[test]
fn a_number_no_option_holds_is_absent_rather_than_a_nearby_option() {
let resolver = LanguageResolver::new();
let options = amounts(&["5", "10"]);
let question = numeric(&options);
assert_eq!(
selected(&resolver.resolve("-5", &ask(&options))),
Some((0, MatchTier::Exact)),
"the untyped reading of this question is the defect, exactly as filed"
);
for utterance in ["-5", "five", "5.0", "9223372036854775808", ""] {
assert_eq!(
resolver.resolve(utterance, &question).into_resolution(),
Resolution::Absent { best_score: 0.0 },
"{utterance:?} is not one of the offered values and no lexical, \
phonetic or fuzzy tier may turn it into one that is"
);
}
}
#[test]
fn a_numeric_reading_is_not_vetoed_by_a_similar_number() {
let options = amounts(&["-5", "-50"]);
let verdict = LanguageResolver::new().resolve("-5", &numeric(&options));
assert_eq!(
selected(&verdict),
Some((0, MatchTier::Exact)),
"the value, not its similarity: {verdict:?}"
);
}
#[test]
fn a_numeric_question_does_not_consult_the_alias_table() {
let resolver = LanguageResolver::with_aliases(vec![Alias::new("amount", "-5", "5")]);
assert_eq!(
resolver.learned(),
1,
"the premise: the alias really is held under the folded key"
);
let options = amounts(&["5", "10"]);
assert_eq!(
resolver.resolve("-5", &numeric(&options)).into_resolution(),
Resolution::Absent { best_score: 0.0 },
"the taught phrase must not reach a question read as values"
);
assert_eq!(
selected(&resolver.resolve("5", &numeric(&options))),
Some((0, MatchTier::Exact)),
"and the value itself still resolves, by value rather than by alias"
);
}
#[test]
fn two_options_holding_one_value_tie_rather_than_taking_the_first() {
let options = amounts(&["5", "05"]);
let verdict = LanguageResolver::new().resolve("5", &numeric(&options));
assert_eq!(
tied(&verdict),
Some((vec![0, 1], MatchTier::Exact)),
"two options hold 5, so no option is preferred: {verdict:?}"
);
}
fn measured(scores: &[(usize, f64)]) -> Vec<(usize, MatchTier, f64)> {
scores
.iter()
.copied()
.map(|(index, score)| (index, MatchTier::Semantic, score))
.collect()
}
#[test]
fn the_lead_is_measured_against_the_highest_other_measured_score() {
let field = measured(&[(0, 0.90), (1, 0.10)]);
assert_eq!(
decide(&field, 0.5, 0.08),
Resolution::Resolved {
index: 0,
tier: MatchTier::Semantic,
score: 0.90,
lead: 0.80,
},
"the lead is the gap to the runner-up, not the score"
);
}
#[test]
fn a_runner_up_just_below_the_threshold_still_counts_against_the_margin() {
let field = measured(&[(0, 0.73), (1, 0.71)]);
assert_eq!(
decide(&field, 0.72, 0.05),
Resolution::Ambiguous {
tied: vec![0, 1],
tier: MatchTier::Semantic,
score: 0.73,
},
"a competitor below the threshold is still a competitor"
);
}
#[test]
fn the_straddling_pair_is_not_special_to_a_whole_hundredth() {
let below = 0.72 - 1e-12;
let field = measured(&[(0, 0.72), (1, below)]);
assert_eq!(
decide(&field, 0.72, 0.05),
Resolution::Ambiguous {
tied: vec![0, 1],
tier: MatchTier::Semantic,
score: 0.72,
},
"a one-epsilon perturbation across the threshold must not manufacture confidence"
);
}
#[test]
fn a_field_entirely_below_the_threshold_is_absent_at_its_best_measured_score() {
let field = measured(&[(0, 0.40), (1, 0.30)]);
assert_eq!(
decide(&field, 0.72, 0.05),
Resolution::Absent { best_score: 0.40 },
"the closest option was measured, so `0.0` would be a value nothing produced"
);
}
#[test]
fn a_lone_option_reports_its_whole_score_as_the_lead() {
let field = measured(&[(0, 0.80)]);
assert_eq!(
decide(&field, 0.72, 0.05),
Resolution::Resolved {
index: 0,
tier: MatchTier::Semantic,
score: 0.80,
lead: 0.80,
}
);
assert_eq!(
decide(&measured(&[(0, 0.50)]), 0.72, 0.05),
Resolution::Absent { best_score: 0.50 },
"a lone option that does not clear the threshold is absent, not resolved"
);
assert_eq!(
decide(&[], 0.72, 0.05),
Resolution::Absent { best_score: 0.0 },
"no measured candidate at all is the empty field"
);
}
#[test]
fn an_exact_tie_is_ambiguous_under_a_positive_margin() {
let field = measured(&[(0, 0.80), (1, 0.80)]);
assert_eq!(
decide(&field, 0.72, 0.05),
Resolution::Ambiguous {
tied: vec![0, 1],
tier: MatchTier::Semantic,
score: 0.80,
},
"a tie holds no lead at all"
);
}
#[test]
fn a_zero_margin_resolves_a_tie_at_the_lowest_index_by_declared_policy() {
let field = measured(&[(0, 0.80), (1, 0.80)]);
assert_eq!(
decide(&field, 0.72, 0.0),
Resolution::Resolved {
index: 0,
tier: MatchTier::Semantic,
score: 0.80,
lead: 0.0,
}
);
assert_eq!(
decide(&measured(&[(0, 0.40), (1, 0.40)]), 0.72, 0.0),
Resolution::Absent { best_score: 0.40 }
);
}
#[test]
fn the_tied_set_is_lowest_index_first_whatever_order_it_arrives_in() {
let ascending = measured(&[(0, 0.80), (1, 0.80), (2, 0.80)]);
let descending = measured(&[(2, 0.80), (1, 0.80), (0, 0.80)]);
let expected = Resolution::Ambiguous {
tied: vec![0, 1, 2],
tier: MatchTier::Semantic,
score: 0.80,
};
assert_eq!(decide(&ascending, 0.72, 0.05), expected);
assert_eq!(decide(&descending, 0.72, 0.05), expected);
}
#[test]
fn permuting_the_option_list_does_not_change_the_verdict() {
let resolver = LanguageResolver::new();
let forwards = vec![
String::from("Accept the offer"),
String::from("Accept the order"),
];
let backwards = vec![
String::from("Accept the order"),
String::from("Accept the offer"),
];
assert_eq!(
resolver.resolve("accept the", &ask(&forwards)),
resolver.resolve("accept the", &ask(&backwards)),
"the same options in a different order must reach the same verdict"
);
}
#[test]
fn every_option_is_measured_even_when_none_clears_the_threshold() {
let choices = vec![String::from("Yes, continue"), String::from("No, go back")];
let resolution = LanguageResolver::new().resolve("yes", &ask(&choices));
assert!(
matches!(
resolution.resolution(),
Resolution::Absent { best_score }
if *best_score > 0.0 && *best_score < MATCH_THRESHOLD
),
"expected Absent at the best measured score below the threshold, got {resolution:?}"
);
}
}