1use super::ScoringCombination;
22use std::fmt;
23
24#[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#[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 SearchScore,
56 GraphSeedScore,
58 HopDistance,
60 NodeProperty(String),
62 TimestampProperty(String),
64}
65
66pub 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 fn returned_value(&self, _column: &str) -> Option<&crate::Value> {
77 None
78 }
79}
80
81#[derive(Debug, Clone, PartialEq)]
83pub struct ScoringEvaluation {
84 pub combined_score: f64,
85 pub term_contributions: Vec<f64>,
87 pub decay_factors: Vec<f64>,
89 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 TimestampTermNotAllowed,
102 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 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 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 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 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 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 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 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 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}