Skip to main content

face_core/
record.rs

1//! One concrete record passed into clustering.
2//!
3//! [`Record`] is the unit the tree builder operates on: the parsed
4//! input value plus an optional resolved score. Score resolution is
5//! per-record permissive — strategies decide whether a missing score
6//! is a problem (§5.2 numeric strategies require scores; categorical
7//! strategies don't). `--invert` polarity flipping is the caller's
8//! responsibility; this slice does not auto-invert.
9
10use serde_json::Value;
11
12/// One concrete record passed into clustering.
13///
14/// `raw` is the original parsed item (never mutated). `score` is the
15/// result of resolving the optional score path against `raw` and
16/// converting to `f64`. `None` when no score was configured for this
17/// run, or when the score path didn't resolve to a number for this
18/// particular record.
19///
20/// `Record` is `#[non_exhaustive]` — construct values via
21/// [`Record::new`] or [`Record::from_items`].
22#[derive(Debug, Clone)]
23#[non_exhaustive]
24pub struct Record {
25    /// Original parsed item, never mutated.
26    pub raw: Value,
27    /// Resolved score, when a score path was configured and the value
28    /// at that path was a JSON number.
29    pub score: Option<f64>,
30}
31
32impl Record {
33    /// Construct a record with no score resolved.
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// use face_core::Record;
39    /// use serde_json::json;
40    ///
41    /// let r = Record::new(json!({"score": 0.9}));
42    /// assert!(r.score.is_none());
43    /// ```
44    pub fn new(raw: Value) -> Self {
45        Self { raw, score: None }
46    }
47
48    /// Build a `Vec<Record>` from a parsed items list.
49    ///
50    /// When `score_path` is `None`, all records have `score: None`.
51    /// When `score_path` is `Some(path)`, the path is resolved against
52    /// each record. If the resolved value is a JSON number, it
53    /// becomes the record's score. Otherwise the score remains `None`
54    /// (this is per-record permissiveness — strategies decide whether
55    /// missing scores are a problem).
56    ///
57    /// `--invert` polarity flipping is the caller's responsibility:
58    /// pass already-inverted scores via a follow-up `apply_invert`,
59    /// or build records via `from_items` and let the caller iterate.
60    /// Slice 5 does not auto-invert.
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use face_core::Record;
66    /// use serde_json::json;
67    ///
68    /// let items = vec![
69    ///     json!({"score": 0.9}),
70    ///     json!({"score": "n/a"}),
71    /// ];
72    /// let records = Record::from_items(items, Some("score"));
73    /// assert_eq!(records[0].score, Some(0.9));
74    /// assert_eq!(records[1].score, None);
75    /// ```
76    pub fn from_items(items: Vec<Value>, score_path: Option<&str>) -> Vec<Record> {
77        items
78            .into_iter()
79            .map(|raw| {
80                let score = score_path
81                    .and_then(|p| crate::path::resolve(&raw, p).ok())
82                    .and_then(Value::as_f64);
83                Record { raw, score }
84            })
85            .collect()
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use serde_json::json;
93
94    #[test]
95    fn from_items_resolves_score() {
96        let items = vec![json!({"score": 0.9}), json!({"score": 0.42})];
97        let records = Record::from_items(items, Some("score"));
98        assert_eq!(records.len(), 2);
99        assert_eq!(records[0].score, Some(0.9));
100        assert_eq!(records[1].score, Some(0.42));
101    }
102
103    #[test]
104    fn from_items_handles_missing_path() {
105        // Path not found, value is non-numeric, and no path requested
106        // all collapse to `score: None` without erroring.
107        let items = vec![
108            json!({"score": "high"}),
109            json!({"other": 1.0}),
110            json!({"score": 0.5}),
111        ];
112        let records = Record::from_items(items, Some("score"));
113        assert_eq!(records[0].score, None);
114        assert_eq!(records[1].score, None);
115        assert_eq!(records[2].score, Some(0.5));
116
117        // No score path at all → all None.
118        let items = vec![json!({"score": 0.9})];
119        let records = Record::from_items(items, None);
120        assert_eq!(records[0].score, None);
121    }
122
123    #[test]
124    fn new_constructs_score_none() {
125        let r = Record::new(json!({"foo": 1}));
126        assert!(r.score.is_none());
127    }
128}