1use crate::binding::Binding;
18use hawdb_core::graph_rag::ScoringFeatureSource;
19use hawdb_core::Value;
20
21pub struct BindingScoreFeatures<'a> {
29 binding: &'a Binding,
30 score_column: &'a str,
31}
32
33impl<'a> BindingScoreFeatures<'a> {
34 pub fn new(binding: &'a Binding, score_column: &'a str) -> Self {
35 Self {
36 binding,
37 score_column,
38 }
39 }
40
41 fn numeric(&self, value: Option<&Value>) -> Option<f64> {
42 match value? {
43 Value::Float(number) => Some(*number),
44 Value::Int(number) => Some(*number as f64),
45 _ => None,
46 }
47 }
48}
49
50impl ScoringFeatureSource for BindingScoreFeatures<'_> {
51 fn search_score(&self) -> Option<f64> {
52 self.numeric(self.binding.values.get(self.score_column))
53 }
54
55 fn graph_seed_score(&self) -> Option<f64> {
56 None
57 }
58
59 fn hop_distance(&self) -> Option<usize> {
60 None
61 }
62
63 fn numeric_property(&self, property: &str) -> Option<f64> {
64 self.numeric(self.binding.values.get(property))
65 }
66
67 fn timestamp_millis(&self, property: &str) -> Option<u64> {
68 match self.binding.values.get(property)? {
69 Value::Int(millis) => u64::try_from(*millis).ok(),
70 _ => None,
71 }
72 }
73}
74
75pub fn reference_time_millis() -> u64 {
78 hawdb_core::time::SystemTime::now()
79 .duration_since(hawdb_core::time::UNIX_EPOCH)
80 .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
81 .unwrap_or(0)
82}
83
84#[cfg(test)]
85mod tests {
86 use super::BindingScoreFeatures;
87 use crate::binding::Binding;
88 use hawdb_core::graph_rag::{ScoreFeature, ScoringFeatureSource, ScoringSpec, ScoringTerm};
89 use hawdb_core::Value;
90 use std::collections::BTreeMap;
91
92 fn binding(values: impl IntoIterator<Item = (&'static str, Value)>) -> Binding {
93 Binding {
94 values: values
95 .into_iter()
96 .map(|(name, value)| (name.to_string(), value))
97 .collect::<BTreeMap<_, _>>(),
98 nodes: BTreeMap::new(),
99 relationships: BTreeMap::new(),
100 }
101 }
102
103 #[test]
104 fn reads_the_declared_score_column_and_properties() {
105 let row = binding([
106 ("score", Value::Float(0.25)),
107 ("pagerank", Value::Int(65_000)),
108 ("updated_at", Value::Int(1_000)),
109 ("title", Value::String("not numeric".to_string())),
110 ]);
111 let features = BindingScoreFeatures::new(&row, "score");
112
113 assert_eq!(features.search_score(), Some(0.25));
114 assert_eq!(features.numeric_property("pagerank"), Some(65_000.0));
115 assert_eq!(features.timestamp_millis("updated_at"), Some(1_000));
116 assert_eq!(features.numeric_property("title"), None);
117 assert_eq!(features.graph_seed_score(), None);
118 assert_eq!(features.hop_distance(), None);
119
120 let spec = ScoringSpec {
121 terms: vec![
122 ScoringTerm {
123 weight: 2.0,
124 feature: ScoreFeature::SearchScore,
125 },
126 ScoringTerm {
127 weight: 1.0,
128 feature: ScoreFeature::NodeProperty("pagerank".to_string()),
129 },
130 ],
131 decay: Vec::new(),
132 };
133 let evaluation = spec.evaluate(&features, 0);
134 assert_eq!(evaluation.combined_score, 2.0 * 0.25 + 65_000.0);
135 }
136
137 #[test]
138 fn absent_and_non_numeric_features_report_instead_of_scoring_zero() {
139 let row = binding([
140 ("score", Value::Float(0.5)),
141 ("title", Value::String("text".to_string())),
142 ("negative_time", Value::Int(-5)),
143 ]);
144 let features = BindingScoreFeatures::new(&row, "score");
145 assert_eq!(features.numeric_property("absent"), None);
146 assert_eq!(features.numeric_property("title"), None);
147 assert_eq!(features.timestamp_millis("negative_time"), None);
148
149 let spec = ScoringSpec {
150 terms: vec![ScoringTerm {
151 weight: 1.0,
152 feature: ScoreFeature::NodeProperty("absent".to_string()),
153 }],
154 decay: Vec::new(),
155 };
156 let evaluation = spec.evaluate(&features, 0);
157 assert_eq!(evaluation.combined_score, 0.0);
158 assert_eq!(
159 evaluation.missing_features,
160 vec![ScoreFeature::NodeProperty("absent".to_string())]
161 );
162 }
163
164 #[test]
165 fn a_missing_score_column_reports_missing_instead_of_zero() {
166 let row = binding([("id", Value::String("row".to_string()))]);
167 let features = BindingScoreFeatures::new(&row, "score");
168 assert_eq!(features.search_score(), None);
169
170 let spec = ScoringSpec {
171 terms: vec![ScoringTerm {
172 weight: 1.0,
173 feature: ScoreFeature::SearchScore,
174 }],
175 decay: Vec::new(),
176 };
177 let evaluation = spec.evaluate(&features, 0);
178 assert!(evaluation
179 .missing_features
180 .contains(&ScoreFeature::SearchScore));
181 }
182
183 #[test]
184 fn rerank_keeps_the_best_rows_with_a_deterministic_tie_break() {
185 let row = |value: i64| Binding {
186 values: BTreeMap::from([("value".to_string(), Value::Int(value))]),
187 nodes: BTreeMap::new(),
188 relationships: BTreeMap::new(),
189 };
190 let mut retained = vec![
191 (1.0, 0usize, row(0)),
192 (3.0, 1, row(1)),
193 (3.0, 2, row(2)),
194 (2.0, 3, row(3)),
195 ];
196 crate::transform::retain_best_scored(&mut retained, 3);
197 let order: Vec<usize> = retained.iter().map(|(_, order, _)| *order).collect();
198 assert_eq!(order, vec![1, 2, 3]);
200 assert_eq!(retained.len(), 3);
201
202 crate::transform::retain_best_scored(&mut retained, 0);
203 assert!(retained.is_empty());
204 }
205}