Skip to main content

face_core/detect/
score.rs

1//! §4.3 score-field detection.
2//!
3//! Given the parsed items list, identify a numeric field that should be
4//! treated as the score. Preference order:
5//!
6//! 1. **Named candidates** (in this order): `score`, `rank`, `relevance`,
7//!    `confidence`, `bm25`, `distance`. The first candidate that resolves
8//!    to a numeric value at the top level on the first item is chosen.
9//! 2. **Single nested numeric**: if no named candidate matches, walk the
10//!    first item recursively. If exactly one path resolves to a numeric
11//!    value, use it. More than one → no score detected with the
12//!    fallback reason `"multiple numeric fields, none named"`.
13//! 3. **Top-level beats nested** when multiple named candidates appear at
14//!    different depths (the top-level candidate wins).
15//!
16//! "No score detected" is a normal outcome, not an error — the caller
17//! falls back to grouping (§5).
18
19use serde_json::Value;
20
21use crate::FaceError;
22use crate::path;
23
24/// Named candidate fields, in §4.3 preference order.
25const NAMED_SCORE_CANDIDATES: &[&str] = &[
26    "score",
27    "rank",
28    "relevance",
29    "confidence",
30    "bm25",
31    "distance",
32];
33
34/// Tunable inputs for §4.3 score-field detection.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct ScoreOptions {
38    /// Candidate field names preferred before the unique-numeric
39    /// fallback. Names are matched at the top level first, then at any
40    /// nested object depth, in this order.
41    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
55/// Sample window used when validating a user-supplied `--score=PATH`.
56///
57/// The path must resolve to a numeric on at least one of the first
58/// `SCORE_VALIDATION_SAMPLE` items. Sized to match the preset
59/// distribution sniff window in [`crate::detect::preset`].
60const SCORE_VALIDATION_SAMPLE: usize = 16;
61
62/// Outcome of §4.3 score-field detection.
63#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65pub struct ScoreDetection {
66    /// jq-like path to the chosen score field. `None` when no numeric
67    /// field was identified — the strategy falls back to grouping (§5).
68    pub path: Option<String>,
69    /// Reason emitted when no score was detected; surfaced via the
70    /// envelope's `result.detection.fallback_reason` (§7).
71    pub fallback_reason: Option<String>,
72}
73
74impl ScoreDetection {
75    /// Construct a detection that picked `path`.
76    fn picked(path: String) -> Self {
77        Self {
78            path: Some(path),
79            fallback_reason: None,
80        }
81    }
82
83    /// Construct a detection that found nothing, with `reason` recorded
84    /// for envelope rendering.
85    fn none(reason: &str) -> Self {
86        Self {
87            path: None,
88            fallback_reason: Some(reason.to_string()),
89        }
90    }
91}
92
93/// §4.3: scan the items for a numeric score field.
94///
95/// Returns `Ok(ScoreDetection)` even when no score is found —
96/// "no score detected" is a normal outcome and not an error. The
97/// caller decides whether absence of a score is fatal in its context.
98///
99/// `--score=PATH` from the CLI bypasses detection entirely and is
100/// validated by [`validate_score_path`]; see that function's docs.
101///
102/// # Errors
103///
104/// This function does not currently return any [`FaceError`] variant;
105/// the `Result` shape is reserved for future structural validation
106/// (e.g. surfacing a parse failure on the items list itself).
107///
108/// # Examples
109///
110/// ```
111/// use face_core::detect::detect_score;
112/// use serde_json::json;
113///
114/// let items = vec![json!({"score": 0.91, "doc": "a"})];
115/// let det = detect_score(&items).unwrap();
116/// assert_eq!(det.path.as_deref(), Some(".score"));
117/// ```
118pub fn detect_score(items: &[Value]) -> Result<ScoreDetection, FaceError> {
119    detect_score_with_options(items, &ScoreOptions::default())
120}
121
122/// §4.3 score detection with caller-supplied named candidates.
123pub 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    // 1. Named candidates at top-level.
132    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    // 2. Named candidates at any depth — top-level beats nested, but a
143    //    top-level non-numeric (e.g. `"score": "high"`) does not block
144    //    a nested numeric of the same name.
145    if let Some(p) = find_nested_named_candidate(first, &options.candidates) {
146        return Ok(ScoreDetection::picked(p));
147    }
148
149    // 3. Exactly-one numeric anywhere in the first item.
150    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
160/// Validate a user-supplied `--score=PATH`.
161///
162/// The path must resolve to a numeric value on at least one of the
163/// first [`SCORE_VALIDATION_SAMPLE`] items. The sample window is small
164/// because the same window drives preset distribution sniffing in
165/// §4.4 — keeping them aligned avoids surprises where the validator
166/// accepts a path the preset detector then ignores.
167///
168/// # Errors
169///
170/// - [`FaceError::UnknownScorePath`] when the path cannot be resolved
171///   to a numeric value on any sampled item.
172///
173/// # Examples
174///
175/// ```
176/// use face_core::detect::validate_score_path;
177/// use serde_json::json;
178///
179/// let items = vec![
180///     json!({"meta": {"score": 0.5}}),
181///     json!({"meta": {"score": 0.9}}),
182/// ];
183/// validate_score_path(&items, ".meta.score").unwrap();
184/// ```
185pub 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
199/// Walk an object recursively for the first named candidate that
200/// resolves to a number. Returns the dotted path (with leading `.`).
201fn find_nested_named_candidate(value: &Value, candidates: &[String]) -> Option<String> {
202    // Iterate candidates in preference order; for each, find its first
203    // numeric occurrence anywhere in the tree.
204    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
212/// Recursive walk: find the first occurrence of `name` whose value is
213/// numeric, returning the dotted jq-like path. Object values are
214/// walked; array contents are skipped (array elements aren't "fields"
215/// in the §4.3 sense).
216fn 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    // Direct key at this level wins over deeper occurrences.
222    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
244/// Collect every numeric-leaf path from an object tree, deduplicated
245/// by path. Array contents are skipped — array elements aren't
246/// "fields" in the §4.3 sense.
247fn 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            // Arrays and scalars: not fields.
270            _ => {}
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        // `rank` and `score` both numeric; `score` wins by preference.
300        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}