Skip to main content

face_core/detect/
preset.rs

1//! §4.4 preset auto-detection from numeric distribution shape.
2//!
3//! Once a score field is chosen (§4.3), sniff a small sample of its
4//! values and classify the distribution into one of the known
5//! [`Preset`] shapes:
6//!
7//! | Distribution                                               | Preset           |
8//! | ---------------------------------------------------------- | ---------------- |
9//! | Max ≤ 1.0, all values in `[0, 1]`                          | `Confidence`     |
10//! | Max ≈ 1000 (within ±10%), no negatives                     | `ConceptSearch`  |
11//! | All small non-negative integers, low cardinality           | `Severity`       |
12//! | Long-tail, no upper bound, max ≥ 2 × p99                   | `Bm25`           |
13//!
14//! `--invert` (sign-flip / scale-subtract) is provided as a polarity
15//! helper for distance metrics so the rest of the pipeline can assume
16//! higher-is-better.
17
18use serde_json::Value;
19
20use crate::path;
21
22/// Sample window used by both score-path validation and preset
23/// classification. Kept small so detection is cheap on streamed input.
24const PRESET_SAMPLE_SIZE: usize = 16;
25
26/// Long-tail signal: max ≥ this multiple of the next-largest value
27/// (or p99 on larger samples). 2.0 matches §4.4's "max ≫ p99".
28const LONG_TAIL_RATIO: f64 = 2.0;
29
30/// `concept-search` upper-bound tolerance: max within ±`CONCEPT_SCALE_TOL`
31/// of `CONCEPT_SCALE`.
32const CONCEPT_SCALE: f64 = 1000.0;
33const CONCEPT_SCALE_TOL: f64 = 0.10;
34
35/// Severity classification: each sample integer-valued and ≤
36/// `SEVERITY_MAX_VALUE`, distinct cardinality ≤ `SEVERITY_MAX_CARDINALITY`.
37const SEVERITY_MAX_VALUE: f64 = 100.0;
38const SEVERITY_MAX_CARDINALITY: usize = 20;
39
40/// One of the auto-detected presets per §4.4.
41///
42/// `--preset=NAME` from the CLI bypasses detection and selects one of
43/// these directly; matching on [`Preset::from_name`] is case-insensitive.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum Preset {
47    /// Max ≤ 1.0, all values in [0, 1].
48    Confidence,
49    /// Max ≈ 1000 (within ±10%), no negatives.
50    ConceptSearch,
51    /// Long-tail, no upper bound, max far above p99.
52    Bm25,
53    /// Skewed, all small non-negative integers (treated as ordinal).
54    Severity,
55}
56
57impl Preset {
58    /// Kebab-case name used on the CLI and in envelope provenance.
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use face_core::detect::Preset;
64    ///
65    /// assert_eq!(Preset::ConceptSearch.name(), "concept-search");
66    /// ```
67    pub fn name(&self) -> &'static str {
68        match self {
69            Preset::Confidence => "confidence",
70            Preset::ConceptSearch => "concept-search",
71            Preset::Bm25 => "bm25",
72            Preset::Severity => "severity",
73        }
74    }
75
76    /// Parse the kebab-case name supplied by `--preset=`. Case-insensitive.
77    /// Returns `None` for unknown names — the CLI wraps that in
78    /// [`crate::FaceError::ConflictingFlags`] (or similar) at the call site.
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use face_core::detect::Preset;
84    ///
85    /// assert_eq!(Preset::from_name("BM25"), Some(Preset::Bm25));
86    /// assert_eq!(Preset::from_name("nope"), None);
87    /// ```
88    pub fn from_name(name: &str) -> Option<Preset> {
89        // Lowercase for case-insensitive comparison; the set is small
90        // enough that a match arm is clearer than a HashMap.
91        let lowered = name.to_ascii_lowercase();
92        match lowered.as_str() {
93            "confidence" => Some(Preset::Confidence),
94            "concept-search" => Some(Preset::ConceptSearch),
95            "bm25" => Some(Preset::Bm25),
96            "severity" => Some(Preset::Severity),
97            _ => None,
98        }
99    }
100}
101
102/// §4.4: classify the score distribution into a [`Preset`].
103///
104/// Returns `None` when the samples don't match any preset cleanly.
105/// In that case the caller proceeds with raw values and the bands
106/// strategy (§5.2) still works — the preset is purely for labeling and
107/// default cluster naming, never for correctness.
108///
109/// Heuristic order matches §4.4: most-specific shape first
110/// (Confidence → ConceptSearch → Severity → Bm25). Once a preset
111/// matches, later checks are skipped — this avoids classifying e.g. a
112/// confidence distribution as severity just because all values happen
113/// to be 0.0.
114///
115/// # Examples
116///
117/// ```
118/// use face_core::detect::{Preset, detect_preset};
119///
120/// let samples = [0.1_f64, 0.5, 0.9, 0.99];
121/// assert_eq!(detect_preset(&samples), Some(Preset::Confidence));
122/// ```
123pub fn detect_preset(samples: &[f64]) -> Option<Preset> {
124    let finite: Vec<f64> = samples.iter().copied().filter(|x| x.is_finite()).collect();
125    if finite.is_empty() {
126        return None;
127    }
128    let min = finite.iter().copied().fold(f64::INFINITY, f64::min);
129    let max = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
130
131    // Confidence: [0, 1] window.
132    if min >= 0.0 && max <= 1.0 {
133        return Some(Preset::Confidence);
134    }
135
136    // Concept-search: max ≈ 1000, non-negative.
137    if min >= 0.0 && (max - CONCEPT_SCALE).abs() <= CONCEPT_SCALE * CONCEPT_SCALE_TOL {
138        return Some(Preset::ConceptSearch);
139    }
140
141    // Severity: small non-negative integers, low cardinality.
142    if min >= 0.0 && is_small_integer_set(&finite) {
143        return Some(Preset::Severity);
144    }
145
146    // Bm25: non-negative long-tail.
147    if min >= 0.0 && is_long_tail(&finite) {
148        return Some(Preset::Bm25);
149    }
150
151    None
152}
153
154/// Sample helper: extract up to `max_samples` numeric values at
155/// `score_path` from a slice of items. Non-finite values are skipped
156/// silently. Used internally by [`detect_preset`] callers, but exposed
157/// because the CLI also uses it for `--explain` output.
158///
159/// # Examples
160///
161/// ```
162/// use face_core::detect::sample_scores;
163/// use serde_json::json;
164///
165/// let items = vec![
166///     json!({"score": 0.1}),
167///     json!({"score": 0.7}),
168///     json!({"score": "n/a"}),  // non-numeric → skipped
169/// ];
170/// let s = sample_scores(&items, ".score", 16);
171/// assert_eq!(s, vec![0.1, 0.7]);
172/// ```
173pub fn sample_scores(items: &[Value], score_path: &str, max_samples: usize) -> Vec<f64> {
174    let mut out = Vec::with_capacity(max_samples.min(items.len()));
175    for item in items {
176        if out.len() >= max_samples {
177            break;
178        }
179        let Ok(v) = path::resolve(item, score_path) else {
180            continue;
181        };
182        let Some(n) = v.as_f64() else {
183            continue;
184        };
185        if n.is_finite() {
186            out.push(n);
187        }
188    }
189    out
190}
191
192/// `--invert` polarity: flip the score so higher-is-better holds
193/// downstream.
194///
195/// Useful for distance metrics (lower-is-better) so the rest of the
196/// strategy pipeline can assume higher-is-better.
197///
198/// `invert(value, scale)`:
199/// - If `scale` is `Some(s)`, return `s - value`.
200/// - If `scale` is `None`, return `-value`.
201///
202/// The CLI's `--scale=N` sets the upper bound for inversion; without
203/// `--scale`, polarity is just sign-flip.
204///
205/// # Examples
206///
207/// ```
208/// use face_core::detect::invert;
209///
210/// assert_eq!(invert(0.2, Some(1.0)), 0.8);
211/// assert_eq!(invert(0.2, None), -0.2);
212/// ```
213pub fn invert(value: f64, scale: Option<f64>) -> f64 {
214    match scale {
215        Some(s) => s - value,
216        None => -value,
217    }
218}
219
220/// Public sample window constant for callers that want to mirror the
221/// classifier's sample size (e.g. §4.6 `--explain`).
222pub const fn preset_sample_size() -> usize {
223    PRESET_SAMPLE_SIZE
224}
225
226/// True when every sample is a non-negative integer ≤
227/// [`SEVERITY_MAX_VALUE`] and the distinct cardinality is small.
228fn is_small_integer_set(samples: &[f64]) -> bool {
229    let mut distinct: Vec<u32> = Vec::with_capacity(samples.len());
230    for s in samples {
231        if !is_integer_valued(*s) {
232            return false;
233        }
234        if *s < 0.0 || *s > SEVERITY_MAX_VALUE {
235            return false;
236        }
237        // Safe: bounded above and non-negative; integer-valued.
238        let n = *s as u32;
239        if !distinct.contains(&n) {
240            distinct.push(n);
241            if distinct.len() > SEVERITY_MAX_CARDINALITY {
242                return false;
243            }
244        }
245    }
246    !distinct.is_empty()
247}
248
249/// True when `x` has no fractional part.
250fn is_integer_valued(x: f64) -> bool {
251    x.is_finite() && x.fract() == 0.0
252}
253
254/// True when the sample exhibits a long-tail signal: `max` is at least
255/// [`LONG_TAIL_RATIO`] times the second-largest value (or p99 on
256/// larger samples). For the small-sample window we use here, the
257/// max-vs-second-largest comparison is the practical signal.
258fn is_long_tail(samples: &[f64]) -> bool {
259    if samples.len() < 3 {
260        return false;
261    }
262    // Sort ascending; we only need the top two.
263    let mut sorted: Vec<f64> = samples.to_vec();
264    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
265    let max = *sorted.last().expect("len >= 3");
266    let second = sorted[sorted.len() - 2];
267    if second <= 0.0 {
268        // Second-largest is zero or negative — treat as long-tail only
269        // if the max is meaningfully positive.
270        return max > 0.0;
271    }
272    max >= LONG_TAIL_RATIO * second
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use serde_json::json;
279
280    #[test]
281    fn confidence_distribution() {
282        let s = [0.05_f64, 0.42, 0.91, 0.99, 0.5];
283        assert_eq!(detect_preset(&s), Some(Preset::Confidence));
284    }
285
286    #[test]
287    fn concept_search_near_thousand() {
288        let s = [12.5_f64, 87.0, 432.0, 950.0, 1010.0];
289        assert_eq!(detect_preset(&s), Some(Preset::ConceptSearch));
290    }
291
292    #[test]
293    fn bm25_long_tail() {
294        // Most values < 4, one big outlier far above p99.
295        let s = [1.2_f64, 1.5, 2.0, 2.4, 2.8, 3.1, 3.4, 14.2];
296        assert_eq!(detect_preset(&s), Some(Preset::Bm25));
297    }
298
299    #[test]
300    fn severity_small_integers() {
301        let s = [0.0_f64, 1.0, 2.0, 3.0, 1.0, 2.0];
302        assert_eq!(detect_preset(&s), Some(Preset::Severity));
303    }
304
305    #[test]
306    fn no_preset_for_mixed_signed() {
307        let s = [-3.5_f64, 1.2, 4.7, 9.9];
308        assert_eq!(detect_preset(&s), None);
309    }
310
311    #[test]
312    fn invert_basic() {
313        assert!((invert(0.2, Some(1.0)) - 0.8).abs() < 1e-9);
314        assert!((invert(0.2, None) + 0.2).abs() < 1e-9);
315        assert_eq!(invert(7.0, Some(10.0)), 3.0);
316    }
317
318    #[test]
319    fn from_name_is_case_insensitive() {
320        assert_eq!(Preset::from_name("Confidence"), Some(Preset::Confidence));
321        assert_eq!(Preset::from_name("BM25"), Some(Preset::Bm25));
322        assert_eq!(
323            Preset::from_name("Concept-Search"),
324            Some(Preset::ConceptSearch)
325        );
326        assert_eq!(Preset::from_name("nope"), None);
327    }
328
329    #[test]
330    fn sample_scores_skips_non_numeric_and_caps() {
331        let items = vec![
332            json!({"score": 0.1}),
333            json!({"score": "n/a"}),
334            json!({"score": 0.7}),
335            json!({"score": 0.5}),
336        ];
337        let s = sample_scores(&items, ".score", 2);
338        assert_eq!(s, vec![0.1, 0.7]);
339    }
340}