face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `Strategy::Quantiles` via `build_tree` (slice 7,
//! §5.2 of `docs/design.md`).
//!
//! `Quantiles { count }` partitions records by the numeric value at the
//! axis field into equal-population buckets. Each bucket becomes a
//! cluster; empty buckets are dropped (rare unless heavy duplication).
//!
//! Behavior pinned by these tests:
//!
//! - With `n` evenly-spaced records and `count = q`, each cluster's
//!   total is in `{n / q, n / q + 1}` (we don't pin the exact split,
//!   just that the spread is one record).
//! - Sum of cluster totals equals the number of records with a numeric
//!   axis value.
//! - Sort: descending by lower bound (same rule as `Bands`).
//! - Heavy duplication can collapse buckets: total preserved, ≤ count
//!   distinct clusters.
//! - Empty input → empty cluster vec.
//! - Single record → 1 cluster.
//! - Recursion: `Quantiles` outer with `Exact` inner produces a
//!   two-tier tree.
//!
//! `value` shape and label format mirror `Bands` (verified there);
//! these tests focus on the equal-population-bucketing semantics.

use face_core::{Axis, AxisPlan, Cluster, NullDiagnostics, Record, build_tree};
use serde_json::{Value, json};

/// Build an `Axis` via deserialization (the type is `#[non_exhaustive]`).
fn axis(field: &str, strategy: Value) -> Axis {
    let mut object = serde_json::Map::new();
    object.insert("field".into(), Value::String(field.into()));
    object.insert("auto".into(), Value::Bool(false));
    if let Value::Object(map) = strategy {
        for (k, v) in map {
            object.insert(k, v);
        }
    } else {
        panic!("strategy must be a JSON object");
    }
    serde_json::from_value(Value::Object(object)).expect("axis must deserialize")
}

fn quantiles(field: &str, count: u8) -> Axis {
    axis(field, json!({"strategy": "quantiles", "count": count}))
}

fn exact(field: &str) -> Axis {
    axis(field, json!({"strategy": "exact"}))
}

fn run(plan: AxisPlan, records: Vec<Record>) -> Vec<Cluster> {
    let mut diag = NullDiagnostics;
    build_tree(&plan, records, &mut diag).expect("Quantiles build_tree should succeed")
}

fn records_from(items: Vec<Value>, score_path: Option<&str>) -> Vec<Record> {
    Record::from_items(items, score_path)
}

/// Pull a numeric `min` field out of a cluster's `value`.
fn value_min(cluster: &Cluster) -> f64 {
    cluster.value["min"].as_f64().unwrap_or_else(|| {
        panic!("cluster.value[\"min\"] should be a JSON number; got {cluster:?}")
    })
}

mod basic {
    //! Acceptance: §5.2 — equal-population buckets.

    use super::*;

    /// Standard 10-record dataset: `[1.0, 2.0, ..., 10.0]`.
    fn dataset() -> Vec<Record> {
        let items: Vec<Value> = (1..=10).map(|i| json!({"score": i as f64})).collect();
        records_from(items, None)
    }

    #[test]
    fn equal_population_partitioning() {
        // 10 records, count=4 → ideal 2.5 per bucket. With ties broken
        // either way, each cluster's total should be 2 or 3, and the
        // sum should be 10.
        let plan = AxisPlan::leaf(quantiles("score", 4));
        let clusters = run(plan, dataset());

        assert_eq!(
            clusters.len(),
            4,
            "10 distinct records into 4 quantiles → 4 clusters; got {} clusters: {:?}",
            clusters.len(),
            clusters.iter().map(|c| c.label.clone()).collect::<Vec<_>>(),
        );

        let totals: Vec<u64> = clusters.iter().map(|c| c.total).collect();
        let sum: u64 = totals.iter().sum();
        assert_eq!(sum, 10, "totals must sum to 10; got {sum} from {totals:?}");

        for total in &totals {
            assert!(
                *total == 2 || *total == 3,
                "each cluster total must be 2 or 3 for 10/4 split; got {total} in {totals:?}",
            );
        }
    }

    #[test]
    fn clusters_sorted_by_lower_bound_descending() {
        let plan = AxisPlan::leaf(quantiles("score", 4));
        let clusters = run(plan, dataset());

        let lowers: Vec<f64> = clusters.iter().map(value_min).collect();
        let mut sorted = lowers.clone();
        sorted.sort_by(|a, b| b.total_cmp(a));
        assert_eq!(
            lowers, sorted,
            "clusters must be sorted by lower bound descending; got {lowers:?}",
        );
    }
}

mod duplicates {
    //! Heavy duplication: identical values can collapse equal-population
    //! buckets so that fewer than `count` clusters survive (empty
    //! buckets dropped). The total must still be preserved.
    //!
    //! Acceptance: §5.2 — empty buckets dropped (rare unless heavy
    //! duplication).

    use super::*;

    #[test]
    fn handles_heavy_duplication() {
        // Six records, only two distinct values (1.0 ×4, 5.0 ×2).
        // Asking for 4 quantiles can't usefully split — we expect at
        // most 2 emitted clusters (one per distinct value), with the
        // total preserved.
        let items = vec![
            json!({"score": 1.0}),
            json!({"score": 1.0}),
            json!({"score": 1.0}),
            json!({"score": 1.0}),
            json!({"score": 5.0}),
            json!({"score": 5.0}),
        ];
        let plan = AxisPlan::leaf(quantiles("score", 4));
        let clusters = run(plan, records_from(items, None));

        assert!(
            !clusters.is_empty() && clusters.len() <= 2,
            "heavy duplication must collapse to ≤ 2 clusters (2 distinct values); got {} clusters: {:?}",
            clusters.len(),
            clusters.iter().map(|c| c.label.clone()).collect::<Vec<_>>(),
        );

        let sum: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(
            sum, 6,
            "total must be preserved across duplication; got {sum}"
        );
    }
}

mod edges {
    //! Single record and empty input.
    //!
    //! Acceptance: §5.2 — single record → 1 cluster; empty input →
    //! empty cluster vec.

    use super::*;

    #[test]
    fn single_record() {
        let items = vec![json!({"score": 0.5})];
        let plan = AxisPlan::leaf(quantiles("score", 4));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 1, "single record → exactly one cluster");
        assert_eq!(clusters[0].total, 1);
    }

    #[test]
    fn empty_records() {
        let plan = AxisPlan::leaf(quantiles("score", 4));
        let clusters = run(plan, Vec::new());
        assert!(
            clusters.is_empty(),
            "empty input must produce empty cluster vec; got {} clusters",
            clusters.len(),
        );
    }
}

mod recursion {
    //! `Quantiles` (outer) with `Exact` (inner) produces a two-tier
    //! tree: each quantile bucket carries an `Exact`-clustered child
    //! list within it.
    //!
    //! Acceptance: §5.5 — `--within` chains a second axis; per-axis
    //! strategy dispatch applies recursively.

    use super::*;

    #[test]
    fn quantiles_within_exact() {
        let items = vec![
            json!({"score": 1.0, "kind": "bug"}),
            json!({"score": 2.0, "kind": "feat"}),
            json!({"score": 3.0, "kind": "bug"}),
            json!({"score": 4.0, "kind": "feat"}),
            json!({"score": 5.0, "kind": "feat"}),
            json!({"score": 6.0, "kind": "bug"}),
            json!({"score": 7.0, "kind": "bug"}),
            json!({"score": 8.0, "kind": "feat"}),
        ];
        let outer = quantiles("score", 4);
        let inner = exact("kind");
        let plan = AxisPlan::with(outer, AxisPlan::leaf(inner));

        let clusters = run(plan, records_from(items, None));

        assert!(
            !clusters.is_empty(),
            "outer Quantiles must produce at least one cluster",
        );

        // Sum of top-level totals equals input.
        let outer_total: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(outer_total, 8);

        for cluster in &clusters {
            // Each cluster has at least one inner child (every record
            // carries `kind`).
            assert!(
                !cluster.clusters.is_empty(),
                "each Quantiles cluster must recurse into Exact children; cluster {:?} has none",
                cluster.label,
            );
            let child_total: u64 = cluster.clusters.iter().map(|c| c.total).sum();
            assert_eq!(
                child_total, cluster.total,
                "child totals must add up to parent total in cluster {:?}",
                cluster.label,
            );
            for child in &cluster.clusters {
                assert_eq!(child.axis, "kind");
            }
        }
    }
}