face_core/cluster_tree.rs
1//! Execution coordinator: walks an [`AxisPlan`] and dispatches to the
2//! per-strategy clusterers, recursing on `within` for two-axis (and
3//! deeper) nesting (§5.5).
4//!
5//! Dispatch covers the categorical strategies [`Strategy::Exact`],
6//! [`Strategy::Prefix`], [`Strategy::Top`], the numeric strategies
7//! [`Strategy::Bands`], [`Strategy::Quantiles`], [`Strategy::Natural`],
8//! and explicit algorithmic string clustering [`Strategy::Similar`].
9//! The catchall [`FaceError::Unsupported`] arm remains as a
10//! `#[non_exhaustive]` safety net for variants added later before their
11//! dispatch arms land.
12//!
13//! # Memory model
14//!
15//! `build_tree` materializes the full record vector and clusters
16//! synchronously. Streaming-eligible strategies (Exact, Prefix, Top,
17//! Bands-with-fixed-thresholds per §12) could be lifted to a streaming
18//! API later; the current API stays simple and buffered.
19
20use crate::{Axis, Cluster, ClusterId, ClusterIdSegment, Diagnostics, FaceError, Record, Strategy};
21
22/// One node in the user's grouping plan: an axis to cluster on, plus
23/// a (possibly empty) list of sub-plans describing how to further
24/// cluster the records that fall into each top-level cluster.
25///
26/// `--by FIELD,FIELD,...` builds a degenerate chain (each axis is the
27/// only child of its parent). `--within` adds explicit children. The
28/// runtime walks `within` recursively per cluster.
29///
30/// `AxisPlan` is `#[non_exhaustive]` — construct values via
31/// [`AxisPlan::leaf`], [`AxisPlan::with`], or [`AxisPlan::with_many`].
32#[derive(Debug, Clone)]
33#[non_exhaustive]
34pub struct AxisPlan {
35 /// Axis to cluster on at this level.
36 pub axis: Axis,
37 /// Sub-plans applied to each cluster's record subset. Empty for
38 /// leaf levels.
39 pub within: Vec<AxisPlan>,
40}
41
42impl AxisPlan {
43 /// A leaf plan with no sub-axes.
44 ///
45 /// # Examples
46 ///
47 /// ```
48 /// use face_core::{Axis, AxisPlan};
49 ///
50 /// // `Axis` is `#[non_exhaustive]`; build it by deserializing the
51 /// // wire form (the same shape `result.axes[]` carries in §7).
52 /// let axis: Axis = serde_json::from_value(serde_json::json!({
53 /// "field": "kind",
54 /// "strategy": "exact",
55 /// "auto": false,
56 /// })).unwrap();
57 /// let plan = AxisPlan::leaf(axis);
58 /// assert!(plan.within.is_empty());
59 /// ```
60 pub fn leaf(axis: Axis) -> Self {
61 Self {
62 axis,
63 within: Vec::new(),
64 }
65 }
66
67 /// A plan with one sub-axis.
68 pub fn with(axis: Axis, within: AxisPlan) -> Self {
69 Self {
70 axis,
71 within: vec![within],
72 }
73 }
74
75 /// A plan with arbitrary children.
76 pub fn with_many(axis: Axis, within: Vec<AxisPlan>) -> Self {
77 Self { axis, within }
78 }
79}
80
81/// Cluster `items` according to `plan`, dispatching per-axis to the
82/// appropriate strategy and recursing on `within`.
83///
84/// All §5.2, §5.3, and explicit §5.4 strategies are implemented:
85/// [`Strategy::Exact`], [`Strategy::Prefix`], [`Strategy::Top`],
86/// [`Strategy::Bands`], [`Strategy::Quantiles`],
87/// [`Strategy::Natural`], [`Strategy::Similar`]. Future variants added to the
88/// `#[non_exhaustive]` enum surface as [`FaceError::Unsupported`] until
89/// their dispatch arms land.
90///
91/// # Errors
92///
93/// Returns [`FaceError::Unsupported`] only when the plan references a
94/// `Strategy` variant added after this build (the `#[non_exhaustive]`
95/// catchall).
96///
97/// # Examples
98///
99/// ```
100/// use face_core::{Axis, AxisPlan, NullDiagnostics, Record, build_tree};
101/// use serde_json::json;
102///
103/// let axis: Axis = serde_json::from_value(json!({
104/// "field": "kind",
105/// "strategy": "exact",
106/// "auto": false,
107/// })).unwrap();
108/// let plan = AxisPlan::leaf(axis);
109///
110/// let items = vec![json!({"kind": "a"}), json!({"kind": "b"}), json!({"kind": "a"})];
111/// let records = Record::from_items(items, None);
112///
113/// let mut diag = NullDiagnostics;
114/// let clusters = build_tree(&plan, records, &mut diag).unwrap();
115///
116/// assert_eq!(clusters.len(), 2);
117/// // Sorted by count desc, label asc → "a" (2) before "b" (1).
118/// assert_eq!(clusters[0].label, "a");
119/// assert_eq!(clusters[0].total, 2);
120/// assert_eq!(clusters[1].label, "b");
121/// assert_eq!(clusters[1].total, 1);
122/// ```
123pub fn build_tree<D: Diagnostics + ?Sized>(
124 plan: &AxisPlan,
125 items: Vec<Record>,
126 diag: &mut D,
127) -> Result<Vec<Cluster>, FaceError> {
128 build_tree_impl(plan, items, &ClusterId::new(Vec::new()), diag)
129}
130
131/// Internal recursion entry point. Crate-visible so `exact::cluster`
132/// can recurse into `plan.within` with the cluster's id as the new
133/// parent.
134pub(crate) fn build_tree_impl<D: Diagnostics + ?Sized>(
135 plan: &AxisPlan,
136 items: Vec<Record>,
137 parent: &ClusterId,
138 diag: &mut D,
139) -> Result<Vec<Cluster>, FaceError> {
140 match &plan.axis.strategy {
141 Strategy::Exact => exact::cluster(plan, items, parent, diag),
142 Strategy::Prefix { .. } => prefix::cluster(plan, items, parent, diag),
143 Strategy::Top { .. } => top::cluster(plan, items, parent, diag),
144 Strategy::Bands { .. } => bands::cluster(plan, items, parent, diag),
145 Strategy::Quantiles { .. } => quantiles::cluster(plan, items, parent, diag),
146 Strategy::Natural { .. } => natural::cluster(plan, items, parent, diag),
147 Strategy::Similar { .. } => similar::cluster(plan, items, parent, diag),
148 // Catchall for future `#[non_exhaustive]` variants added
149 // before their dispatch arm lands. Today every named variant
150 // above has an implementation, so the compiler sees this as
151 // unreachable from inside the crate; the arm exists so adding
152 // a new `Strategy` variant in a follow-up slice cannot crash
153 // the build before the new arm lands.
154 #[expect(
155 unreachable_patterns,
156 reason = "guard for `#[non_exhaustive]` variants added in future slices"
157 )]
158 other => Err(FaceError::Unsupported {
159 feature: format!("strategy `{}` unsupported by this build", other.name()),
160 }),
161 }
162}
163
164/// Compose a child [`ClusterId`] by extending a parent with one new
165/// `axis:value` segment.
166pub(crate) fn extend_id(parent: &ClusterId, axis: &str, value: &str) -> ClusterId {
167 let mut segs = parent.segments().to_vec();
168 segs.push(ClusterIdSegment {
169 axis: axis.to_string(),
170 value: value.to_string(),
171 });
172 ClusterId::new(segs)
173}
174
175mod bands;
176mod exact;
177mod natural;
178mod prefix;
179mod quantiles;
180mod similar;
181mod top;
182mod util;
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use serde_json::json;
188
189 use crate::{NullDiagnostics, VecDiagnostics};
190
191 fn axis(field: &str, strategy: Strategy) -> Axis {
192 Axis {
193 field: field.into(),
194 strategy,
195 auto: false,
196 }
197 }
198
199 fn records(items: Vec<serde_json::Value>) -> Vec<Record> {
200 Record::from_items(items, None)
201 }
202
203 #[test]
204 fn exact_single_axis_groups_by_value() {
205 let plan = AxisPlan::leaf(axis("kind", Strategy::Exact));
206 let items = vec![
207 json!({"kind": "alpha"}),
208 json!({"kind": "beta"}),
209 json!({"kind": "alpha"}),
210 json!({"kind": "alpha"}),
211 json!({"kind": "beta"}),
212 ];
213 let mut diag = NullDiagnostics;
214 let clusters = build_tree(&plan, records(items), &mut diag).unwrap();
215 assert_eq!(clusters.len(), 2);
216 // count desc, label asc.
217 assert_eq!(clusters[0].label, "alpha");
218 assert_eq!(clusters[0].total, 3);
219 assert_eq!(clusters[1].label, "beta");
220 assert_eq!(clusters[1].total, 2);
221 // Each leaf has empty children.
222 assert!(clusters[0].clusters.is_empty());
223 assert!(clusters[1].clusters.is_empty());
224 }
225
226 #[test]
227 fn exact_two_axis_via_within() {
228 let plan = AxisPlan::with(
229 axis("repo", Strategy::Exact),
230 AxisPlan::leaf(axis("kind", Strategy::Exact)),
231 );
232 let items = vec![
233 json!({"repo": "r1", "kind": "a"}),
234 json!({"repo": "r1", "kind": "a"}),
235 json!({"repo": "r1", "kind": "b"}),
236 json!({"repo": "r2", "kind": "a"}),
237 ];
238 let mut diag = VecDiagnostics::default();
239 let clusters = build_tree(&plan, records(items), &mut diag).unwrap();
240 assert_eq!(clusters.len(), 2);
241
242 // r1 has 3, r2 has 1. r1 comes first (count desc).
243 assert_eq!(clusters[0].label, "r1");
244 assert_eq!(clusters[0].total, 3);
245 assert_eq!(clusters[0].clusters.len(), 2);
246 // Inside r1: a (2) then b (1).
247 assert_eq!(clusters[0].clusters[0].label, "a");
248 assert_eq!(clusters[0].clusters[0].total, 2);
249 assert_eq!(clusters[0].clusters[1].label, "b");
250 assert_eq!(clusters[0].clusters[1].total, 1);
251
252 // r2 has just one child.
253 assert_eq!(clusters[1].label, "r2");
254 assert_eq!(clusters[1].total, 1);
255 assert_eq!(clusters[1].clusters.len(), 1);
256 assert_eq!(clusters[1].clusters[0].label, "a");
257
258 // Child id chains parent.
259 let child_id = &clusters[0].clusters[0].id;
260 assert_eq!(child_id.depth(), 2);
261 assert_eq!(child_id.segments()[0].axis, "repo");
262 assert_eq!(child_id.segments()[0].value, "r1");
263 assert_eq!(child_id.segments()[1].axis, "kind");
264 assert_eq!(child_id.segments()[1].value, "a");
265 }
266
267 #[test]
268 fn bands_dispatches_to_numeric_clusterer() {
269 // After slice 7, all six strategies dispatch successfully.
270 // This test just confirms the dispatch wiring; the per-strategy
271 // semantics live in the strategy modules' own test sections.
272 let plan = AxisPlan::leaf(axis("score", Strategy::Bands { count: 5 }));
273 let items = vec![json!({"score": 0.9})];
274 let mut diag = NullDiagnostics;
275 let clusters = build_tree(&plan, records(items), &mut diag).unwrap();
276 // Single record → single cluster spanning [0.9, 0.9].
277 assert_eq!(clusters.len(), 1);
278 assert_eq!(clusters[0].total, 1);
279 }
280
281 #[test]
282 fn exact_within_bands_recurses_into_numeric_axis() {
283 // Top-level Exact, inner Bands. After slice 7 this composes
284 // cleanly rather than tripping on recursion.
285 let plan = AxisPlan::with(
286 axis("kind", Strategy::Exact),
287 AxisPlan::leaf(axis("score", Strategy::Bands { count: 5 })),
288 );
289 let items = vec![json!({"kind": "a", "score": 0.9})];
290 let mut diag = NullDiagnostics;
291 let clusters = build_tree(&plan, records(items), &mut diag).unwrap();
292 assert_eq!(clusters.len(), 1);
293 assert_eq!(clusters[0].label, "a");
294 // Inner Bands cluster on the single-record `a` group.
295 assert_eq!(clusters[0].clusters.len(), 1);
296 assert_eq!(clusters[0].clusters[0].total, 1);
297 }
298}