use serde_json::{Value, json};
use crate::cluster_tree::util::score_range;
use crate::cluster_tree::{build_tree_impl, extend_id};
use crate::{
AxisPlan, Cluster, ClusterId, Diagnostics, FaceError, Record, SimilarAlgorithm, SkipReason,
SkipReport, Strategy,
};
pub(super) fn cluster<D: Diagnostics + ?Sized>(
plan: &AxisPlan,
items: Vec<Record>,
parent: &ClusterId,
diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
let Strategy::Similar { algorithm } = &plan.axis.strategy else {
unreachable!("similar clusterer is only called for Strategy::Similar")
};
let algorithm = *algorithm;
let mut groups: Vec<SimilarGroup> = Vec::new();
for (index, record) in items.into_iter().enumerate() {
let Ok(resolved) = crate::path::resolve(&record.raw, &plan.axis.field) else {
continue;
};
let Some(text) = resolved.as_str() else {
diag.record_skip(SkipReport {
record_index: index,
reason: SkipReason::WrongType {
field: plan.axis.field.clone(),
kind: kind_name(resolved).to_string(),
},
});
continue;
};
let text = text.to_string();
if let Some(group) = groups
.iter_mut()
.find(|group| algorithm.matches(&text, &group.representative))
{
group.records.push(record);
} else {
groups.push(SimilarGroup {
representative: text,
records: vec![record],
});
}
}
groups.sort_by(|a, b| {
b.records
.len()
.cmp(&a.records.len())
.then_with(|| a.representative.cmp(&b.representative))
});
let mut out = Vec::with_capacity(groups.len());
for group in groups {
out.push(build_cluster(plan, parent, algorithm, group, diag)?);
}
Ok(out)
}
struct SimilarGroup {
representative: String,
records: Vec<Record>,
}
fn build_cluster<D: Diagnostics + ?Sized>(
plan: &AxisPlan,
parent: &ClusterId,
algorithm: SimilarAlgorithm,
group: SimilarGroup,
diag: &mut D,
) -> Result<Cluster, FaceError> {
let label = label_for(&group.representative);
let id = extend_id(parent, &plan.axis.field, &label);
let total = group.records.len() as u64;
let (score_min, score_max) = score_range(&group.records);
let children = if plan.within.is_empty() {
Vec::new()
} else {
let mut acc = Vec::new();
for child_plan in &plan.within {
let child_clusters = build_tree_impl(child_plan, group.records.clone(), &id, diag)?;
acc.extend(child_clusters);
}
acc
};
Ok(Cluster {
id,
label,
axis: plan.axis.field.clone(),
value: json!({
"algorithm": algorithm.name(),
"representative": group.representative,
}),
total,
score_min,
score_max,
clusters: children,
})
}
fn label_for(text: &str) -> String {
const MAX_CHARS: usize = 64;
let trimmed = text.trim();
let mut label = trimmed.chars().take(MAX_CHARS).collect::<String>();
if trimmed.chars().count() > MAX_CHARS {
label.push_str("...");
}
if label.is_empty() {
"(empty)".to_string()
} else {
label
}
}
fn kind_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}