face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! §5.3 Exact clustering: one cluster per distinct value at the axis path.
//!
//! - Resolves the axis field on each record via [`crate::path::resolve`].
//! - Records whose path doesn't resolve are dropped silently — the
//!   field is absent and a missing optional axis field is not, by
//!   itself, evidence of a corrupted record (§5.1 only routes
//!   string-and-enum-numeric fields to `exact` in the first place).
//! - Records whose path resolves to a value that cannot be coerced
//!   into an exact key (arrays, objects, fractional numbers,
//!   non-finite) are dropped **and** a [`SkipReport`] is emitted via
//!   the [`Diagnostics`] sink. The value was present but unusable —
//!   that is exactly the §11.1 per-record skip contract.
//! - Groups by the string representation of the resolved value.
//! - Sorts clusters by descending count; ties broken alphabetically by
//!   label. Deterministic ordering matters for golden tests.
//! - Recurses into `plan.within` for each cluster's record subset.

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},
    },
};

/// Cluster `items` by exact value at `plan.axis.field`, recursing per
/// cluster into `plan.within`.
pub(super) fn cluster<D: Diagnostics + ?Sized>(
    plan: &AxisPlan,
    items: Vec<Record>,
    parent: &ClusterId,
    diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
    // Group by exact-key. Missing-field records drop silently;
    // present-but-uncoercible records drop AND emit a SkipReport
    // (see module-level note).
    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 {
            // Path absent — silent drop.
            continue;
        };
        let Some(key) = exact_key(resolved) else {
            // Value present but uncoercible (array, object, fractional
            // number, etc.) — drop and report with the value's kind.
            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);
    }

    // Stable sort: count desc, then label asc. BTreeMap iteration is
    // already alphabetical, so a stable sort by count desc preserves
    // alphabetical order for ties.
    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 {
            // For each child plan, run over the same record subset and
            // concatenate. This matches `--within` being a sibling-axis
            // chain when multiple are supplied at the same level.
            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() {
        // Two singletons (zed, alpha) and a triple (mid). After
        // count-desc sort, mid (3) leads. The two singletons must come
        // out alphabetically by label (alpha then zed) since BTreeMap
        // gives us alphabetical iteration and the sort is stable.
        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}), // no `kind` field — silent drop
            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]}), // array — drop + skip
            json!({"k": 0.5}),    // fractional — drop + skip
            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"
        );
        // record_index reflects position in the input vector.
        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"));
    }
}