use std::collections::{BTreeMap, BTreeSet};
use rayon::prelude::*;
use crate::config::{EmptyPolicy, Match, Normalize, Tier};
use crate::fuzzy::fuzzy_canonicals;
use crate::index::{Cell, KeyId, Matrix};
use crate::normalize::{exact, normalized};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CandidateGroup {
pub keys: Vec<KeyId>,
pub tier: Tier,
pub agree_locales: usize,
pub total_locales: usize,
pub shared: BTreeMap<String, Cell>,
pub differ: Vec<String>,
pub cross_domain: bool,
}
pub fn group_tier(matrix: &Matrix, tier: Tier, config: &Match) -> Vec<CandidateGroup> {
let canon = canonical_matrix(
matrix,
tier,
&config.normalize,
config.fuzzy_max_distance,
config.fuzzy_min_length,
);
bucket(&canon, tier, config.empty_policy, config.min_locales_agree)
}
pub fn canonical_matrix(
matrix: &Matrix,
tier: Tier,
normalize: &Normalize,
fuzzy_max_distance: usize,
fuzzy_min_length: usize,
) -> Matrix {
match tier {
Tier::Exact => map_cells(matrix, exact),
Tier::Normalized => map_cells(matrix, |value| normalized(value, normalize)),
Tier::Fuzzy => fuzzy_matrix(matrix, fuzzy_max_distance, fuzzy_min_length),
}
}
fn map_cells(matrix: &Matrix, canon: impl Fn(&str) -> String) -> Matrix {
let rows = matrix
.rows
.iter()
.map(|(key, row)| {
(
key.clone(),
row.iter().map(|cell| map_cell(cell, &canon)).collect(),
)
})
.collect();
Matrix {
locales: matrix.locales.clone(),
rows,
}
}
fn map_cell(cell: &Cell, canon: impl Fn(&str) -> String) -> Cell {
match cell {
Cell::Value(value) => Cell::Value(canon(value)),
Cell::Empty => Cell::Empty,
}
}
fn fuzzy_matrix(matrix: &Matrix, max_distance: usize, min_length: usize) -> Matrix {
let width = matrix.locales.len();
let mut columns: Vec<Vec<String>> = vec![Vec::new(); width];
for row in matrix.rows.values() {
for (index, cell) in row.iter().enumerate() {
if let Cell::Value(value) = cell {
columns[index].push(value.clone());
}
}
}
let maps: Vec<BTreeMap<String, String>> = columns
.par_iter()
.map(|values| {
fuzzy_canonicals(values, max_distance, min_length)
.into_iter()
.collect()
})
.collect();
let rows = matrix
.rows
.iter()
.map(|(key, row)| {
let canon_row = row
.iter()
.enumerate()
.map(|(index, cell)| match cell {
Cell::Value(value) => Cell::Value(
maps[index]
.get(value)
.cloned()
.unwrap_or_else(|| value.clone()),
),
Cell::Empty => Cell::Empty,
})
.collect();
(key.clone(), canon_row)
})
.collect();
Matrix {
locales: matrix.locales.clone(),
rows,
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum SigToken {
Empty,
Value(String),
}
type SubSig = Vec<(usize, SigToken)>;
struct RawGroup<'a> {
subsig: SubSig,
keys: BTreeSet<&'a KeyId>,
}
impl EmptyPolicy {
fn present(self, row: &[Cell]) -> Vec<(usize, SigToken)> {
match self {
EmptyPolicy::Own => row
.iter()
.enumerate()
.map(|(i, cell)| (i, token(cell)))
.collect(),
EmptyPolicy::Skip => row
.iter()
.enumerate()
.filter_map(|(i, cell)| match cell {
Cell::Value(value) => Some((i, SigToken::Value(value.clone()))),
Cell::Empty => None,
})
.collect(),
}
}
fn admits(self, subsig: &SubSig) -> bool {
match self {
EmptyPolicy::Own => subsig.iter().any(|(_, t)| !matches!(t, SigToken::Empty)),
EmptyPolicy::Skip => true,
}
}
fn differ_total(
self,
retained: &BTreeSet<usize>,
width: usize,
group: &RawGroup,
canon: &Matrix,
) -> (Vec<usize>, usize) {
match self {
EmptyPolicy::Own => {
let differ = (0..width)
.filter(|index| !retained.contains(index))
.collect();
(differ, width)
}
EmptyPolicy::Skip => {
let differ: Vec<usize> = (0..width)
.filter(|index| !retained.contains(index))
.filter(|index| {
group
.keys
.iter()
.all(|key| !canon.rows[*key][*index].is_empty())
})
.collect();
let total = group.subsig.len() + differ.len();
(differ, total)
}
}
}
}
fn bucket(
canon: &Matrix,
tier: Tier,
policy: EmptyPolicy,
min_agree: usize,
) -> Vec<CandidateGroup> {
let width = canon.locales.len();
if min_agree == 0 || width == 0 {
return Vec::new();
}
let rows: Vec<(&KeyId, &Vec<Cell>)> = canon.rows.iter().collect();
let mut pairs: Vec<(SubSig, &KeyId)> = rows
.par_iter()
.flat_map_iter(|(key, row)| {
let key: &KeyId = key;
subsigs(&row[..], policy, min_agree)
.into_iter()
.map(move |subsig| (subsig, key))
})
.collect();
pairs.sort();
let mut raw: Vec<RawGroup> = Vec::new();
let mut start = 0;
while start < pairs.len() {
let mut end = start + 1;
while end < pairs.len() && pairs[end].0 == pairs[start].0 {
end += 1;
}
let keys: BTreeSet<&KeyId> = pairs[start..end].iter().map(|(_, key)| *key).collect();
if keys.len() >= 2 {
raw.push(RawGroup {
subsig: pairs[start].0.clone(),
keys,
});
}
start = end;
}
raw.sort_by(|a, b| {
b.subsig
.len()
.cmp(&a.subsig.len())
.then(b.keys.len().cmp(&a.keys.len()))
.then(a.subsig.cmp(&b.subsig))
});
let mut kept: Vec<RawGroup> = Vec::new();
for candidate in raw {
if kept
.iter()
.any(|group| candidate.keys.is_subset(&group.keys))
{
continue;
}
kept.push(candidate);
}
let mut groups: Vec<CandidateGroup> = kept
.into_iter()
.map(|group| build_group(group, canon, tier, policy, width))
.collect();
groups.sort_by(|a, b| {
a.keys
.cmp(&b.keys)
.then(b.agree_locales.cmp(&a.agree_locales))
});
groups
}
fn subsigs(row: &[Cell], policy: EmptyPolicy, min_agree: usize) -> Vec<SubSig> {
let present = policy.present(row);
let count = present.len();
if count < min_agree {
return Vec::new();
}
let max_drop = count - min_agree;
let mut out = Vec::new();
for size in 0..=max_drop {
for dropset in combinations(count, size) {
let dropped: BTreeSet<usize> = dropset.into_iter().collect();
let retained: SubSig = present
.iter()
.enumerate()
.filter(|(position, _)| !dropped.contains(position))
.map(|(_, pair)| pair.clone())
.collect();
if !policy.admits(&retained) {
continue;
}
out.push(retained);
}
}
out
}
fn token(cell: &Cell) -> SigToken {
match cell {
Cell::Value(value) => SigToken::Value(value.clone()),
Cell::Empty => SigToken::Empty,
}
}
fn combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
let mut result = Vec::new();
if k > n {
return result;
}
let mut combo: Vec<usize> = (0..k).collect();
loop {
result.push(combo.clone());
let mut index = k;
let mut advanced = false;
while index > 0 {
index -= 1;
if combo[index] != index + n - k {
advanced = true;
break;
}
}
if !advanced {
break;
}
combo[index] += 1;
for next in (index + 1)..k {
combo[next] = combo[next - 1] + 1;
}
}
result
}
fn build_group(
group: RawGroup,
canon: &Matrix,
tier: Tier,
policy: EmptyPolicy,
width: usize,
) -> CandidateGroup {
let retained: BTreeSet<usize> = group.subsig.iter().map(|(index, _)| *index).collect();
let keys: Vec<KeyId> = group.keys.iter().map(|key| (*key).clone()).collect();
let mut shared = BTreeMap::new();
for (index, tok) in &group.subsig {
let cell = match tok {
SigToken::Empty => Cell::Empty,
SigToken::Value(value) => Cell::Value(value.clone()),
};
shared.insert(canon.locales[*index].clone(), cell);
}
let (differ_cols, total) = policy.differ_total(&retained, width, &group, canon);
let differ = differ_cols
.into_iter()
.map(|index| canon.locales[index].clone())
.collect();
let cross_domain = keys
.iter()
.map(|key| &key.domain)
.collect::<BTreeSet<_>>()
.len()
> 1;
CandidateGroup {
keys,
tier,
agree_locales: group.subsig.len(),
total_locales: total,
shared,
differ,
cross_domain,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(domain: &str, msgid: &str) -> KeyId {
KeyId {
domain: domain.to_string(),
msgctxt: None,
msgid: msgid.to_string(),
msgid_plural: None,
}
}
fn val(value: &str) -> Cell {
Cell::Value(value.to_string())
}
fn matrix(locales: &[&str], rows: Vec<(KeyId, Vec<Cell>)>) -> Matrix {
Matrix {
locales: locales.iter().map(|l| l.to_string()).collect(),
rows: rows.into_iter().collect(),
}
}
fn config(tiers: Vec<Tier>, min_agree: usize) -> Match {
Match {
tiers,
min_locales_agree: min_agree,
..Match::default()
}
}
fn group(matrix: &Matrix, config: &Match) -> Vec<CandidateGroup> {
let mut all = Vec::new();
for &tier in &config.tiers {
all.extend(group_tier(matrix, tier, config));
}
all
}
#[test]
fn bucketing_is_tier_agnostic() {
let m = matrix(
&["en", "ru"],
vec![
(key("messages", "a"), vec![val("Save"), val("OK")]),
(key("messages", "b"), vec![val("Save"), val("OK")]),
],
);
for tier in [Tier::Exact, Tier::Normalized, Tier::Fuzzy] {
let groups = group(&m, &config(vec![tier], 2));
assert_eq!(groups.len(), 1, "tier {tier:?} should find one group");
assert_eq!(groups[0].agree_locales, 2);
}
}
#[test]
fn full_duplicates_form_one_group() {
let m = matrix(
&["en", "es", "ru"],
vec![
(
key("m", "a"),
vec![val("Save"), val("Guardar"), val("Сохранить")],
),
(
key("m", "b"),
vec![val("Save"), val("Guardar"), val("Сохранить")],
),
],
);
let groups = group(&m, &config(vec![Tier::Exact], 2));
assert_eq!(groups.len(), 1);
let g = &groups[0];
assert_eq!(g.agree_locales, 3);
assert_eq!(g.total_locales, 3);
assert!(g.differ.is_empty());
assert_eq!(g.keys, vec![key("m", "a"), key("m", "b")]);
}
#[test]
fn agreement_in_m_minus_one_groups_at_that_level() {
let m = matrix(
&["en", "es", "ru"],
vec![
(
key("m", "a"),
vec![val("Save"), val("Guardar"), val("Сохранить")],
),
(
key("m", "b"),
vec![val("Save"), val("Guardar"), val("Сохрани")],
),
],
);
let groups = group(&m, &config(vec![Tier::Exact], 2));
assert_eq!(groups.len(), 1);
let g = &groups[0];
assert_eq!(g.agree_locales, 2);
assert_eq!(g.total_locales, 3);
assert_eq!(g.differ, vec!["ru".to_string()]);
}
#[test]
fn empty_policy_changes_results() {
let rows = || {
vec![
(
key("m", "a"),
vec![val("Save"), val("Guardar"), val("Сохранить")],
),
(
key("m", "b"),
vec![val("Save"), val("Guardar"), Cell::Empty],
),
]
};
let own = config(vec![Tier::Exact], 2);
let groups_own = group(&matrix(&["en", "es", "ru"], rows()), &own);
assert_eq!(groups_own.len(), 1);
assert_eq!(groups_own[0].total_locales, 3); assert_eq!(groups_own[0].differ, vec!["ru".to_string()]);
let mut skip = config(vec![Tier::Exact], 2);
skip.empty_policy = EmptyPolicy::Skip;
let groups_skip = group(&matrix(&["en", "es", "ru"], rows()), &skip);
assert_eq!(groups_skip.len(), 1);
assert_eq!(groups_skip[0].total_locales, 2); assert!(groups_skip[0].differ.is_empty());
}
#[test]
fn full_group_not_duplicated_at_lower_level() {
let m = matrix(
&["en", "es", "ru"],
vec![
(
key("m", "a"),
vec![val("Save"), val("Guardar"), val("Сохранить")],
),
(
key("m", "b"),
vec![val("Save"), val("Guardar"), val("Сохранить")],
),
],
);
let groups = group(&m, &config(vec![Tier::Exact], 2));
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].agree_locales, 3);
}
#[test]
fn cross_domain_is_flagged() {
let m = matrix(
&["en", "ru"],
vec![
(key("messages", "x"), vec![val("Save"), val("Сохранить")]),
(key("django", "x"), vec![val("Save"), val("Сохранить")]),
],
);
let groups = group(&m, &config(vec![Tier::Exact], 2));
assert_eq!(groups.len(), 1);
assert!(groups[0].cross_domain);
}
#[test]
fn single_domain_is_not_cross_domain() {
let m = matrix(
&["en", "ru"],
vec![
(key("m", "a"), vec![val("Save"), val("Сохранить")]),
(key("m", "b"), vec![val("Save"), val("Сохранить")]),
],
);
let groups = group(&m, &config(vec![Tier::Exact], 2));
assert!(!groups[0].cross_domain);
}
#[test]
fn results_are_deterministic() {
let m = matrix(
&["en", "es", "ru"],
vec![
(
key("m", "a"),
vec![val("Save"), val("Guardar"), val("Сохранить")],
),
(
key("m", "b"),
vec![val("Save"), val("Guardar"), val("Сохранить")],
),
(
key("m", "c"),
vec![val("Save"), val("Guardar"), val("Other")],
),
],
);
let cfg = config(vec![Tier::Exact, Tier::Normalized], 2);
assert_eq!(group(&m, &cfg), group(&m, &cfg));
}
#[test]
fn combinations_are_correct() {
assert_eq!(combinations(3, 0), vec![Vec::<usize>::new()]);
assert_eq!(combinations(3, 2), vec![vec![0, 1], vec![0, 2], vec![1, 2]]);
assert_eq!(combinations(2, 3).len(), 0);
}
fn fuzzy_canon(m: &Matrix) -> Matrix {
canonical_matrix(m, Tier::Fuzzy, &Normalize::default(), 2, 5)
}
#[test]
fn fuzzy_clusters_per_column_without_transpose() {
let a = key("m", "a");
let b = key("m", "b");
let m = matrix(
&["c0", "c1"],
vec![
(a.clone(), vec![val("delte"), val("delte")]),
(b.clone(), vec![val("delta"), val("zzzzz")]),
],
);
let canon = fuzzy_canon(&m);
assert_eq!(canon.rows[&a][0], val("delta"), "column 0 clusters");
assert_eq!(canon.rows[&a][1], val("delte"), "column 1 leaves it alone");
assert_eq!(canon.rows[&b][0], val("delta"));
assert_eq!(canon.rows[&b][1], val("zzzzz"));
}
#[test]
fn fuzzy_representative_is_lexicographically_smallest() {
let a = key("m", "a");
let b = key("m", "b");
let c = key("m", "c");
let m = matrix(
&["en"],
vec![
(a.clone(), vec![val("gammc")]),
(b.clone(), vec![val("gammb")]),
(c.clone(), vec![val("gamma")]),
],
);
let canon = fuzzy_canon(&m);
assert_eq!(canon.rows[&a][0], val("gamma"));
assert_eq!(canon.rows[&b][0], val("gamma"));
assert_eq!(canon.rows[&c][0], val("gamma"));
}
#[test]
fn fuzzy_min_length_keeps_short_strings_distinct() {
let a = key("m", "a");
let b = key("m", "b");
let m = matrix(
&["en"],
vec![(a.clone(), vec![val("on")]), (b.clone(), vec![val("off")])],
);
let canon = fuzzy_canon(&m);
assert_eq!(canon.rows[&a][0], val("on"));
assert_eq!(canon.rows[&b][0], val("off"));
}
#[test]
fn fuzzy_preserves_shape_and_empties() {
let a = key("m", "a");
let m = matrix(
&["en", "ru"],
vec![(a.clone(), vec![val("hello"), Cell::Empty])],
);
let canon = fuzzy_canon(&m);
assert_eq!(canon.locales, m.locales);
assert_eq!(canon.rows.len(), 1);
assert_eq!(canon.rows[&a].len(), 2);
assert_eq!(canon.rows[&a][1], Cell::Empty, "empties pass through");
}
}