face_core/detect/
score.rs1use serde_json::Value;
20
21use crate::FaceError;
22use crate::path;
23
24const NAMED_SCORE_CANDIDATES: &[&str] = &[
26 "score",
27 "rank",
28 "relevance",
29 "confidence",
30 "bm25",
31 "distance",
32];
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct ScoreOptions {
38 pub candidates: Vec<String>,
42}
43
44impl Default for ScoreOptions {
45 fn default() -> Self {
46 Self {
47 candidates: NAMED_SCORE_CANDIDATES
48 .iter()
49 .map(|candidate| (*candidate).to_string())
50 .collect(),
51 }
52 }
53}
54
55const SCORE_VALIDATION_SAMPLE: usize = 16;
61
62#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65pub struct ScoreDetection {
66 pub path: Option<String>,
69 pub fallback_reason: Option<String>,
72}
73
74impl ScoreDetection {
75 fn picked(path: String) -> Self {
77 Self {
78 path: Some(path),
79 fallback_reason: None,
80 }
81 }
82
83 fn none(reason: &str) -> Self {
86 Self {
87 path: None,
88 fallback_reason: Some(reason.to_string()),
89 }
90 }
91}
92
93pub fn detect_score(items: &[Value]) -> Result<ScoreDetection, FaceError> {
119 detect_score_with_options(items, &ScoreOptions::default())
120}
121
122pub fn detect_score_with_options(
124 items: &[Value],
125 options: &ScoreOptions,
126) -> Result<ScoreDetection, FaceError> {
127 let Some(first) = items.first() else {
128 return Ok(ScoreDetection::none("no items in input"));
129 };
130
131 if let Value::Object(map) = first {
133 for candidate in &options.candidates {
134 if let Some(v) = map.get(candidate)
135 && v.is_number()
136 {
137 return Ok(ScoreDetection::picked(format!(".{candidate}")));
138 }
139 }
140 }
141
142 if let Some(p) = find_nested_named_candidate(first, &options.candidates) {
146 return Ok(ScoreDetection::picked(p));
147 }
148
149 let numeric_paths = collect_numeric_paths(first);
151 match numeric_paths.len() {
152 0 => Ok(ScoreDetection::none("no numeric field in first item")),
153 1 => Ok(ScoreDetection::picked(
154 numeric_paths.into_iter().next().expect("len == 1"),
155 )),
156 _ => Ok(ScoreDetection::none("multiple numeric fields, none named")),
157 }
158}
159
160pub fn validate_score_path(items: &[Value], path: &str) -> Result<(), FaceError> {
186 let window = items.iter().take(SCORE_VALIDATION_SAMPLE);
187 for item in window {
188 if let Ok(v) = path::resolve(item, path)
189 && v.is_number()
190 {
191 return Ok(());
192 }
193 }
194 Err(FaceError::UnknownScorePath {
195 path: path.to_string(),
196 })
197}
198
199fn find_nested_named_candidate(value: &Value, candidates: &[String]) -> Option<String> {
202 for candidate in candidates {
205 if let Some(p) = find_named_anywhere(value, candidate, &mut Vec::new()) {
206 return Some(p);
207 }
208 }
209 None
210}
211
212fn find_named_anywhere(value: &Value, name: &str, stack: &mut Vec<String>) -> Option<String> {
217 let Value::Object(map) = value else {
218 return None;
219 };
220
221 if let Some(v) = map.get(name)
223 && v.is_number()
224 {
225 stack.push(name.to_string());
226 let p = format_path(stack);
227 stack.pop();
228 return Some(p);
229 }
230
231 for (k, v) in map {
232 if v.is_object() {
233 stack.push(k.clone());
234 if let Some(p) = find_named_anywhere(v, name, stack) {
235 stack.pop();
236 return Some(p);
237 }
238 stack.pop();
239 }
240 }
241 None
242}
243
244fn collect_numeric_paths(value: &Value) -> Vec<String> {
248 let mut out = Vec::new();
249 collect_into(value, &mut Vec::new(), &mut out);
250 out
251}
252
253fn collect_into(value: &Value, stack: &mut Vec<String>, out: &mut Vec<String>) {
254 let Value::Object(map) = value else {
255 return;
256 };
257 for (k, v) in map {
258 match v {
259 Value::Number(_) => {
260 stack.push(k.clone());
261 out.push(format_path(stack));
262 stack.pop();
263 }
264 Value::Object(_) => {
265 stack.push(k.clone());
266 collect_into(v, stack, out);
267 stack.pop();
268 }
269 _ => {}
271 }
272 }
273}
274
275fn format_path(segments: &[String]) -> String {
276 let mut out = String::with_capacity(segments.iter().map(|s| s.len() + 1).sum());
277 for s in segments {
278 out.push('.');
279 out.push_str(s);
280 }
281 out
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use serde_json::json;
288
289 #[test]
290 fn picks_named_candidate_score_at_top_level() {
291 let items = vec![json!({"score": 0.91, "rank": 3, "doc": "a"})];
292 let det = detect_score(&items).unwrap();
293 assert_eq!(det.path.as_deref(), Some(".score"));
294 assert!(det.fallback_reason.is_none());
295 }
296
297 #[test]
298 fn prefers_named_order_score_over_rank() {
299 let items = vec![json!({"rank": 1, "score": 0.5})];
301 let det = detect_score(&items).unwrap();
302 assert_eq!(det.path.as_deref(), Some(".score"));
303 }
304
305 #[test]
306 fn falls_back_to_unique_numeric() {
307 let items = vec![json!({"weight": 0.42, "doc": "a"})];
308 let det = detect_score(&items).unwrap();
309 assert_eq!(det.path.as_deref(), Some(".weight"));
310 }
311
312 #[test]
313 fn no_score_when_multiple_unnamed_numerics() {
314 let items = vec![json!({"alpha": 1.0, "beta": 2.0, "doc": "x"})];
315 let det = detect_score(&items).unwrap();
316 assert!(det.path.is_none());
317 assert_eq!(
318 det.fallback_reason.as_deref(),
319 Some("multiple numeric fields, none named"),
320 );
321 }
322
323 #[test]
324 fn handles_empty_items_list() {
325 let det = detect_score(&[]).unwrap();
326 assert!(det.path.is_none());
327 assert_eq!(det.fallback_reason.as_deref(), Some("no items in input"));
328 }
329
330 #[test]
331 fn nested_named_candidate_when_top_level_absent() {
332 let items = vec![json!({"meta": {"confidence": 0.7}, "doc": "a"})];
333 let det = detect_score(&items).unwrap();
334 assert_eq!(det.path.as_deref(), Some(".meta.confidence"));
335 }
336
337 #[test]
338 fn validate_score_path_accepts_resolvable_numeric() {
339 let items = vec![json!({"meta": {"score": 0.5}})];
340 validate_score_path(&items, ".meta.score").unwrap();
341 }
342
343 #[test]
344 fn validate_score_path_rejects_non_numeric() {
345 let items = vec![json!({"score": "high"})];
346 let err = validate_score_path(&items, ".score").unwrap_err();
347 match err {
348 FaceError::UnknownScorePath { path } => assert_eq!(path, ".score"),
349 other => panic!("unexpected error: {other:?}"),
350 }
351 }
352}