face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! §4.3 score-field detection.
//!
//! Given the parsed items list, identify a numeric field that should be
//! treated as the score. Preference order:
//!
//! 1. **Named candidates** (in this order): `score`, `rank`, `relevance`,
//!    `confidence`, `bm25`, `distance`. The first candidate that resolves
//!    to a numeric value at the top level on the first item is chosen.
//! 2. **Single nested numeric**: if no named candidate matches, walk the
//!    first item recursively. If exactly one path resolves to a numeric
//!    value, use it. More than one → no score detected with the
//!    fallback reason `"multiple numeric fields, none named"`.
//! 3. **Top-level beats nested** when multiple named candidates appear at
//!    different depths (the top-level candidate wins).
//!
//! "No score detected" is a normal outcome, not an error — the caller
//! falls back to grouping (§5).

use serde_json::Value;

use crate::FaceError;
use crate::path;

/// Named candidate fields, in §4.3 preference order.
const NAMED_SCORE_CANDIDATES: &[&str] = &[
    "score",
    "rank",
    "relevance",
    "confidence",
    "bm25",
    "distance",
];

/// Tunable inputs for §4.3 score-field detection.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ScoreOptions {
    /// Candidate field names preferred before the unique-numeric
    /// fallback. Names are matched at the top level first, then at any
    /// nested object depth, in this order.
    pub candidates: Vec<String>,
}

impl Default for ScoreOptions {
    fn default() -> Self {
        Self {
            candidates: NAMED_SCORE_CANDIDATES
                .iter()
                .map(|candidate| (*candidate).to_string())
                .collect(),
        }
    }
}

/// Sample window used when validating a user-supplied `--score=PATH`.
///
/// The path must resolve to a numeric on at least one of the first
/// `SCORE_VALIDATION_SAMPLE` items. Sized to match the preset
/// distribution sniff window in [`crate::detect::preset`].
const SCORE_VALIDATION_SAMPLE: usize = 16;

/// Outcome of §4.3 score-field detection.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ScoreDetection {
    /// jq-like path to the chosen score field. `None` when no numeric
    /// field was identified — the strategy falls back to grouping (§5).
    pub path: Option<String>,
    /// Reason emitted when no score was detected; surfaced via the
    /// envelope's `result.detection.fallback_reason` (§7).
    pub fallback_reason: Option<String>,
}

impl ScoreDetection {
    /// Construct a detection that picked `path`.
    fn picked(path: String) -> Self {
        Self {
            path: Some(path),
            fallback_reason: None,
        }
    }

    /// Construct a detection that found nothing, with `reason` recorded
    /// for envelope rendering.
    fn none(reason: &str) -> Self {
        Self {
            path: None,
            fallback_reason: Some(reason.to_string()),
        }
    }
}

/// §4.3: scan the items for a numeric score field.
///
/// Returns `Ok(ScoreDetection)` even when no score is found —
/// "no score detected" is a normal outcome and not an error. The
/// caller decides whether absence of a score is fatal in its context.
///
/// `--score=PATH` from the CLI bypasses detection entirely and is
/// validated by [`validate_score_path`]; see that function's docs.
///
/// # Errors
///
/// This function does not currently return any [`FaceError`] variant;
/// the `Result` shape is reserved for future structural validation
/// (e.g. surfacing a parse failure on the items list itself).
///
/// # Examples
///
/// ```
/// use face_core::detect::detect_score;
/// use serde_json::json;
///
/// let items = vec![json!({"score": 0.91, "doc": "a"})];
/// let det = detect_score(&items).unwrap();
/// assert_eq!(det.path.as_deref(), Some(".score"));
/// ```
pub fn detect_score(items: &[Value]) -> Result<ScoreDetection, FaceError> {
    detect_score_with_options(items, &ScoreOptions::default())
}

/// §4.3 score detection with caller-supplied named candidates.
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"));
    };

    // 1. Named candidates at top-level.
    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}")));
            }
        }
    }

    // 2. Named candidates at any depth — top-level beats nested, but a
    //    top-level non-numeric (e.g. `"score": "high"`) does not block
    //    a nested numeric of the same name.
    if let Some(p) = find_nested_named_candidate(first, &options.candidates) {
        return Ok(ScoreDetection::picked(p));
    }

    // 3. Exactly-one numeric anywhere in the first item.
    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")),
    }
}

/// Validate a user-supplied `--score=PATH`.
///
/// The path must resolve to a numeric value on at least one of the
/// first [`SCORE_VALIDATION_SAMPLE`] items. The sample window is small
/// because the same window drives preset distribution sniffing in
/// §4.4 — keeping them aligned avoids surprises where the validator
/// accepts a path the preset detector then ignores.
///
/// # Errors
///
/// - [`FaceError::UnknownScorePath`] when the path cannot be resolved
///   to a numeric value on any sampled item.
///
/// # Examples
///
/// ```
/// use face_core::detect::validate_score_path;
/// use serde_json::json;
///
/// let items = vec![
///     json!({"meta": {"score": 0.5}}),
///     json!({"meta": {"score": 0.9}}),
/// ];
/// validate_score_path(&items, ".meta.score").unwrap();
/// ```
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(),
    })
}

/// Walk an object recursively for the first named candidate that
/// resolves to a number. Returns the dotted path (with leading `.`).
fn find_nested_named_candidate(value: &Value, candidates: &[String]) -> Option<String> {
    // Iterate candidates in preference order; for each, find its first
    // numeric occurrence anywhere in the tree.
    for candidate in candidates {
        if let Some(p) = find_named_anywhere(value, candidate, &mut Vec::new()) {
            return Some(p);
        }
    }
    None
}

/// Recursive walk: find the first occurrence of `name` whose value is
/// numeric, returning the dotted jq-like path. Object values are
/// walked; array contents are skipped (array elements aren't "fields"
/// in the §4.3 sense).
fn find_named_anywhere(value: &Value, name: &str, stack: &mut Vec<String>) -> Option<String> {
    let Value::Object(map) = value else {
        return None;
    };

    // Direct key at this level wins over deeper occurrences.
    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
}

/// Collect every numeric-leaf path from an object tree, deduplicated
/// by path. Array contents are skipped — array elements aren't
/// "fields" in the §4.3 sense.
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();
            }
            // Arrays and scalars: not fields.
            _ => {}
        }
    }
}

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() {
        // `rank` and `score` both numeric; `score` wins by preference.
        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:?}"),
        }
    }
}