Skip to main content

hawdb_core/graph_rag/
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//! Host-injectable scoring for graph-augmented retrieval.
16//!
17//! The spec is defined here, in the lowest crate that both the query engine and
18//! the host-facing contracts can depend on, because the scoring operator runs in
19//! the executor while the request contract lives above the search crate.
20
21use super::ScoringCombination;
22use std::fmt;
23
24/// Typed, host-injectable rerank scoring.
25///
26/// A spec is a weighted sum of features multiplied by exponential decay
27/// factors. The engine evaluates it one candidate at a time; a plan carries
28/// only [`ScoringSpec::shape_fingerprint`], so changing weights never
29/// invalidates a cached plan.
30#[derive(Debug, Clone, PartialEq)]
31pub struct ScoringSpec {
32    pub terms: Vec<ScoringTerm>,
33    pub decay: Vec<DecayTerm>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub struct ScoringTerm {
38    pub weight: f64,
39    pub feature: ScoreFeature,
40}
41
42/// Exponential decay `0.5^(age / half_life)` over a distance or timestamp
43/// feature, clamped to `[min_factor, 1]` and multiplied into the combined
44/// score.
45#[derive(Debug, Clone, PartialEq)]
46pub struct DecayTerm {
47    pub feature: ScoreFeature,
48    pub half_life: f64,
49    pub min_factor: f64,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
53pub enum ScoreFeature {
54    /// Ranking score returned by the search projection.
55    SearchScore,
56    /// Graph-side seed score for the same canonical node.
57    GraphSeedScore,
58    /// Bounded graph distance from the seed (`0` for the seed itself).
59    HopDistance,
60    /// Numeric canonical node property.
61    NodeProperty(String),
62    /// Canonical timestamp property, aged against the request clock.
63    TimestampProperty(String),
64}
65
66/// Feature values the engine can supply for one candidate.
67pub trait ScoringFeatureSource {
68    fn search_score(&self) -> Option<f64>;
69    fn graph_seed_score(&self) -> Option<f64>;
70    fn hop_distance(&self) -> Option<usize>;
71    fn numeric_property(&self, property: &str) -> Option<f64>;
72    fn timestamp_millis(&self, property: &str) -> Option<u64>;
73
74    /// Borrowed, explicitly returned metadata for a host's cohort policy, such
75    /// as an exact reason/tie key. It does not establish retrieval provenance.
76    fn returned_value(&self, _column: &str) -> Option<&crate::Value> {
77        None
78    }
79}
80
81/// One candidate's evaluation, carrying per-term provenance.
82#[derive(Debug, Clone, PartialEq)]
83pub struct ScoringEvaluation {
84    pub combined_score: f64,
85    /// Contribution of each [`ScoringSpec::terms`] entry, in spec order.
86    pub term_contributions: Vec<f64>,
87    /// Factor of each [`ScoringSpec::decay`] entry, in spec order.
88    pub decay_factors: Vec<f64>,
89    /// Features the source could not supply; they neither add nor multiply.
90    pub missing_features: Vec<ScoreFeature>,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum ScoringSpecError {
95    NoTerms,
96    NonFiniteWeight,
97    NegativeWeight,
98    NonPositiveHalfLife,
99    InvalidMinFactor,
100    /// A timestamp feature carries no rank value of its own.
101    TimestampTermNotAllowed,
102    /// Only `HopDistance` and `TimestampProperty` define an age to decay.
103    UnsupportedDecayFeature,
104}
105
106impl fmt::Display for ScoringSpecError {
107    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108        let text = match self {
109            Self::NoTerms => "a scoring spec needs at least one term",
110            Self::NonFiniteWeight => "scoring term weights must be finite",
111            Self::NegativeWeight => "scoring term weights must not be negative",
112            Self::NonPositiveHalfLife => "decay half-lives must be positive and finite",
113            Self::InvalidMinFactor => "decay floors must lie in [0, 1]",
114            Self::TimestampTermNotAllowed => {
115                "a timestamp property is a decay feature, not an additive term"
116            }
117            Self::UnsupportedDecayFeature => {
118                "only hop distance and timestamp properties define an age to decay"
119            }
120        };
121        formatter.write_str(text)
122    }
123}
124
125impl std::error::Error for ScoringSpecError {}
126
127impl ScoringSpec {
128    /// Weighted sum over the two retriever scores.
129    pub fn weighted_scores(search_weight: f64, graph_seed_weight: f64) -> Self {
130        Self {
131            terms: vec![
132                ScoringTerm {
133                    weight: search_weight,
134                    feature: ScoreFeature::SearchScore,
135                },
136                ScoringTerm {
137                    weight: graph_seed_weight,
138                    feature: ScoreFeature::GraphSeedScore,
139                },
140            ],
141            decay: Vec::new(),
142        }
143    }
144
145    pub fn validate(&self) -> Result<(), ScoringSpecError> {
146        if self.terms.is_empty() {
147            return Err(ScoringSpecError::NoTerms);
148        }
149        for term in &self.terms {
150            if !term.weight.is_finite() {
151                return Err(ScoringSpecError::NonFiniteWeight);
152            }
153            if term.weight < 0.0 {
154                return Err(ScoringSpecError::NegativeWeight);
155            }
156            if matches!(term.feature, ScoreFeature::TimestampProperty(_)) {
157                return Err(ScoringSpecError::TimestampTermNotAllowed);
158            }
159        }
160        for decay in &self.decay {
161            if !decay.half_life.is_finite() || decay.half_life <= 0.0 {
162                return Err(ScoringSpecError::NonPositiveHalfLife);
163            }
164            if !decay.min_factor.is_finite() || !(0.0..=1.0).contains(&decay.min_factor) {
165                return Err(ScoringSpecError::InvalidMinFactor);
166            }
167            if !matches!(
168                decay.feature,
169                ScoreFeature::HopDistance | ScoreFeature::TimestampProperty(_)
170            ) {
171                return Err(ScoringSpecError::UnsupportedDecayFeature);
172            }
173        }
174        Ok(())
175    }
176
177    /// Feature shape without weights: the identity that belongs in a plan
178    /// fingerprint or cache key.
179    pub fn shape_fingerprint(&self) -> String {
180        let terms = self
181            .terms
182            .iter()
183            .map(|term| score_feature_name(&term.feature))
184            .collect::<Vec<_>>()
185            .join(",");
186        let decay = self
187            .decay
188            .iter()
189            .map(|decay| score_feature_name(&decay.feature))
190            .collect::<Vec<_>>()
191            .join(",");
192        format!("terms=[{terms}] decay=[{decay}]")
193    }
194
195    /// Whether this spec reads canonical node properties. Callers use it to
196    /// avoid loading a node record for specs that only need retriever scores.
197    pub fn needs_canonical_node_properties(&self) -> bool {
198        self.terms
199            .iter()
200            .any(|term| matches!(term.feature, ScoreFeature::NodeProperty(_)))
201            || self.decay.iter().any(|decay| {
202                matches!(
203                    decay.feature,
204                    ScoreFeature::NodeProperty(_) | ScoreFeature::TimestampProperty(_)
205                )
206            })
207    }
208
209    /// Evaluates one candidate. Missing features contribute nothing and are
210    /// reported instead of silently ranking as zero-valued hits.
211    pub fn evaluate(
212        &self,
213        source: &impl ScoringFeatureSource,
214        reference_time_millis: u64,
215    ) -> ScoringEvaluation {
216        let mut missing_features = Vec::new();
217        let mut term_contributions = Vec::with_capacity(self.terms.len());
218        let mut decay_factors = Vec::with_capacity(self.decay.len());
219        let combined_score = self.evaluate_into(
220            source,
221            reference_time_millis,
222            ScoringCombination::WeightedSum,
223            &mut |value| term_contributions.push(value),
224            &mut |value| decay_factors.push(value),
225            &mut |feature: &ScoreFeature| missing_features.push(feature.clone()),
226        );
227        ScoringEvaluation {
228            combined_score,
229            term_contributions,
230            decay_factors,
231            missing_features,
232        }
233    }
234
235    /// Evaluates the same score without allocating a per-row diagnostic report.
236    ///
237    /// Validate the specification before execution. Missing features retain
238    /// the same neutral behavior as [`Self::evaluate`]; use that method when
239    /// the caller needs feature provenance and individual contributions.
240    pub fn evaluate_score(
241        &self,
242        source: &impl ScoringFeatureSource,
243        reference_time_millis: u64,
244    ) -> f64 {
245        self.evaluate_into(
246            source,
247            reference_time_millis,
248            ScoringCombination::WeightedSum,
249            &mut |_| {},
250            &mut |_| {},
251            &mut |_| {},
252        )
253    }
254
255    pub(super) fn evaluate_into(
256        &self,
257        source: &impl ScoringFeatureSource,
258        reference_time_millis: u64,
259        combination: ScoringCombination,
260        term_contribution: &mut impl FnMut(f64),
261        decay_factor: &mut impl FnMut(f64),
262        missing_feature: &mut impl FnMut(&ScoreFeature),
263    ) -> f64 {
264        let mut combined_score = match combination {
265            ScoringCombination::WeightedSum => 0.0,
266            ScoringCombination::WeightedProduct => 1.0,
267        };
268        for term in &self.terms {
269            let value = match self.term_value(&term.feature, source) {
270                Some(value) => value,
271                None => {
272                    missing_feature(&term.feature);
273                    match combination {
274                        ScoringCombination::WeightedSum => 0.0,
275                        ScoringCombination::WeightedProduct => {
276                            term_contribution(1.0);
277                            continue;
278                        }
279                    }
280                }
281            };
282            let contribution = match combination {
283                ScoringCombination::WeightedSum => term.weight * value,
284                ScoringCombination::WeightedProduct => value.powf(term.weight),
285            };
286            match combination {
287                ScoringCombination::WeightedSum => combined_score += contribution,
288                ScoringCombination::WeightedProduct => combined_score *= contribution,
289            }
290            term_contribution(contribution);
291        }
292        for decay in &self.decay {
293            let age = match self.decay_age(&decay.feature, source, reference_time_millis) {
294                Some(age) => age,
295                None => {
296                    missing_feature(&decay.feature);
297                    decay_factor(1.0);
298                    continue;
299                }
300            };
301            let factor = 0.5f64
302                .powf(age / decay.half_life)
303                .clamp(decay.min_factor, 1.0);
304            combined_score *= factor;
305            decay_factor(factor);
306        }
307        combined_score
308    }
309
310    fn term_value(
311        &self,
312        feature: &ScoreFeature,
313        source: &impl ScoringFeatureSource,
314    ) -> Option<f64> {
315        match feature {
316            ScoreFeature::SearchScore => source.search_score().filter(|v| v.is_finite()),
317            ScoreFeature::GraphSeedScore => source.graph_seed_score().filter(|v| v.is_finite()),
318            ScoreFeature::HopDistance => source
319                .hop_distance()
320                .map(|hops| u32::try_from(hops).map_or(f64::from(u32::MAX), f64::from)),
321            ScoreFeature::NodeProperty(property) => {
322                source.numeric_property(property).filter(|v| v.is_finite())
323            }
324            // Decay-only features carry no additive value.
325            ScoreFeature::TimestampProperty(_) => None,
326        }
327    }
328
329    fn decay_age(
330        &self,
331        feature: &ScoreFeature,
332        source: &impl ScoringFeatureSource,
333        reference_time_millis: u64,
334    ) -> Option<f64> {
335        match feature {
336            ScoreFeature::HopDistance => source.hop_distance().map(|hops| hops as f64),
337            ScoreFeature::TimestampProperty(property) => {
338                let timestamp = source.timestamp_millis(property)?;
339                let age_millis = reference_time_millis.saturating_sub(timestamp);
340                Some(age_millis as f64 / 1_000.0)
341            }
342            _ => None,
343        }
344    }
345}
346
347fn score_feature_name(feature: &ScoreFeature) -> String {
348    match feature {
349        ScoreFeature::SearchScore => "search_score".to_string(),
350        ScoreFeature::GraphSeedScore => "graph_seed_score".to_string(),
351        ScoreFeature::HopDistance => "hop_distance".to_string(),
352        ScoreFeature::NodeProperty(property) => format!("node_property:{property}"),
353        ScoreFeature::TimestampProperty(property) => format!("timestamp:{property}"),
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::{
360        DecayTerm, ScoreFeature, ScoringFeatureSource, ScoringSpec, ScoringSpecError, ScoringTerm,
361    };
362    use std::collections::BTreeMap;
363
364    #[derive(Default)]
365    struct StubFeatures {
366        search_score: Option<f64>,
367        graph_seed_score: Option<f64>,
368        hop_distance: Option<usize>,
369        numeric: BTreeMap<String, f64>,
370        timestamps: BTreeMap<String, u64>,
371    }
372
373    impl ScoringFeatureSource for StubFeatures {
374        fn search_score(&self) -> Option<f64> {
375            self.search_score
376        }
377
378        fn graph_seed_score(&self) -> Option<f64> {
379            self.graph_seed_score
380        }
381
382        fn hop_distance(&self) -> Option<usize> {
383            self.hop_distance
384        }
385
386        fn numeric_property(&self, property: &str) -> Option<f64> {
387            self.numeric.get(property).copied()
388        }
389
390        fn timestamp_millis(&self, property: &str) -> Option<u64> {
391            self.timestamps.get(property).copied()
392        }
393    }
394
395    fn term(weight: f64, feature: ScoreFeature) -> ScoringTerm {
396        ScoringTerm { weight, feature }
397    }
398
399    fn decay(feature: ScoreFeature, half_life: f64, min_factor: f64) -> DecayTerm {
400        DecayTerm {
401            feature,
402            half_life,
403            min_factor,
404        }
405    }
406
407    #[test]
408    fn scoring_spec_combines_weighted_features() {
409        let spec = ScoringSpec {
410            terms: vec![
411                term(0.5, ScoreFeature::SearchScore),
412                term(2.0, ScoreFeature::NodeProperty("pagerank".to_string())),
413            ],
414            decay: Vec::new(),
415        };
416        let source = StubFeatures {
417            search_score: Some(4.0),
418            numeric: BTreeMap::from([("pagerank".to_string(), 3.0)]),
419            ..StubFeatures::default()
420        };
421        let evaluation = spec.evaluate(&source, 0);
422        assert_eq!(evaluation.combined_score, 0.5 * 4.0 + 2.0 * 3.0);
423        assert_eq!(evaluation.term_contributions, vec![2.0, 6.0]);
424        assert!(evaluation.missing_features.is_empty());
425        assert_eq!(spec.evaluate_score(&source, 0), evaluation.combined_score);
426    }
427
428    #[test]
429    fn scoring_spec_reports_missing_features_instead_of_ranking_them_zero() {
430        let spec = ScoringSpec {
431            terms: vec![
432                term(1.0, ScoreFeature::SearchScore),
433                term(1.0, ScoreFeature::GraphSeedScore),
434            ],
435            decay: Vec::new(),
436        };
437        let source = StubFeatures {
438            search_score: Some(2.0),
439            ..StubFeatures::default()
440        };
441        let evaluation = spec.evaluate(&source, 0);
442        assert_eq!(evaluation.combined_score, 2.0);
443        assert_eq!(spec.evaluate_score(&source, 0), evaluation.combined_score);
444        assert_eq!(
445            evaluation.missing_features,
446            vec![ScoreFeature::GraphSeedScore]
447        );
448    }
449
450    #[test]
451    fn hop_decay_multiplies_and_holds_its_floor() {
452        let spec = ScoringSpec {
453            terms: vec![term(1.0, ScoreFeature::SearchScore)],
454            decay: vec![decay(ScoreFeature::HopDistance, 1.0, 0.25)],
455        };
456        let evaluate = |hops: usize| {
457            spec.evaluate(
458                &StubFeatures {
459                    search_score: Some(8.0),
460                    hop_distance: Some(hops),
461                    ..StubFeatures::default()
462                },
463                0,
464            )
465        };
466        assert_eq!(evaluate(0).combined_score, 8.0);
467        assert_eq!(evaluate(1).combined_score, 4.0);
468        assert_eq!(evaluate(2).combined_score, 2.0);
469        assert_eq!(evaluate(9).combined_score, 2.0);
470        assert_eq!(evaluate(2).decay_factors, vec![0.25]);
471    }
472
473    #[test]
474    fn timestamp_decay_ages_against_the_request_clock() {
475        let spec = ScoringSpec {
476            terms: vec![term(1.0, ScoreFeature::SearchScore)],
477            decay: vec![decay(
478                ScoreFeature::TimestampProperty("updated_at".to_string()),
479                600.0,
480                0.0,
481            )],
482        };
483        let aged = StubFeatures {
484            search_score: Some(10.0),
485            timestamps: BTreeMap::from([("updated_at".to_string(), 400_000)]),
486            ..StubFeatures::default()
487        };
488        // 600 seconds old against a 600-second half-life.
489        assert_eq!(spec.evaluate(&aged, 1_000_000).combined_score, 5.0);
490        let future = StubFeatures {
491            search_score: Some(10.0),
492            timestamps: BTreeMap::from([("updated_at".to_string(), 2_000_000)]),
493            ..StubFeatures::default()
494        };
495        assert_eq!(spec.evaluate(&future, 1_000_000).combined_score, 10.0);
496    }
497
498    #[test]
499    fn scoring_spec_validation_rejects_unusable_shapes() {
500        let missing = StubFeatures::default();
501        assert_eq!(
502            ScoringSpec {
503                terms: Vec::new(),
504                decay: Vec::new(),
505            }
506            .validate(),
507            Err(ScoringSpecError::NoTerms)
508        );
509        assert_eq!(
510            ScoringSpec {
511                terms: vec![term(-1.0, ScoreFeature::SearchScore)],
512                decay: Vec::new(),
513            }
514            .validate(),
515            Err(ScoringSpecError::NegativeWeight)
516        );
517        assert_eq!(
518            ScoringSpec {
519                terms: vec![term(f64::NAN, ScoreFeature::SearchScore)],
520                decay: Vec::new(),
521            }
522            .validate(),
523            Err(ScoringSpecError::NonFiniteWeight)
524        );
525        assert_eq!(
526            ScoringSpec {
527                terms: vec![term(1.0, ScoreFeature::SearchScore)],
528                decay: vec![decay(ScoreFeature::HopDistance, 0.0, 0.0)],
529            }
530            .validate(),
531            Err(ScoringSpecError::NonPositiveHalfLife)
532        );
533        assert_eq!(
534            ScoringSpec {
535                terms: vec![term(1.0, ScoreFeature::SearchScore)],
536                decay: vec![decay(ScoreFeature::HopDistance, 1.0, 1.5)],
537            }
538            .validate(),
539            Err(ScoringSpecError::InvalidMinFactor)
540        );
541        assert_eq!(
542            ScoringSpec {
543                terms: vec![term(1.0, ScoreFeature::TimestampProperty("t".to_string()))],
544                decay: Vec::new(),
545            }
546            .validate(),
547            Err(ScoringSpecError::TimestampTermNotAllowed)
548        );
549        assert_eq!(
550            ScoringSpec {
551                terms: vec![term(1.0, ScoreFeature::SearchScore)],
552                decay: vec![decay(ScoreFeature::SearchScore, 1.0, 0.0)],
553            }
554            .validate(),
555            Err(ScoringSpecError::UnsupportedDecayFeature)
556        );
557        assert!(ScoringSpec::weighted_scores(1.0, 1.0).validate().is_ok());
558        // A property that is merely absent at runtime is valid, and reported
559        // through `missing_features` instead of failing the request.
560        assert!(ScoringSpec {
561            terms: vec![term(1.0, ScoreFeature::NodeProperty("absent".to_string()))],
562            decay: Vec::new(),
563        }
564        .validate()
565        .is_ok());
566        assert!(missing.numeric_property("absent").is_none());
567        assert!(ScoringSpecError::NoTerms
568            .to_string()
569            .contains("at least one term"));
570    }
571
572    #[test]
573    fn scoring_shape_fingerprint_ignores_weights() {
574        let lightness = ScoringSpec {
575            terms: vec![
576                term(0.1, ScoreFeature::SearchScore),
577                term(0.2, ScoreFeature::HopDistance),
578            ],
579            decay: vec![decay(ScoreFeature::HopDistance, 30.0, 0.1)],
580        };
581        let heavy = ScoringSpec {
582            terms: vec![
583                term(9.0, ScoreFeature::SearchScore),
584                term(5.0, ScoreFeature::HopDistance),
585            ],
586            decay: vec![decay(ScoreFeature::HopDistance, 1.0, 0.9)],
587        };
588        assert_eq!(lightness.shape_fingerprint(), heavy.shape_fingerprint());
589        assert!(!lightness.needs_canonical_node_properties());
590        let property = ScoringSpec {
591            terms: vec![term(
592                1.0,
593                ScoreFeature::NodeProperty("pagerank".to_string()),
594            )],
595            decay: Vec::new(),
596        };
597        assert!(property.needs_canonical_node_properties());
598    }
599}