use super::funejson::Value; use super::math_round_div::round_div; use super::text_normalise_name::normalise_name; const COMPANY_FORMS: [&str; 6] = ["ltd", "limited", "plc", "llp", "llc", "inc"]; const MAX_KEY: usize = 500; fn lower_one(ch: char) -> char { // One-to-one case mappings only, as text.normalise-name, so all three languages agree. let mapped: Vec = ch.to_lowercase().collect(); if mapped.len() == 1 { mapped[0] } else { ch } } fn is_ascii_punctuation(cp: u32) -> bool { (0x21..=0x2f).contains(&cp) || (0x3a..=0x40).contains(&cp) || (0x5b..=0x60).contains(&cp) || (0x7b..=0x7e).contains(&cp) } /// The normalised, punctuation-free, company-form-free, token-sorted key. fn match_key(value: &str) -> String { let mut text = String::new(); for ch in normalise_name(value).chars() { if ch == '\'' || ch == '\u{2019}' || ch == '.' { continue; } if ch == '&' { text.push_str(" and "); } else if is_ascii_punctuation(ch as u32) || ch == ' ' { text.push(' '); } else { text.push(lower_one(ch)); } } let mut tokens: Vec<&str> = text.split(' ').filter(|t| !t.is_empty() && !COMPANY_FORMS.contains(t)).collect(); // Byte order of UTF-8 is code point order. tokens.sort(); let key = tokens.join(" "); if key.chars().count() > MAX_KEY { panic!("names must be at most {} characters after normalising", MAX_KEY); } key } /// Jaro-Winkler similarity in basis points, from exact integer fractions. fn jaro_winkler(s1: &[char], s2: &[char]) -> i64 { let a = s1.len(); let b = s2.len(); if a == 0 || b == 0 { return 0; } let window = (a.max(b) / 2).saturating_sub(1); let mut used = vec![false; b]; let mut order1: Vec = Vec::new(); for i in 0..a { let lo = i.saturating_sub(window); let hi = (b - 1).min(i + window); for j in lo..=hi { if !used[j] && s1[i] == s2[j] { used[j] = true; order1.push(s1[i]); break; } } } let m = order1.len() as i64; if m == 0 { return 0; } let mut k = 0; let mut out_of_order: i64 = 0; for j in 0..b { if !used[j] { continue; } if s2[j] != order1[k] { out_of_order += 1; } k += 1; } let (a, b) = (a as i64, b as i64); // Jaro = n / d exactly, with t = out_of_order / 2. let n = 2 * m * m * (a + b) + a * b * (2 * m - out_of_order); let d = 6 * a * b * m; let mut prefix: i64 = 0; while prefix < 4 && prefix < a && prefix < b && s1[prefix as usize] == s2[prefix as usize] { prefix += 1; } if 10 * n <= 7 * d { return round_div(10000 * n, d, "half-up"); } round_div(10000 * (n * (10 - prefix) + prefix * d), 10 * d, "half-up") } /// Candidates similar to a name, best first, for a conflict check. /// /// # Panics /// Panics on a threshold outside 0..=10000, a name that normalises to nothing, /// or a key longer than 500 characters. pub fn conflict_name_match(name: &str, candidates: &[String], threshold_basis_points: i64) -> Vec { if !(0..=10000).contains(&threshold_basis_points) { panic!("thresholdBasisPoints must be between 0 and 10000, received {}", threshold_basis_points); } let key = match_key(name); if key.is_empty() { panic!("name is empty after normalising"); } let s1: Vec = key.chars().collect(); let mut matches: Vec = Vec::new(); for (index, candidate) in candidates.iter().enumerate() { let other = match_key(candidate); let s2: Vec = other.chars().collect(); let score = jaro_winkler(&s1, &s2); if score >= threshold_basis_points { matches.push(NameMatch { index: index as i64, candidate: candidate.clone(), match_key: other, score }); } } matches.sort_by(|x, y| y.score.cmp(&x.score).then(x.index.cmp(&y.index))); matches } pub fn name_match_to_value(m: &NameMatch) -> Value { Value::obj(vec![ ("index", Value::Int(m.index)), ("candidate", Value::str(&m.candidate)), ("matchKey", Value::str(&m.match_key)), ("score", Value::Int(m.score)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let candidates: Vec = args[1].as_arr().iter().map(|v| v.as_str().to_string()).collect(); Value::Arr(conflict_name_match(args[0].as_str(), &candidates, args[2].as_i64()).iter().map(name_match_to_value).collect()) }