face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! §4.4 preset auto-detection from numeric distribution shape.
//!
//! Once a score field is chosen (§4.3), sniff a small sample of its
//! values and classify the distribution into one of the known
//! [`Preset`] shapes:
//!
//! | Distribution                                               | Preset           |
//! | ---------------------------------------------------------- | ---------------- |
//! | Max ≤ 1.0, all values in `[0, 1]`                          | `Confidence`     |
//! | Max ≈ 1000 (within ±10%), no negatives                     | `ConceptSearch`  |
//! | All small non-negative integers, low cardinality           | `Severity`       |
//! | Long-tail, no upper bound, max ≥ 2 × p99                   | `Bm25`           |
//!
//! `--invert` (sign-flip / scale-subtract) is provided as a polarity
//! helper for distance metrics so the rest of the pipeline can assume
//! higher-is-better.

use serde_json::Value;

use crate::path;

/// Sample window used by both score-path validation and preset
/// classification. Kept small so detection is cheap on streamed input.
const PRESET_SAMPLE_SIZE: usize = 16;

/// Long-tail signal: max ≥ this multiple of the next-largest value
/// (or p99 on larger samples). 2.0 matches §4.4's "max ≫ p99".
const LONG_TAIL_RATIO: f64 = 2.0;

/// `concept-search` upper-bound tolerance: max within ±`CONCEPT_SCALE_TOL`
/// of `CONCEPT_SCALE`.
const CONCEPT_SCALE: f64 = 1000.0;
const CONCEPT_SCALE_TOL: f64 = 0.10;

/// Severity classification: each sample integer-valued and ≤
/// `SEVERITY_MAX_VALUE`, distinct cardinality ≤ `SEVERITY_MAX_CARDINALITY`.
const SEVERITY_MAX_VALUE: f64 = 100.0;
const SEVERITY_MAX_CARDINALITY: usize = 20;

/// One of the auto-detected presets per §4.4.
///
/// `--preset=NAME` from the CLI bypasses detection and selects one of
/// these directly; matching on [`Preset::from_name`] is case-insensitive.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Preset {
    /// Max ≤ 1.0, all values in [0, 1].
    Confidence,
    /// Max ≈ 1000 (within ±10%), no negatives.
    ConceptSearch,
    /// Long-tail, no upper bound, max far above p99.
    Bm25,
    /// Skewed, all small non-negative integers (treated as ordinal).
    Severity,
}

impl Preset {
    /// Kebab-case name used on the CLI and in envelope provenance.
    ///
    /// # Examples
    ///
    /// ```
    /// use face_core::detect::Preset;
    ///
    /// assert_eq!(Preset::ConceptSearch.name(), "concept-search");
    /// ```
    pub fn name(&self) -> &'static str {
        match self {
            Preset::Confidence => "confidence",
            Preset::ConceptSearch => "concept-search",
            Preset::Bm25 => "bm25",
            Preset::Severity => "severity",
        }
    }

    /// Parse the kebab-case name supplied by `--preset=`. Case-insensitive.
    /// Returns `None` for unknown names — the CLI wraps that in
    /// [`crate::FaceError::ConflictingFlags`] (or similar) at the call site.
    ///
    /// # Examples
    ///
    /// ```
    /// use face_core::detect::Preset;
    ///
    /// assert_eq!(Preset::from_name("BM25"), Some(Preset::Bm25));
    /// assert_eq!(Preset::from_name("nope"), None);
    /// ```
    pub fn from_name(name: &str) -> Option<Preset> {
        // Lowercase for case-insensitive comparison; the set is small
        // enough that a match arm is clearer than a HashMap.
        let lowered = name.to_ascii_lowercase();
        match lowered.as_str() {
            "confidence" => Some(Preset::Confidence),
            "concept-search" => Some(Preset::ConceptSearch),
            "bm25" => Some(Preset::Bm25),
            "severity" => Some(Preset::Severity),
            _ => None,
        }
    }
}

/// §4.4: classify the score distribution into a [`Preset`].
///
/// Returns `None` when the samples don't match any preset cleanly.
/// In that case the caller proceeds with raw values and the bands
/// strategy (§5.2) still works — the preset is purely for labeling and
/// default cluster naming, never for correctness.
///
/// Heuristic order matches §4.4: most-specific shape first
/// (Confidence → ConceptSearch → Severity → Bm25). Once a preset
/// matches, later checks are skipped — this avoids classifying e.g. a
/// confidence distribution as severity just because all values happen
/// to be 0.0.
///
/// # Examples
///
/// ```
/// use face_core::detect::{Preset, detect_preset};
///
/// let samples = [0.1_f64, 0.5, 0.9, 0.99];
/// assert_eq!(detect_preset(&samples), Some(Preset::Confidence));
/// ```
pub fn detect_preset(samples: &[f64]) -> Option<Preset> {
    let finite: Vec<f64> = samples.iter().copied().filter(|x| x.is_finite()).collect();
    if finite.is_empty() {
        return None;
    }
    let min = finite.iter().copied().fold(f64::INFINITY, f64::min);
    let max = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);

    // Confidence: [0, 1] window.
    if min >= 0.0 && max <= 1.0 {
        return Some(Preset::Confidence);
    }

    // Concept-search: max ≈ 1000, non-negative.
    if min >= 0.0 && (max - CONCEPT_SCALE).abs() <= CONCEPT_SCALE * CONCEPT_SCALE_TOL {
        return Some(Preset::ConceptSearch);
    }

    // Severity: small non-negative integers, low cardinality.
    if min >= 0.0 && is_small_integer_set(&finite) {
        return Some(Preset::Severity);
    }

    // Bm25: non-negative long-tail.
    if min >= 0.0 && is_long_tail(&finite) {
        return Some(Preset::Bm25);
    }

    None
}

/// Sample helper: extract up to `max_samples` numeric values at
/// `score_path` from a slice of items. Non-finite values are skipped
/// silently. Used internally by [`detect_preset`] callers, but exposed
/// because the CLI also uses it for `--explain` output.
///
/// # Examples
///
/// ```
/// use face_core::detect::sample_scores;
/// use serde_json::json;
///
/// let items = vec![
///     json!({"score": 0.1}),
///     json!({"score": 0.7}),
///     json!({"score": "n/a"}),  // non-numeric → skipped
/// ];
/// let s = sample_scores(&items, ".score", 16);
/// assert_eq!(s, vec![0.1, 0.7]);
/// ```
pub fn sample_scores(items: &[Value], score_path: &str, max_samples: usize) -> Vec<f64> {
    let mut out = Vec::with_capacity(max_samples.min(items.len()));
    for item in items {
        if out.len() >= max_samples {
            break;
        }
        let Ok(v) = path::resolve(item, score_path) else {
            continue;
        };
        let Some(n) = v.as_f64() else {
            continue;
        };
        if n.is_finite() {
            out.push(n);
        }
    }
    out
}

/// `--invert` polarity: flip the score so higher-is-better holds
/// downstream.
///
/// Useful for distance metrics (lower-is-better) so the rest of the
/// strategy pipeline can assume higher-is-better.
///
/// `invert(value, scale)`:
/// - If `scale` is `Some(s)`, return `s - value`.
/// - If `scale` is `None`, return `-value`.
///
/// The CLI's `--scale=N` sets the upper bound for inversion; without
/// `--scale`, polarity is just sign-flip.
///
/// # Examples
///
/// ```
/// use face_core::detect::invert;
///
/// assert_eq!(invert(0.2, Some(1.0)), 0.8);
/// assert_eq!(invert(0.2, None), -0.2);
/// ```
pub fn invert(value: f64, scale: Option<f64>) -> f64 {
    match scale {
        Some(s) => s - value,
        None => -value,
    }
}

/// Public sample window constant for callers that want to mirror the
/// classifier's sample size (e.g. §4.6 `--explain`).
pub const fn preset_sample_size() -> usize {
    PRESET_SAMPLE_SIZE
}

/// True when every sample is a non-negative integer ≤
/// [`SEVERITY_MAX_VALUE`] and the distinct cardinality is small.
fn is_small_integer_set(samples: &[f64]) -> bool {
    let mut distinct: Vec<u32> = Vec::with_capacity(samples.len());
    for s in samples {
        if !is_integer_valued(*s) {
            return false;
        }
        if *s < 0.0 || *s > SEVERITY_MAX_VALUE {
            return false;
        }
        // Safe: bounded above and non-negative; integer-valued.
        let n = *s as u32;
        if !distinct.contains(&n) {
            distinct.push(n);
            if distinct.len() > SEVERITY_MAX_CARDINALITY {
                return false;
            }
        }
    }
    !distinct.is_empty()
}

/// True when `x` has no fractional part.
fn is_integer_valued(x: f64) -> bool {
    x.is_finite() && x.fract() == 0.0
}

/// True when the sample exhibits a long-tail signal: `max` is at least
/// [`LONG_TAIL_RATIO`] times the second-largest value (or p99 on
/// larger samples). For the small-sample window we use here, the
/// max-vs-second-largest comparison is the practical signal.
fn is_long_tail(samples: &[f64]) -> bool {
    if samples.len() < 3 {
        return false;
    }
    // Sort ascending; we only need the top two.
    let mut sorted: Vec<f64> = samples.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let max = *sorted.last().expect("len >= 3");
    let second = sorted[sorted.len() - 2];
    if second <= 0.0 {
        // Second-largest is zero or negative — treat as long-tail only
        // if the max is meaningfully positive.
        return max > 0.0;
    }
    max >= LONG_TAIL_RATIO * second
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn confidence_distribution() {
        let s = [0.05_f64, 0.42, 0.91, 0.99, 0.5];
        assert_eq!(detect_preset(&s), Some(Preset::Confidence));
    }

    #[test]
    fn concept_search_near_thousand() {
        let s = [12.5_f64, 87.0, 432.0, 950.0, 1010.0];
        assert_eq!(detect_preset(&s), Some(Preset::ConceptSearch));
    }

    #[test]
    fn bm25_long_tail() {
        // Most values < 4, one big outlier far above p99.
        let s = [1.2_f64, 1.5, 2.0, 2.4, 2.8, 3.1, 3.4, 14.2];
        assert_eq!(detect_preset(&s), Some(Preset::Bm25));
    }

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

    #[test]
    fn no_preset_for_mixed_signed() {
        let s = [-3.5_f64, 1.2, 4.7, 9.9];
        assert_eq!(detect_preset(&s), None);
    }

    #[test]
    fn invert_basic() {
        assert!((invert(0.2, Some(1.0)) - 0.8).abs() < 1e-9);
        assert!((invert(0.2, None) + 0.2).abs() < 1e-9);
        assert_eq!(invert(7.0, Some(10.0)), 3.0);
    }

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

    #[test]
    fn sample_scores_skips_non_numeric_and_caps() {
        let items = vec![
            json!({"score": 0.1}),
            json!({"score": "n/a"}),
            json!({"score": 0.7}),
            json!({"score": 0.5}),
        ];
        let s = sample_scores(&items, ".score", 2);
        assert_eq!(s, vec![0.1, 0.7]);
    }
}