use std::{cmp::Reverse, collections::BTreeMap};
use serde_json::Value;
use crate::{
Cluster, ClusterId, Diagnostics, FaceError, Record, SkipReason, SkipReport, Strategy,
cluster_tree::{
AxisPlan, build_tree_impl, extend_id,
util::{exact_key, json_kind, score_range},
},
};
const OTHER_LABEL: &str = "(other)";
pub(super) fn cluster<D: Diagnostics + ?Sized>(
plan: &AxisPlan,
items: Vec<Record>,
parent: &ClusterId,
diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
let Strategy::Top { n } = &plan.axis.strategy else {
unreachable!("top clusterer is only called for Strategy::Top")
};
let n = *n as usize;
let mut groups: BTreeMap<String, Vec<Record>> = BTreeMap::new();
let field = plan.axis.field.as_str();
for (index, record) in items.into_iter().enumerate() {
let Ok(resolved) = crate::path::resolve(&record.raw, field) else {
continue;
};
let Some(key) = exact_key(resolved) else {
diag.record_skip(SkipReport {
record_index: index,
reason: SkipReason::WrongType {
field: plan.axis.field.clone(),
kind: json_kind(resolved).to_string(),
},
});
continue;
};
groups.entry(key).or_default().push(record);
}
let mut ordered: Vec<(String, Vec<Record>)> = groups.into_iter().collect();
ordered.sort_by_key(|(_, group)| Reverse(group.len()));
let split_at = n.min(ordered.len());
let tail = ordered.split_off(split_at);
let top = ordered;
let mut out = Vec::with_capacity(top.len() + usize::from(!tail.is_empty()));
for (label, group) in top {
let id = extend_id(parent, &plan.axis.field, &label);
let total = group.len() as u64;
let (score_min, score_max) = score_range(&group);
let children = recurse_within(plan, &id, &group, diag)?;
out.push(Cluster {
id,
label: label.clone(),
axis: plan.axis.field.clone(),
value: Value::String(label),
total,
score_min,
score_max,
clusters: children,
});
}
if !tail.is_empty() {
let mut remaining: Vec<Record> = Vec::new();
for (_, group) in tail {
remaining.extend(group);
}
let id = extend_id(parent, &plan.axis.field, OTHER_LABEL);
let total = remaining.len() as u64;
let (score_min, score_max) = score_range(&remaining);
let children = recurse_within(plan, &id, &remaining, diag)?;
out.push(Cluster {
id,
label: OTHER_LABEL.to_string(),
axis: plan.axis.field.clone(),
value: Value::String(OTHER_LABEL.to_string()),
total,
score_min,
score_max,
clusters: children,
});
}
Ok(out)
}
fn recurse_within<D: Diagnostics + ?Sized>(
plan: &AxisPlan,
id: &ClusterId,
group: &[Record],
diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
if plan.within.is_empty() {
return Ok(Vec::new());
}
let mut acc: Vec<Cluster> = Vec::new();
for child_plan in &plan.within {
let child_clusters = build_tree_impl(child_plan, group.to_vec(), id, diag)?;
acc.extend(child_clusters);
}
Ok(acc)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Axis, NullDiagnostics, Strategy};
use serde_json::json;
fn axis(field: &str, n: u32) -> Axis {
Axis {
field: field.into(),
strategy: Strategy::Top { n },
auto: false,
}
}
#[test]
fn keeps_top_n_and_collapses_rest_to_other() {
let plan = AxisPlan::leaf(axis("kind", 2));
let items = vec![
json!({"kind": "a"}),
json!({"kind": "a"}),
json!({"kind": "a"}),
json!({"kind": "b"}),
json!({"kind": "b"}),
json!({"kind": "c"}),
json!({"kind": "d"}),
];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters.len(), 3);
assert_eq!(clusters[0].label, "a");
assert_eq!(clusters[0].total, 3);
assert_eq!(clusters[1].label, "b");
assert_eq!(clusters[1].total, 2);
assert_eq!(clusters[2].label, "(other)");
assert_eq!(clusters[2].total, 2);
assert_eq!(clusters[2].value, json!("(other)"));
}
#[test]
fn no_other_when_distinct_values_le_n() {
let plan = AxisPlan::leaf(axis("kind", 5));
let items = vec![
json!({"kind": "a"}),
json!({"kind": "b"}),
json!({"kind": "a"}),
];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters.len(), 2);
assert_eq!(clusters[0].label, "a");
assert_eq!(clusters[1].label, "b");
assert!(
clusters.iter().all(|c| c.label != "(other)"),
"no synthetic cluster expected when distinct values ≤ n",
);
}
#[test]
fn n_zero_collapses_all_to_other() {
let plan = AxisPlan::leaf(axis("kind", 0));
let items = vec![
json!({"kind": "a"}),
json!({"kind": "b"}),
json!({"kind": "a"}),
];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].label, "(other)");
assert_eq!(clusters[0].total, 3);
}
#[test]
fn other_id_extends_parent() {
let plan = AxisPlan::leaf(axis("kind", 1));
let items = vec![
json!({"kind": "a"}),
json!({"kind": "a"}),
json!({"kind": "b"}),
];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
let other = clusters.iter().find(|c| c.label == "(other)").unwrap();
assert_eq!(other.id.depth(), 1);
assert_eq!(other.id.segments()[0].axis, "kind");
assert_eq!(other.id.segments()[0].value, "(other)");
}
#[test]
fn within_recurses_inside_other() {
let outer = axis("kind", 1);
let inner = Axis {
field: "tag".into(),
strategy: Strategy::Exact,
auto: false,
};
let plan = AxisPlan::with(outer, AxisPlan::leaf(inner));
let items = vec![
json!({"kind": "a", "tag": "x"}),
json!({"kind": "a", "tag": "x"}),
json!({"kind": "b", "tag": "y"}),
json!({"kind": "c", "tag": "y"}),
];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters.len(), 2);
let other = clusters.iter().find(|c| c.label == "(other)").unwrap();
assert_eq!(other.total, 2);
assert_eq!(other.clusters.len(), 1);
assert_eq!(other.clusters[0].label, "y");
assert_eq!(other.clusters[0].total, 2);
}
}