Functional Weave
Code in Python

legal.conflict-name-match@1.0.1

impl/rust.rs

4,659 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::text_normalise_name::normalise_name;  ← from text.normalise-name ^1.0.0 · built alongside by fune

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<char> = 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<char> = 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<NameMatch> {
    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<char> = key.chars().collect();
    let mut matches: Vec<NameMatch> = Vec::new();
    for (index, candidate) in candidates.iter().enumerate() {
        let other = match_key(candidate);
        let s2: Vec<char> = 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<String> = 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())
}