use std::{cmp::Reverse, collections::BTreeMap};
use serde_json::Value;
use crate::{
Cluster, ClusterId, Diagnostics, FaceError, Record, SkipReason, SkipReport,
cluster_tree::{
AxisPlan, build_tree_impl, extend_id,
util::{exact_key, json_kind, score_range},
},
};
pub(super) fn cluster<D: Diagnostics + ?Sized>(
plan: &AxisPlan,
items: Vec<Record>,
parent: &ClusterId,
diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
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 mut out = Vec::with_capacity(ordered.len());
for (label, group) in ordered {
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 = if plan.within.is_empty() {
Vec::new()
} else {
let mut acc: Vec<Cluster> = Vec::new();
for child_plan in &plan.within {
let child_clusters = build_tree_impl(child_plan, group.clone(), &id, diag)?;
acc.extend(child_clusters);
}
acc
};
out.push(Cluster {
id,
label: label.clone(),
axis: plan.axis.field.clone(),
value: Value::String(label),
total,
score_min,
score_max,
clusters: children,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Axis, NullDiagnostics, Strategy};
use serde_json::json;
fn axis(field: &str) -> Axis {
Axis {
field: field.into(),
strategy: Strategy::Exact,
auto: false,
}
}
fn records_with_scores(pairs: Vec<(serde_json::Value, Option<f64>)>) -> Vec<Record> {
pairs
.into_iter()
.map(|(raw, score)| Record { raw, score })
.collect()
}
#[test]
fn sort_by_count_desc_label_asc() {
let plan = AxisPlan::leaf(axis("k"));
let items = vec![
json!({"k": "zed"}),
json!({"k": "mid"}),
json!({"k": "alpha"}),
json!({"k": "mid"}),
json!({"k": "mid"}),
];
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, "mid");
assert_eq!(clusters[0].total, 3);
assert_eq!(clusters[1].label, "alpha");
assert_eq!(clusters[2].label, "zed");
}
#[test]
fn missing_path_drops_silently() {
let plan = AxisPlan::leaf(axis("kind"));
let items = vec![
json!({"kind": "x"}),
json!({"other": 1}), json!({"kind": "x"}),
];
let mut diag = crate::VecDiagnostics::default();
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, "x");
assert_eq!(clusters[0].total, 2);
assert!(
diag.skips.is_empty(),
"missing-path drops must be silent, got {} skips",
diag.skips.len(),
);
}
#[test]
fn uncoercible_values_drop_and_emit_skip() {
let plan = AxisPlan::leaf(axis("k"));
let items = vec![
json!({"k": "ok"}),
json!({"k": [1, 2]}), json!({"k": 0.5}), json!({"k": "ok"}),
];
let mut diag = crate::VecDiagnostics::default();
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].total, 2);
assert_eq!(
diag.skips.len(),
2,
"expected one skip per uncoercible value"
);
assert_eq!(diag.skips[0].record_index, 1);
assert_eq!(diag.skips[1].record_index, 2);
}
#[test]
fn integer_enum_numeric_keys_serialize_as_strings() {
let plan = AxisPlan::leaf(axis("status"));
let items = vec![
json!({"status": 200}),
json!({"status": 200}),
json!({"status": 404}),
];
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, "200");
assert_eq!(clusters[0].total, 2);
assert_eq!(clusters[1].label, "404");
}
#[test]
fn null_value_keeps_a_null_label() {
let plan = AxisPlan::leaf(axis("k"));
let items = vec![json!({"k": null}), json!({"k": "x"})];
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 labels: Vec<&str> = clusters.iter().map(|c| c.label.as_str()).collect();
assert!(labels.contains(&"null"));
assert!(labels.contains(&"x"));
}
#[test]
fn score_min_max_are_aggregated_across_group() {
let plan = AxisPlan::leaf(axis("k"));
let items = records_with_scores(vec![
(json!({"k": "a"}), Some(0.4)),
(json!({"k": "a"}), Some(0.9)),
(json!({"k": "a"}), Some(0.7)),
(json!({"k": "b"}), None),
]);
let mut diag = NullDiagnostics;
let clusters = super::cluster(&plan, items, &ClusterId::default(), &mut diag).unwrap();
let a = clusters.iter().find(|c| c.label == "a").unwrap();
assert_eq!(a.score_min, Some(0.4));
assert_eq!(a.score_max, Some(0.9));
let b = clusters.iter().find(|c| c.label == "b").unwrap();
assert_eq!(b.score_min, None);
assert_eq!(b.score_max, None);
}
#[test]
fn cluster_value_is_string_form_of_label() {
let plan = AxisPlan::leaf(axis("status"));
let items = vec![json!({"status": 200})];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters[0].value, json!("200"));
}
}