face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! §5.2 Bands clustering: equal-width bands over the min–max of the
//! axis field's numeric value.
//!
//! - Resolves the axis field on each record via [`crate::path::resolve`].
//! - Records whose path doesn't resolve drop silently (consistent with
//!   Exact's missing-path policy).
//! - Records whose path resolves to a non-numeric value (string,
//!   array, object, boolean, null, non-finite number) drop and emit a
//!   [`SkipReport`] via the [`Diagnostics`] sink.
//! - With zero valid records, returns an empty cluster vec.
//! - With one valid record, returns a single cluster spanning the
//!   degenerate range `[v, v]`.
//! - Otherwise divides `[min, max]` into `count` equal-width bands.
//!   Bands are half-open `[lo, hi)` except the last, which is closed
//!   `[lo, hi]` so the maximum value lands in the last band rather
//!   than spilling out.
//! - Empty bands are dropped (we don't emit zero-total clusters).
//! - Sort: numeric descending by band lower bound so highest-scoring
//!   ranges appear first. We do **not** apply the categorical "count
//!   desc" sort here; numeric ranges carry their own order.
//! - Cluster `value` is `{"min": lo, "max": hi}` carrying the band's
//!   nominal bounds (not the actual record min/max in the band — that
//!   information is recoverable from `score_min`/`score_max`).
//! - Label format: en-dash separated `lo–hi`. If every band bound is
//!   an integer-valued `f64`, render without decimals (`0–10`);
//!   otherwise render with two-decimal precision (`0.85–1.00`).

use serde_json::json;

use crate::{
    Cluster, ClusterId, Diagnostics, FaceError, Record, Strategy,
    cluster_tree::{
        AxisPlan, build_tree_impl, extend_id,
        util::{resolve_numeric, score_range},
    },
};

/// Cluster `items` by equal-width band on the resolved numeric 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> {
    let Strategy::Bands { count } = &plan.axis.strategy else {
        unreachable!("bands clusterer is only called for Strategy::Bands")
    };
    let count = (*count).max(1) as usize;

    let resolved = resolve_numeric(items, &plan.axis.field, diag);

    if resolved.is_empty() {
        return Ok(Vec::new());
    }

    let (min, max) = min_max(&resolved);

    // Single-cluster degenerate case: zero range, or `count == 1`, or
    // exactly one valid record. Emit one cluster spanning [min, max].
    if (max - min).abs() == 0.0 || count == 1 || resolved.len() == 1 {
        let label = format_label(min, max, &[min, max]);
        return Ok(vec![build_cluster(
            plan,
            parent,
            label,
            min,
            max,
            resolved.into_iter().map(|(_, r)| r).collect(),
            diag,
        )?]);
    }

    // Equal-width bands. Compute boundaries up front so the label
    // formatter can decide on integer-vs-float rendering once.
    let width = (max - min) / count as f64;
    let mut bounds: Vec<f64> = Vec::with_capacity(count + 1);
    for i in 0..count {
        bounds.push(min + width * i as f64);
    }
    bounds.push(max);

    // Bucket each record into a band. Half-open `[lo, hi)` except the
    // last band, which is closed `[lo, hi]`.
    let mut buckets: Vec<Vec<Record>> = (0..count).map(|_| Vec::new()).collect();
    for (value, record) in resolved {
        let band = band_index(value, &bounds, count);
        buckets[band].push(record);
    }

    let mut out = Vec::with_capacity(count);
    for (i, group) in buckets.into_iter().enumerate() {
        if group.is_empty() {
            continue;
        }
        let lo = bounds[i];
        let hi = bounds[i + 1];
        let label = format_label(lo, hi, &bounds);
        out.push(build_cluster(plan, parent, label, lo, hi, group, diag)?);
    }
    out.reverse();

    Ok(out)
}

/// Min and max of a non-empty `(f64, Record)` slice. Caller guarantees
/// non-empty.
fn min_max(values: &[(f64, Record)]) -> (f64, f64) {
    let mut min = values[0].0;
    let mut max = values[0].0;
    for (v, _) in &values[1..] {
        if *v < min {
            min = *v;
        }
        if *v > max {
            max = *v;
        }
    }
    (min, max)
}

/// Choose the band index for `value` against `bounds` of length
/// `count + 1`. Bands `0..count-1` are half-open `[lo, hi)`; band
/// `count - 1` is closed `[lo, hi]` so the maximum lands inside.
fn band_index(value: f64, bounds: &[f64], count: usize) -> usize {
    debug_assert_eq!(bounds.len(), count + 1);
    for i in 0..count - 1 {
        if value < bounds[i + 1] {
            return i;
        }
    }
    count - 1
}

/// Build one cluster from a (label, bounds, records) triple, recursing
/// into `plan.within` if any.
fn build_cluster<D: Diagnostics + ?Sized>(
    plan: &AxisPlan,
    parent: &ClusterId,
    label: String,
    lo: f64,
    hi: f64,
    group: Vec<Record>,
    diag: &mut D,
) -> Result<Cluster, FaceError> {
    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
    };

    Ok(Cluster {
        id,
        label,
        axis: plan.axis.field.clone(),
        value: json!({"min": lo, "max": hi}),
        total,
        score_min,
        score_max,
        clusters: children,
    })
}

/// Render `lo–hi` with the en-dash (U+2013). If every value in
/// `bounds` is integer-valued in floating point, render without
/// decimals; otherwise use two-decimal precision.
fn format_label(lo: f64, hi: f64, bounds: &[f64]) -> String {
    if bounds.iter().all(|b| b.fract() == 0.0) {
        format!("{}\u{2013}{}", lo as i64, hi as i64)
    } else {
        format!("{lo:.2}\u{2013}{hi:.2}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Axis, NullDiagnostics, Strategy, VecDiagnostics};
    use serde_json::json;

    fn axis(field: &str, count: u8) -> Axis {
        Axis {
            field: field.into(),
            strategy: Strategy::Bands { count },
            auto: false,
        }
    }

    #[test]
    fn partitions_into_equal_width_bands() {
        // Range [0, 10] divided into 5 bands of width 2:
        //   [0,2), [2,4), [4,6), [6,8), [8,10].
        let plan = AxisPlan::leaf(axis("v", 5));
        let items = vec![
            json!({"v": 0.0}),
            json!({"v": 1.5}),
            json!({"v": 3.0}),
            json!({"v": 7.0}),
            json!({"v": 10.0}),
        ];
        let mut diag = NullDiagnostics;
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        // Bands [0,2), [2,4), [6,8), [8,10] are populated; [4,6) is empty.
        assert_eq!(clusters.len(), 4);
        // Numeric descending order.
        assert_eq!(clusters[0].total, 1); // [8,10]: 10.0
        assert_eq!(clusters[1].total, 1); // [6,8): 7.0
        assert_eq!(clusters[2].total, 1); // [2,4): 3.0
        assert_eq!(clusters[3].total, 2); // [0,2): 0.0, 1.5
        // Labels are integer-rendered because bounds are whole numbers.
        assert_eq!(clusters[0].label, "8\u{2013}10");
        assert_eq!(clusters[3].label, "0\u{2013}2");
    }

    #[test]
    fn single_record_one_cluster() {
        let plan = AxisPlan::leaf(axis("v", 5));
        let items = vec![json!({"v": 0.42})];
        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].total, 1);
        // min == max, so the value object's bounds collapse.
        assert_eq!(clusters[0].value, json!({"min": 0.42, "max": 0.42}));
    }

    #[test]
    fn empty_records_returns_empty() {
        let plan = AxisPlan::leaf(axis("v", 5));
        let items: Vec<serde_json::Value> = vec![json!({"other": 1})]; // missing path → silent drop
        let mut diag = VecDiagnostics::default();
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        assert!(clusters.is_empty());
        assert!(
            diag.skips.is_empty(),
            "missing-path drops must be silent, got {} skips",
            diag.skips.len()
        );
    }

    #[test]
    fn label_uses_en_dash() {
        // Fractional bounds → two-decimal precision and en-dash.
        let plan = AxisPlan::leaf(axis("v", 4));
        let items = vec![
            json!({"v": 0.0}),
            json!({"v": 0.25}),
            json!({"v": 0.5}),
            json!({"v": 0.75}),
            json!({"v": 1.0}),
        ];
        let mut diag = NullDiagnostics;
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        // 4 bands of width 0.25 over [0, 1]; all populated.
        assert_eq!(clusters.len(), 4);
        // Labels should contain the en-dash and two-decimal formatting
        // because bounds are fractional (0.25, 0.50, ...), ordered
        // high-to-low.
        assert_eq!(clusters[0].label, "0.75\u{2013}1.00");
        assert_eq!(clusters[3].label, "0.00\u{2013}0.25");
    }

    #[test]
    fn non_numeric_value_emits_skip() {
        let plan = AxisPlan::leaf(axis("v", 3));
        let items = vec![
            json!({"v": 0.0}),
            json!({"v": "high"}), // present but non-numeric → skip
            json!({"v": 1.0}),
        ];
        let mut diag = VecDiagnostics::default();
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        // Two valid records → at least one cluster.
        assert_eq!(diag.skips.len(), 1);
        assert_eq!(diag.skips[0].record_index, 1);
        let total: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(total, 2);
    }
}