use crate::name_correction::{levenshtein, normalize_name};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MatchReason {
SeparatorVariant,
PhoneticEdit,
}
const MIN_COMPACT_LEN: usize = 3;
pub(crate) fn compact_key(name: &str) -> String {
normalize_name(name)
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
fn distance_budget(len: usize) -> usize {
if len >= 6 {
2
} else {
1
}
}
fn is_single_token(name: &str) -> bool {
normalize_name(name).split_whitespace().count() == 1
}
pub(crate) fn names_plausibly_same_person(a: &str, b: &str) -> Option<MatchReason> {
let ca = compact_key(a);
let cb = compact_key(b);
if ca.is_empty() || cb.is_empty() {
return None;
}
if ca == cb {
if ca.chars().count() >= MIN_COMPACT_LEN {
return Some(MatchReason::SeparatorVariant);
}
return None;
}
if !is_single_token(a) || !is_single_token(b) {
return None;
}
let na = normalize_name(a);
let nb = normalize_name(b);
if !na.is_ascii() || !nb.is_ascii() {
return None;
}
if na.as_bytes().first() != nb.as_bytes().first() {
return None;
}
let budget = distance_budget(na.chars().count().min(nb.chars().count()));
if levenshtein(&na, &nb) <= budget {
Some(MatchReason::PhoneticEdit)
} else {
None
}
}
pub(crate) fn cluster_indices(n: usize, edges: &[(usize, usize)]) -> Vec<Vec<usize>> {
use std::collections::{BTreeMap, BTreeSet};
let mut edge_set: BTreeSet<(usize, usize)> = BTreeSet::new();
for &(a, b) in edges {
if a >= n || b >= n || a == b {
continue;
}
edge_set.insert((a.min(b), a.max(b)));
}
let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], x: usize) -> usize {
let mut root = x;
while parent[root] != root {
root = parent[root];
}
let mut cur = x;
while parent[cur] != root {
let next = parent[cur];
parent[cur] = root;
cur = next;
}
root
}
for &(a, b) in &edge_set {
let ra = find(&mut parent, a);
let rb = find(&mut parent, b);
if ra != rb {
if ra < rb {
parent[rb] = ra;
} else {
parent[ra] = rb;
}
}
}
let mut components: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for i in 0..n {
let root = find(&mut parent, i);
components.entry(root).or_default().push(i);
}
let is_edge = |a: usize, b: usize| edge_set.contains(&(a.min(b), a.max(b)));
let mut clusters: Vec<Vec<usize>> = Vec::new();
for members in components.into_values() {
if members.len() < 2 {
continue;
}
let is_clique = members
.iter()
.enumerate()
.all(|(i, &a)| members[i + 1..].iter().all(|&b| is_edge(a, b)));
if is_clique {
clusters.push(members); } else {
for i in 0..members.len() {
for j in (i + 1)..members.len() {
if is_edge(members[i], members[j]) {
clusters.push(vec![members[i], members[j]]);
}
}
}
}
}
clusters.sort();
clusters
}
#[cfg(test)]
pub(crate) fn cluster_names(names: &[String]) -> Vec<Vec<usize>> {
let mut edges = Vec::new();
for i in 0..names.len() {
for j in (i + 1)..names.len() {
if names_plausibly_same_person(&names[i], &names[j]).is_some() {
edges.push((i, j));
}
}
}
cluster_indices(names.len(), &edges)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compact_key_folds_separators_case_and_accents() {
assert_eq!(compact_key("Mo-Han"), "mohan");
assert_eq!(compact_key("jun-rei"), "junrei");
assert_eq!(compact_key("José"), "jose");
}
#[test]
fn separator_variants_link() {
assert_eq!(
names_plausibly_same_person("jun-rei", "junrei"),
Some(MatchReason::SeparatorVariant)
);
}
#[test]
fn short_separator_keys_do_not_link() {
assert_eq!(names_plausibly_same_person("d-p", "dp"), None);
}
#[test]
fn spelling_edit_drift_links() {
assert_eq!(
names_plausibly_same_person("geert", "gert"),
Some(MatchReason::PhoneticEdit)
);
assert!(names_plausibly_same_person("junrei", "junlei").is_some());
assert!(names_plausibly_same_person("junrei", "junwei").is_some());
}
#[test]
fn dissimilar_names_do_not_link() {
assert_eq!(names_plausibly_same_person("carl", "karl"), None);
assert_eq!(names_plausibly_same_person("carl", "deepak"), None);
assert_eq!(names_plausibly_same_person("sarah", "sam"), None);
assert_eq!(names_plausibly_same_person("bright", "liam"), None);
}
#[test]
fn non_ascii_near_miss_does_not_link() {
assert_eq!(names_plausibly_same_person("李雷", "李蕾"), None);
}
#[test]
fn multi_token_names_use_separator_tier_only() {
assert_eq!(names_plausibly_same_person("Alex Chen", "Alex Kim"), None);
}
#[test]
fn clique_clustering_groups_a_drift_set() {
let names: Vec<String> = ["jun-rei", "junrei", "junlei", "junwei", "bright"]
.iter()
.map(|s| s.to_string())
.collect();
let clusters = cluster_names(&names);
assert_eq!(clusters.len(), 1, "one cluster, bright excluded");
assert_eq!(clusters[0], vec![0, 1, 2, 3]);
}
#[test]
fn drift_chain_does_not_bridge_endpoints() {
let names: Vec<String> = ["jon", "jan", "jana"]
.iter()
.map(|s| s.to_string())
.collect();
let clusters = cluster_names(&names);
for c in &clusters {
assert!(
!(c.contains(&0) && c.contains(&2)),
"drift-chain endpoints jon/jana were bridged: {c:?}"
);
}
}
#[test]
fn clustering_is_deterministic_and_excludes_singletons() {
let names: Vec<String> = ["zeta", "gert", "geert"]
.iter()
.map(|s| s.to_string())
.collect();
let clusters = cluster_names(&names);
assert_eq!(clusters, vec![vec![1, 2]]);
}
}