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}