use serde_json::Value;
use crate::FaceError;
use crate::path;
const NAMED_SCORE_CANDIDATES: &[&str] = &[
"score",
"rank",
"relevance",
"confidence",
"bm25",
"distance",
];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ScoreOptions {
pub candidates: Vec<String>,
}
impl Default for ScoreOptions {
fn default() -> Self {
Self {
candidates: NAMED_SCORE_CANDIDATES
.iter()
.map(|candidate| (*candidate).to_string())
.collect(),
}
}
}
const SCORE_VALIDATION_SAMPLE: usize = 16;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ScoreDetection {
pub path: Option<String>,
pub fallback_reason: Option<String>,
}
impl ScoreDetection {
fn picked(path: String) -> Self {
Self {
path: Some(path),
fallback_reason: None,
}
}
fn none(reason: &str) -> Self {
Self {
path: None,
fallback_reason: Some(reason.to_string()),
}
}
}
pub fn detect_score(items: &[Value]) -> Result<ScoreDetection, FaceError> {
detect_score_with_options(items, &ScoreOptions::default())
}
pub fn detect_score_with_options(
items: &[Value],
options: &ScoreOptions,
) -> Result<ScoreDetection, FaceError> {
let Some(first) = items.first() else {
return Ok(ScoreDetection::none("no items in input"));
};
if let Value::Object(map) = first {
for candidate in &options.candidates {
if let Some(v) = map.get(candidate)
&& v.is_number()
{
return Ok(ScoreDetection::picked(format!(".{candidate}")));
}
}
}
if let Some(p) = find_nested_named_candidate(first, &options.candidates) {
return Ok(ScoreDetection::picked(p));
}
let numeric_paths = collect_numeric_paths(first);
match numeric_paths.len() {
0 => Ok(ScoreDetection::none("no numeric field in first item")),
1 => Ok(ScoreDetection::picked(
numeric_paths.into_iter().next().expect("len == 1"),
)),
_ => Ok(ScoreDetection::none("multiple numeric fields, none named")),
}
}
pub fn validate_score_path(items: &[Value], path: &str) -> Result<(), FaceError> {
let window = items.iter().take(SCORE_VALIDATION_SAMPLE);
for item in window {
if let Ok(v) = path::resolve(item, path)
&& v.is_number()
{
return Ok(());
}
}
Err(FaceError::UnknownScorePath {
path: path.to_string(),
})
}
fn find_nested_named_candidate(value: &Value, candidates: &[String]) -> Option<String> {
for candidate in candidates {
if let Some(p) = find_named_anywhere(value, candidate, &mut Vec::new()) {
return Some(p);
}
}
None
}
fn find_named_anywhere(value: &Value, name: &str, stack: &mut Vec<String>) -> Option<String> {
let Value::Object(map) = value else {
return None;
};
if let Some(v) = map.get(name)
&& v.is_number()
{
stack.push(name.to_string());
let p = format_path(stack);
stack.pop();
return Some(p);
}
for (k, v) in map {
if v.is_object() {
stack.push(k.clone());
if let Some(p) = find_named_anywhere(v, name, stack) {
stack.pop();
return Some(p);
}
stack.pop();
}
}
None
}
fn collect_numeric_paths(value: &Value) -> Vec<String> {
let mut out = Vec::new();
collect_into(value, &mut Vec::new(), &mut out);
out
}
fn collect_into(value: &Value, stack: &mut Vec<String>, out: &mut Vec<String>) {
let Value::Object(map) = value else {
return;
};
for (k, v) in map {
match v {
Value::Number(_) => {
stack.push(k.clone());
out.push(format_path(stack));
stack.pop();
}
Value::Object(_) => {
stack.push(k.clone());
collect_into(v, stack, out);
stack.pop();
}
_ => {}
}
}
}
fn format_path(segments: &[String]) -> String {
let mut out = String::with_capacity(segments.iter().map(|s| s.len() + 1).sum());
for s in segments {
out.push('.');
out.push_str(s);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn picks_named_candidate_score_at_top_level() {
let items = vec![json!({"score": 0.91, "rank": 3, "doc": "a"})];
let det = detect_score(&items).unwrap();
assert_eq!(det.path.as_deref(), Some(".score"));
assert!(det.fallback_reason.is_none());
}
#[test]
fn prefers_named_order_score_over_rank() {
let items = vec![json!({"rank": 1, "score": 0.5})];
let det = detect_score(&items).unwrap();
assert_eq!(det.path.as_deref(), Some(".score"));
}
#[test]
fn falls_back_to_unique_numeric() {
let items = vec![json!({"weight": 0.42, "doc": "a"})];
let det = detect_score(&items).unwrap();
assert_eq!(det.path.as_deref(), Some(".weight"));
}
#[test]
fn no_score_when_multiple_unnamed_numerics() {
let items = vec![json!({"alpha": 1.0, "beta": 2.0, "doc": "x"})];
let det = detect_score(&items).unwrap();
assert!(det.path.is_none());
assert_eq!(
det.fallback_reason.as_deref(),
Some("multiple numeric fields, none named"),
);
}
#[test]
fn handles_empty_items_list() {
let det = detect_score(&[]).unwrap();
assert!(det.path.is_none());
assert_eq!(det.fallback_reason.as_deref(), Some("no items in input"));
}
#[test]
fn nested_named_candidate_when_top_level_absent() {
let items = vec![json!({"meta": {"confidence": 0.7}, "doc": "a"})];
let det = detect_score(&items).unwrap();
assert_eq!(det.path.as_deref(), Some(".meta.confidence"));
}
#[test]
fn validate_score_path_accepts_resolvable_numeric() {
let items = vec![json!({"meta": {"score": 0.5}})];
validate_score_path(&items, ".meta.score").unwrap();
}
#[test]
fn validate_score_path_rejects_non_numeric() {
let items = vec![json!({"score": "high"})];
let err = validate_score_path(&items, ".score").unwrap_err();
match err {
FaceError::UnknownScorePath { path } => assert_eq!(path, ".score"),
other => panic!("unexpected error: {other:?}"),
}
}
}