face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for preset auto-detection (§4.4 of `docs/design.md`).
//!
//! Pins the preset cascade (`Confidence` → `ConceptSearch` → `Severity`
//! → `Bm25` → `None`), `Preset::name` / `Preset::from_name` round-trip
//! and case-insensitivity, and the `sample_scores` helper that feeds
//! the cascade.
//!
//! Public surface under test (locked by the developer brief):
//!
//! ```ignore
//! pub enum Preset { Confidence, ConceptSearch, Bm25, Severity }  // #[non_exhaustive]
//! impl Preset {
//!     pub fn name(&self) -> &'static str;
//!     pub fn from_name(name: &str) -> Option<Preset>;
//! }
//! pub fn detect_preset(samples: &[f64]) -> Option<Preset>;
//! pub fn sample_scores(items: &[serde_json::Value], score_path: &str, max_samples: usize)
//!     -> Vec<f64>;
//! ```

use face_core::detect::preset::{Preset, detect_preset, sample_scores};
use serde_json::json;

mod confidence {
    //! Cascade tier 1: max ≤ 1.0 AND min ≥ 0.0 → `Confidence`.

    use super::*;

    #[test]
    fn all_in_zero_one_classifies_confidence() {
        let samples = [0.1, 0.5, 0.9, 1.0];
        assert_eq!(detect_preset(&samples), Some(Preset::Confidence));
    }

    #[test]
    fn boundary_zero_and_one() {
        // The interval is closed at both ends.
        let samples = [0.0, 1.0];
        assert_eq!(detect_preset(&samples), Some(Preset::Confidence));
    }

    #[test]
    fn slightly_above_one_is_not_confidence() {
        let samples = [0.5, 1.01];
        assert_ne!(
            detect_preset(&samples),
            Some(Preset::Confidence),
            "1.01 > 1.0 must take this distribution out of the `Confidence` band",
        );
    }

    #[test]
    fn negative_value_excludes_confidence() {
        let samples = [-0.1, 0.5];
        assert_ne!(
            detect_preset(&samples),
            Some(Preset::Confidence),
            "negative values disqualify `Confidence`",
        );
    }
}

mod concept_search {
    //! Cascade tier 2: max within ±10% of 1000.0 AND min ≥ 0.0 →
    //! `ConceptSearch`. Excluded by `Confidence` if the whole sample
    //! sits in `[0, 1]`.

    use super::*;

    #[test]
    fn max_near_thousand_classifies() {
        // Max = 950, well within ±10% of 1000.
        let samples = [10.0, 250.0, 800.0, 950.0];
        assert_eq!(detect_preset(&samples), Some(Preset::ConceptSearch));
    }

    #[test]
    fn max_at_exactly_thousand() {
        let samples = [1.0, 1000.0];
        assert_eq!(detect_preset(&samples), Some(Preset::ConceptSearch));
    }

    #[test]
    fn max_at_1095_within_tolerance() {
        // 1095 is within +10% of 1000 (≤ 1100).
        let samples = [1.0, 1095.0];
        assert_eq!(detect_preset(&samples), Some(Preset::ConceptSearch));
    }

    #[test]
    fn max_at_1101_outside_tolerance() {
        // 1101 is outside ±10% of 1000 (> 1100). It may fall through to
        // `Bm25` (since 1101 ≥ 2× p99 ≈ 1.0) or to `None`. Either is
        // acceptable per the brief — the firm assertion is "not
        // ConceptSearch".
        let samples = [1.0, 1101.0];
        assert_ne!(
            detect_preset(&samples),
            Some(Preset::ConceptSearch),
            "1101.0 is outside the ±10%-of-1000 tolerance",
        );
    }
}

mod severity {
    //! Cascade tier 3: all small non-negative integers (each ≤ 100, no
    //! fractional parts, cardinality ≤ 20) → `Severity`.

    use super::*;

    #[test]
    fn small_integers_classify_severity() {
        let samples = [1.0, 2.0, 1.0, 3.0, 2.0, 1.0];
        assert_eq!(detect_preset(&samples), Some(Preset::Severity));
    }

    #[test]
    fn non_integer_excludes_severity() {
        let samples = [1.0, 2.5, 3.0];
        assert_ne!(
            detect_preset(&samples),
            Some(Preset::Severity),
            "fractional parts disqualify `Severity`",
        );
    }

    #[test]
    fn over_100_excludes_severity() {
        let samples = [50.0, 150.0];
        assert_ne!(
            detect_preset(&samples),
            Some(Preset::Severity),
            "values > 100 disqualify `Severity`",
        );
    }

    #[test]
    fn cardinality_above_20_excludes_severity() {
        // Distinct integers 1..=25 → cardinality 25, exceeds the 20 cap.
        let samples: Vec<f64> = (1..=25).map(|n| n as f64).collect();
        assert_ne!(
            detect_preset(&samples),
            Some(Preset::Severity),
            "cardinality > 20 disqualifies `Severity`",
        );
    }
}

mod bm25 {
    //! Cascade tier 4: long-tail (max ≥ 2× p99) AND min ≥ 0.0 → `Bm25`.

    use super::*;

    #[test]
    fn long_tail_classifies_bm25() {
        // Confidence excluded by max=50 > 1.0. ConceptSearch excluded
        // by max=50 < 900. Severity excluded by 1.2/1.5/2.0 having
        // fractional parts. Long tail (50 ≫ p99 ≈ 2) qualifies Bm25.
        let samples = [1.0, 1.2, 1.5, 2.0, 50.0];
        assert_eq!(detect_preset(&samples), Some(Preset::Bm25));
    }

    #[test]
    fn no_long_tail_excludes_bm25() {
        // Max = 2.5, p99 ≈ 2.5 → not ≥ 2× p99. Not a long-tail
        // distribution. Should fall through to `None` (no other tier
        // matches: Confidence excluded by max > 1, ConceptSearch by
        // max < 900, Severity by fractional parts).
        let samples = [1.0, 1.2, 1.5, 2.0, 2.5];
        assert_ne!(
            detect_preset(&samples),
            Some(Preset::Bm25),
            "max ≈ p99 → no long tail → not `Bm25`",
        );
    }
}

mod priority {
    //! The cascade is order-dependent. These tests pin precedence
    //! between adjacent tiers when the input could plausibly satisfy
    //! more than one.

    use super::*;

    #[test]
    fn concept_search_picked_when_max_is_thousand() {
        // `[0.0, 1000.0]`: not all in [0,1] (1000 > 1), so Confidence
        // is excluded. Max = 1000 is on the nose for ConceptSearch.
        let samples = [0.0, 1000.0];
        assert_eq!(detect_preset(&samples), Some(Preset::ConceptSearch));
    }

    #[test]
    fn severity_when_integers_in_zero_to_three() {
        // `[0.0, 1.0, 2.0, 3.0]`: not all in [0,1] (2,3 > 1), so
        // Confidence is excluded. ConceptSearch needs max near 1000 →
        // excluded. All small non-negative integers, cardinality 4 →
        // Severity wins.
        let samples = [0.0, 1.0, 2.0, 3.0];
        assert_eq!(detect_preset(&samples), Some(Preset::Severity));
    }
}

mod empty_and_edge {
    //! Empty / single-sample / edge-case inputs.

    use super::*;

    #[test]
    fn empty_samples_returns_none() {
        let samples: [f64; 0] = [];
        assert_eq!(detect_preset(&samples), None);
    }

    #[test]
    fn single_value_zero_to_one() {
        // Spec doesn't carve out singletons; a single in-range sample
        // satisfies the Confidence band trivially.
        let samples = [0.5];
        assert_eq!(detect_preset(&samples), Some(Preset::Confidence));
    }
}

mod from_name {
    //! `Preset::name` / `Preset::from_name` contract: kebab-case names,
    //! case-insensitive parse, unknown → `None`, perfect round-trip.

    use super::*;

    #[test]
    fn valid_kebab_case() {
        assert_eq!(Preset::from_name("confidence"), Some(Preset::Confidence));
        assert_eq!(
            Preset::from_name("concept-search"),
            Some(Preset::ConceptSearch),
        );
        assert_eq!(Preset::from_name("bm25"), Some(Preset::Bm25));
        assert_eq!(Preset::from_name("severity"), Some(Preset::Severity));
    }

    #[test]
    fn case_insensitive() {
        assert_eq!(Preset::from_name("BM25"), Some(Preset::Bm25));
        assert_eq!(Preset::from_name("Confidence"), Some(Preset::Confidence));
        assert_eq!(
            Preset::from_name("Concept-Search"),
            Some(Preset::ConceptSearch),
        );
        assert_eq!(Preset::from_name("SEVERITY"), Some(Preset::Severity));
    }

    #[test]
    fn unknown_returns_none() {
        assert_eq!(Preset::from_name("foo"), None);
        assert_eq!(Preset::from_name(""), None);
    }

    #[test]
    fn name_round_trip() {
        // Cover every variant via an explicit list — `Preset` is
        // `#[non_exhaustive]` and we want a compile failure (or at
        // least a known-coverage gap) when a new variant lands. List
        // each variant directly.
        for p in [
            Preset::Confidence,
            Preset::ConceptSearch,
            Preset::Bm25,
            Preset::Severity,
        ] {
            let n = p.name();
            assert_eq!(Preset::from_name(n), Some(p), "round-trip failed for `{n}`",);
        }
    }

    #[test]
    fn name_is_kebab_case() {
        assert_eq!(Preset::Confidence.name(), "confidence");
        assert_eq!(Preset::ConceptSearch.name(), "concept-search");
        assert_eq!(Preset::Bm25.name(), "bm25");
        assert_eq!(Preset::Severity.name(), "severity");
    }
}

mod sample_scores_extraction {
    //! `sample_scores` walks `items`, resolves each item's `score_path`
    //! to a numeric value, and collects up to `max_samples` finite
    //! `f64`s. Missing-path / non-numeric / non-finite entries are
    //! skipped silently.

    use super::*;

    #[test]
    fn extracts_numerics_at_path() {
        let items = vec![
            json!({"score": 0.1}),
            json!({"score": 0.4}),
            json!({"score": 0.9}),
        ];
        let got = sample_scores(&items, ".score", 100);
        assert_eq!(got.len(), 3, "all three numeric scores collected");
        // Order should be preserved; assert exact contents.
        assert_eq!(got, vec![0.1, 0.4, 0.9]);
    }

    #[test]
    fn skips_missing_path_silently() {
        // Two items have `.score`; one is missing entirely. Soft-match:
        // the brief locks "NaN/non-finite skipped silently" but does
        // not pin missing-path behavior. Either skip-silently or
        // skip-with-error are plausible — assert the milder property
        // that the returned vec is bounded by the number of items.
        let items = vec![
            json!({"score": 1.0}),
            json!({"other": "no score here"}),
            json!({"score": 2.0}),
        ];
        let got = sample_scores(&items, ".score", 100);
        assert!(
            got.len() <= items.len(),
            "returned vec must not exceed item count; got {} for {} items",
            got.len(),
            items.len(),
        );
        // The two valid scores must appear regardless of whether the
        // missing-path item was skipped or treated some other way.
        assert!(got.contains(&1.0), "valid score 1.0 should be present");
        assert!(got.contains(&2.0), "valid score 2.0 should be present");
    }

    #[test]
    fn respects_max_samples_cap() {
        let items: Vec<serde_json::Value> = (0..50).map(|n| json!({"score": n as f64})).collect();
        let got = sample_scores(&items, ".score", 10);
        assert!(
            got.len() <= 10,
            "max_samples cap must be respected; got {} > 10",
            got.len(),
        );
    }

    #[test]
    fn nan_skipped() {
        // `null` decodes to `Value::Null`, which is not a number — the
        // sampler should silently skip it rather than panic or insert a
        // sentinel value.
        let items = vec![
            json!({"score": 0.5}),
            json!({"score": null}),
            json!({"score": 0.7}),
        ];
        let got = sample_scores(&items, ".score", 100);
        assert!(
            !got.iter().any(|v| v.is_nan()),
            "no NaN should leak into the sample vec; got {got:?}",
        );
        assert!(got.contains(&0.5), "valid score 0.5 must remain");
        assert!(got.contains(&0.7), "valid score 0.7 must remain");
    }
}