Skip to main content

hawdb_executor/
scoring.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Row-level feature source for the typed scoring contract.
16
17use crate::binding::Binding;
18use hawdb_core::graph_rag::ScoringFeatureSource;
19use hawdb_core::Value;
20
21/// Reads scoring features from one row.
22///
23/// The search score comes from the declared score column the seed stage
24/// produced; property features come from row values of the same name, which is
25/// how node and relationship properties reach a row. Features this stage cannot
26/// supply — the graph-seed score and the hop distance — are reported absent so
27/// the specification records them instead of scoring them as zero.
28pub 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
75/// Wall clock the specification ages timestamp features against, in epoch
76/// milliseconds.
77pub 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        // Highest score first, and equal scores keep their input order.
199        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}