1use super::{ScoreFeature, ScoringEvaluation, ScoringFeatureSource, ScoringSpec, ScoringSpecError};
16use std::fmt;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub enum ScoringCombination {
21 WeightedSum,
23 WeightedProduct,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum MissingScoringFeature {
30 Reject,
33 Neutral,
36}
37
38#[derive(Debug, Clone, PartialEq)]
44pub struct ScoringProgram {
45 combination: ScoringCombination,
46 missing: MissingScoringFeature,
47 spec: ScoringSpec,
48}
49
50#[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 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 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}