Skip to main content

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}