Skip to main content

hawdb_core/graph_rag/
scoring_program.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
15use super::{ScoreFeature, ScoringEvaluation, ScoringFeatureSource, ScoringSpec, ScoringSpecError};
16use std::fmt;
17
18/// Composition of the ordered terms, before the shared decay factors.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub enum ScoringCombination {
21    /// `sum(weight * value)`, preserving the existing ScoringSpec arithmetic.
22    WeightedSum,
23    /// `product(value.powf(weight))`. A zero weight contributes one.
24    /// Negative bases with fractional exponents fail with `NonFiniteScore`.
25    WeightedProduct,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum MissingScoringFeature {
30    /// Fail rather than silently ranking an incomplete set of declared signals.
31    /// One missing signal fails the entire scored request, including OPTIONAL NULLs.
32    Reject,
33    /// Missing sum terms contribute zero; product terms and decays contribute one.
34    /// A missing timestamp therefore receives no age penalty, like a future timestamp.
35    Neutral,
36}
37
38/// Validated reusable scoring template with coefficients supplied by the host.
39///
40/// Unlike the legacy sum-only ScoringSpec, a program explicitly declares both
41/// composition and missing-signal behavior. Time is an execution input, never
42/// captured while constructing or caching a program.
43#[derive(Debug, Clone, PartialEq)]
44pub struct ScoringProgram {
45    combination: ScoringCombination,
46    missing: MissingScoringFeature,
47    spec: ScoringSpec,
48}
49
50/// Typed cache identity, deliberately excluding coefficients and request time.
51/// Strings remain individual fields; punctuation cannot collide across terms.
52#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
53pub struct ScoringProgramShape {
54    version: u32,
55    combination: ScoringCombination,
56    missing: MissingScoringFeature,
57    terms: Vec<ScoreFeature>,
58    decay: Vec<ScoreFeature>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum ScoringProgramError {
63    InvalidSpecification(ScoringSpecError),
64    MissingFeature(ScoreFeature),
65    NonFiniteScore,
66}
67
68impl fmt::Display for ScoringProgramError {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::InvalidSpecification(error) => error.fmt(formatter),
72            Self::MissingFeature(feature) => {
73                write!(formatter, "missing declared scoring feature: {feature:?}")
74            }
75            Self::NonFiniteScore => {
76                formatter.write_str("combined scoring arithmetic is not finite")
77            }
78        }
79    }
80}
81
82impl std::error::Error for ScoringProgramError {}
83
84impl ScoringProgram {
85    pub fn new(
86        combination: ScoringCombination,
87        missing: MissingScoringFeature,
88        spec: ScoringSpec,
89    ) -> Result<Self, ScoringProgramError> {
90        spec.validate()
91            .map_err(ScoringProgramError::InvalidSpecification)?;
92        Ok(Self {
93            combination,
94            missing,
95            spec,
96        })
97    }
98
99    pub fn specification(&self) -> &ScoringSpec {
100        &self.spec
101    }
102
103    pub fn shape(&self) -> ScoringProgramShape {
104        ScoringProgramShape {
105            version: 1,
106            combination: self.combination,
107            missing: self.missing,
108            terms: self
109                .spec
110                .terms
111                .iter()
112                .map(|term| term.feature.clone())
113                .collect(),
114            decay: self
115                .spec
116                .decay
117                .iter()
118                .map(|term| term.feature.clone())
119                .collect(),
120        }
121    }
122
123    /// A cache template retains no previous request's coefficients.
124    pub fn neutral_template(&self) -> Self {
125        let mut template = self.clone();
126        for term in &mut template.spec.terms {
127            term.weight = 1.0;
128        }
129        for decay in &mut template.spec.decay {
130            decay.half_life = 1.0;
131            decay.min_factor = 0.0;
132        }
133        template
134    }
135
136    pub fn evaluate_score(
137        &self,
138        source: &impl ScoringFeatureSource,
139        reference_time_millis: u64,
140    ) -> Result<f64, ScoringProgramError> {
141        let mut missing = None;
142        let score = self.spec.evaluate_into(
143            source,
144            reference_time_millis,
145            self.combination,
146            &mut |_| {},
147            &mut |_| {},
148            &mut |feature: &ScoreFeature| {
149                if self.missing == MissingScoringFeature::Reject && missing.is_none() {
150                    missing = Some(feature.clone());
151                }
152            },
153        );
154        self.check_result(score, missing.as_ref())
155    }
156
157    pub fn evaluate(
158        &self,
159        source: &impl ScoringFeatureSource,
160        reference_time_millis: u64,
161    ) -> Result<ScoringEvaluation, ScoringProgramError> {
162        let mut evaluation = ScoringEvaluation {
163            combined_score: 0.0,
164            term_contributions: Vec::with_capacity(self.spec.terms.len()),
165            decay_factors: Vec::with_capacity(self.spec.decay.len()),
166            missing_features: Vec::new(),
167        };
168        evaluation.combined_score = self.spec.evaluate_into(
169            source,
170            reference_time_millis,
171            self.combination,
172            &mut |value| evaluation.term_contributions.push(value),
173            &mut |value| evaluation.decay_factors.push(value),
174            &mut |feature: &ScoreFeature| evaluation.missing_features.push(feature.clone()),
175        );
176        self.check_result(
177            evaluation.combined_score,
178            evaluation.missing_features.first(),
179        )?;
180        Ok(evaluation)
181    }
182
183    fn check_result(
184        &self,
185        score: f64,
186        missing: Option<&ScoreFeature>,
187    ) -> Result<f64, ScoringProgramError> {
188        if self.missing == MissingScoringFeature::Reject
189            && let Some(feature) = missing
190        {
191            return Err(ScoringProgramError::MissingFeature(feature.clone()));
192        }
193        if !score.is_finite() {
194            return Err(ScoringProgramError::NonFiniteScore);
195        }
196        Ok(score)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::graph_rag::{DecayTerm, ScoringTerm};
204
205    struct Features(f64);
206    impl ScoringFeatureSource for Features {
207        fn search_score(&self) -> Option<f64> {
208            Some(self.0)
209        }
210        fn graph_seed_score(&self) -> Option<f64> {
211            Some(0.8)
212        }
213        fn hop_distance(&self) -> Option<usize> {
214            Some(2)
215        }
216        fn numeric_property(&self, _: &str) -> Option<f64> {
217            None
218        }
219        fn timestamp_millis(&self, _: &str) -> Option<u64> {
220            Some(1_000)
221        }
222    }
223
224    fn spec() -> ScoringSpec {
225        ScoringSpec {
226            terms: vec![
227                ScoringTerm {
228                    feature: ScoreFeature::SearchScore,
229                    weight: 2.0,
230                },
231                ScoringTerm {
232                    feature: ScoreFeature::GraphSeedScore,
233                    weight: 1.0,
234                },
235            ],
236            decay: vec![DecayTerm {
237                feature: ScoreFeature::TimestampProperty("created".into()),
238                half_life: 1.0,
239                min_factor: 0.0,
240            }],
241        }
242    }
243
244    #[test]
245    fn product_and_sum_use_declared_weights_decay_and_execution_time() {
246        for (combination, expected) in [
247            (ScoringCombination::WeightedSum, 1.8),
248            (ScoringCombination::WeightedProduct, 0.2),
249        ] {
250            let specification = spec();
251            let program = ScoringProgram::new(
252                combination,
253                MissingScoringFeature::Reject,
254                specification.clone(),
255            )
256            .unwrap();
257            for (anchor, factor) in [(1_000, 1.0), (2_000, 0.5), (3_000, 0.25)] {
258                let scalar = program.evaluate_score(&Features(0.5), anchor).unwrap();
259                let diagnostic = program.evaluate(&Features(0.5), anchor).unwrap();
260                assert_eq!(scalar, expected * factor);
261                assert_eq!(scalar.to_bits(), diagnostic.combined_score.to_bits());
262                if combination == ScoringCombination::WeightedSum {
263                    assert_eq!(
264                        scalar.to_bits(),
265                        specification
266                            .evaluate_score(&Features(0.5), anchor)
267                            .to_bits()
268                    );
269                }
270            }
271        }
272    }
273
274    #[test]
275    fn structural_identity_excludes_numbers_but_keeps_composition_and_missing_policy() {
276        let original = ScoringProgram::new(
277            ScoringCombination::WeightedSum,
278            MissingScoringFeature::Reject,
279            spec(),
280        )
281        .unwrap();
282        let mut changed = spec();
283        changed.terms[0].weight = 7.0;
284        changed.decay[0].half_life = 50.0;
285        changed.decay[0].min_factor = 0.3;
286        let rebound = ScoringProgram::new(
287            ScoringCombination::WeightedSum,
288            MissingScoringFeature::Reject,
289            changed,
290        )
291        .unwrap();
292        assert_eq!(original.shape(), rebound.shape());
293        assert_eq!(original.neutral_template(), rebound.neutral_template());
294        assert_ne!(
295            original.shape(),
296            ScoringProgram::new(
297                ScoringCombination::WeightedProduct,
298                MissingScoringFeature::Reject,
299                spec()
300            )
301            .unwrap()
302            .shape()
303        );
304        assert_ne!(
305            original.shape(),
306            ScoringProgram::new(
307                ScoringCombination::WeightedSum,
308                MissingScoringFeature::Neutral,
309                spec()
310            )
311            .unwrap()
312            .shape()
313        );
314    }
315
316    #[test]
317    fn negative_product_bases_preserve_integer_powers_and_reject_fractional_powers() {
318        for (weight, expected) in [
319            (0.0, Ok(1.0)),
320            (0.5, Err(ScoringProgramError::NonFiniteScore)),
321            (1.0, Ok(-0.25)),
322            (2.0, Ok(0.0625)),
323        ] {
324            let program = ScoringProgram::new(
325                ScoringCombination::WeightedProduct,
326                MissingScoringFeature::Reject,
327                ScoringSpec {
328                    terms: vec![ScoringTerm {
329                        feature: ScoreFeature::SearchScore,
330                        weight,
331                    }],
332                    decay: Vec::new(),
333                },
334            )
335            .unwrap();
336            assert_eq!(program.evaluate_score(&Features(-0.25), 1_000), expected);
337            assert_eq!(
338                program
339                    .evaluate(&Features(-0.25), 1_000)
340                    .map(|evaluation| evaluation.combined_score),
341                expected
342            );
343        }
344    }
345
346    #[test]
347    fn missing_features_are_explicit_and_nonfinite_products_fail() {
348        let mut specification = spec();
349        specification.terms[0].feature = ScoreFeature::NodeProperty("absent".into());
350        let strict = ScoringProgram::new(
351            ScoringCombination::WeightedProduct,
352            MissingScoringFeature::Reject,
353            specification.clone(),
354        )
355        .unwrap();
356        assert_eq!(
357            strict.evaluate_score(&Features(0.5), 1_000),
358            Err(ScoringProgramError::MissingFeature(
359                ScoreFeature::NodeProperty("absent".into())
360            ))
361        );
362        let neutral = ScoringProgram::new(
363            ScoringCombination::WeightedProduct,
364            MissingScoringFeature::Neutral,
365            specification,
366        )
367        .unwrap();
368        assert_eq!(neutral.evaluate_score(&Features(0.5), 1_000).unwrap(), 0.8);
369        let mut specification = spec();
370        specification.terms[1].weight = f64::MAX;
371        specification.terms[0].weight = f64::MAX;
372        // Finite inputs can underflow safely; overflow is checked separately.
373        let program = ScoringProgram::new(
374            ScoringCombination::WeightedProduct,
375            MissingScoringFeature::Reject,
376            specification,
377        )
378        .unwrap();
379        assert_eq!(program.evaluate_score(&Features(0.5), 1_000).unwrap(), 0.0);
380        assert_eq!(
381            program.evaluate_score(&Features(2.0), 1_000),
382            Err(ScoringProgramError::NonFiniteScore)
383        );
384    }
385}