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}