use serde_json::Value;
use crate::path;
const PRESET_SAMPLE_SIZE: usize = 16;
const LONG_TAIL_RATIO: f64 = 2.0;
const CONCEPT_SCALE: f64 = 1000.0;
const CONCEPT_SCALE_TOL: f64 = 0.10;
const SEVERITY_MAX_VALUE: f64 = 100.0;
const SEVERITY_MAX_CARDINALITY: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Preset {
Confidence,
ConceptSearch,
Bm25,
Severity,
}
impl Preset {
pub fn name(&self) -> &'static str {
match self {
Preset::Confidence => "confidence",
Preset::ConceptSearch => "concept-search",
Preset::Bm25 => "bm25",
Preset::Severity => "severity",
}
}
pub fn from_name(name: &str) -> Option<Preset> {
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,
}
}
}
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);
if min >= 0.0 && max <= 1.0 {
return Some(Preset::Confidence);
}
if min >= 0.0 && (max - CONCEPT_SCALE).abs() <= CONCEPT_SCALE * CONCEPT_SCALE_TOL {
return Some(Preset::ConceptSearch);
}
if min >= 0.0 && is_small_integer_set(&finite) {
return Some(Preset::Severity);
}
if min >= 0.0 && is_long_tail(&finite) {
return Some(Preset::Bm25);
}
None
}
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
}
pub fn invert(value: f64, scale: Option<f64>) -> f64 {
match scale {
Some(s) => s - value,
None => -value,
}
}
pub const fn preset_sample_size() -> usize {
PRESET_SAMPLE_SIZE
}
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;
}
let n = *s as u32;
if !distinct.contains(&n) {
distinct.push(n);
if distinct.len() > SEVERITY_MAX_CARDINALITY {
return false;
}
}
}
!distinct.is_empty()
}
fn is_integer_valued(x: f64) -> bool {
x.is_finite() && x.fract() == 0.0
}
fn is_long_tail(samples: &[f64]) -> bool {
if samples.len() < 3 {
return false;
}
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 {
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() {
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]);
}
}