1use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(tag = "type", rename_all = "snake_case")]
26pub enum AdjustmentCondition {
27 MemoryType { kind: String },
29 AgeRange {
31 #[serde(default)]
32 min_days: Option<f32>,
33 #[serde(default)]
34 max_days: Option<f32>,
35 },
36 SalienceRange {
38 #[serde(default)]
39 min: Option<f32>,
40 #[serde(default)]
41 max: Option<f32>,
42 },
43 EntityMatch,
45 EntityMiss,
47 All {
49 conditions: Vec<AdjustmentCondition>,
50 },
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(tag = "type", rename_all = "snake_case")]
56pub enum AdjustmentOp {
57 Add { value: f32 },
59 Subtract { value: f32 },
61 Multiply { factor: f32 },
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ScoreAdjustment {
68 pub condition: AdjustmentCondition,
69 pub operation: AdjustmentOp,
70}
71
72pub struct CandidateContext<'a> {
74 pub memory_type: &'a str,
75 pub age_days: f32,
76 pub salience: f32,
77 pub content: &'a str,
78 pub entity_names: &'a [String],
79}
80
81impl AdjustmentCondition {
82 pub fn matches(&self, ctx: &CandidateContext<'_>) -> bool {
84 match self {
85 Self::MemoryType { kind } => ctx.memory_type == kind.as_str(),
86 Self::AgeRange { min_days, max_days } => {
87 if let Some(min) = min_days {
88 if ctx.age_days < *min {
89 return false;
90 }
91 }
92 if let Some(max) = max_days {
93 if ctx.age_days > *max {
94 return false;
95 }
96 }
97 true
98 }
99 Self::SalienceRange { min, max } => {
100 if let Some(lo) = min {
101 if ctx.salience < *lo {
102 return false;
103 }
104 }
105 if let Some(hi) = max {
106 if ctx.salience > *hi {
107 return false;
108 }
109 }
110 true
111 }
112 Self::EntityMatch => {
113 if ctx.entity_names.is_empty() {
114 return false;
115 }
116 let lower = ctx.content.to_lowercase();
117 ctx.entity_names.iter().any(|e| lower.contains(e.as_str()))
118 }
119 Self::EntityMiss => {
120 if ctx.entity_names.is_empty() {
121 return false;
122 }
123 let lower = ctx.content.to_lowercase();
124 !ctx.entity_names.iter().any(|e| lower.contains(e.as_str()))
125 }
126 Self::All { conditions } => conditions.iter().all(|c| c.matches(ctx)),
127 }
128 }
129}
130
131impl AdjustmentOp {
132 pub fn apply(&self, score: f32) -> f32 {
134 match self {
135 Self::Add { value } => score + value,
136 Self::Subtract { value } => score - value,
137 Self::Multiply { factor } => score * factor,
138 }
139 }
140}
141
142impl ScoreAdjustment {
143 pub fn apply(&self, score: f32, ctx: &CandidateContext<'_>) -> f32 {
145 if self.condition.matches(ctx) {
146 self.operation.apply(score)
147 } else {
148 score
149 }
150 }
151}
152
153pub fn default_adjustments() -> Vec<ScoreAdjustment> {
155 vec![
156 ScoreAdjustment {
158 condition: AdjustmentCondition::All {
159 conditions: vec![
160 AdjustmentCondition::MemoryType {
161 kind: "episodic".into(),
162 },
163 AdjustmentCondition::AgeRange {
164 min_days: None,
165 max_days: Some(7.0),
166 },
167 ],
168 },
169 operation: AdjustmentOp::Add { value: 0.05 },
170 },
171 ScoreAdjustment {
174 condition: AdjustmentCondition::All {
175 conditions: vec![
176 AdjustmentCondition::MemoryType {
177 kind: "semantic".into(),
178 },
179 AdjustmentCondition::AgeRange {
180 min_days: Some(30.0),
181 max_days: None,
182 },
183 AdjustmentCondition::SalienceRange {
184 min: Some(0.85),
185 max: None,
186 },
187 ],
188 },
189 operation: AdjustmentOp::Subtract { value: 0.05 },
190 },
191 ScoreAdjustment {
193 condition: AdjustmentCondition::EntityMatch,
194 operation: AdjustmentOp::Multiply { factor: 1.3 },
195 },
196 ]
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(default)]
204pub struct ScoringWeights {
205 pub salience: f32,
207 pub temporal: f32,
209 pub relevance: f32,
211}
212
213impl Default for ScoringWeights {
214 fn default() -> Self {
215 Self {
216 salience: 0.2,
217 temporal: 0.1,
218 relevance: 0.7,
219 }
220 }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(default)]
229pub struct ScoringConfig {
230 pub weights: ScoringWeights,
232
233 pub min_raw_relevance: f32,
237 pub min_rrf_relevance: f32,
239 pub baseline_relevance: f32,
241
242 pub decay_cap: f32,
246
247 pub max_recall_candidates: usize,
250 pub default_recall_limit: usize,
253 pub default_token_budget: usize,
255 pub chars_per_token: usize,
257
258 pub mmr_penalty: f32,
262 pub mmr_prefix_len: usize,
264
265 pub enable_supersedes_suppression: bool,
269 pub enable_multilingual_routing: bool,
273 pub multilingual_model: Option<String>,
277
278 pub adjustments: Vec<ScoreAdjustment>,
282}
283
284impl Default for ScoringConfig {
285 fn default() -> Self {
286 Self {
287 weights: ScoringWeights::default(),
288
289 min_raw_relevance: 0.10,
290 min_rrf_relevance: 0.0,
291 baseline_relevance: 0.15,
292
293 decay_cap: 0.05,
294
295 max_recall_candidates: 200,
296 default_recall_limit: 10,
297 default_token_budget: 4000,
298 chars_per_token: 4,
299
300 mmr_penalty: 0.1,
301 mmr_prefix_len: 100,
302
303 enable_supersedes_suppression: true,
304 enable_multilingual_routing: true,
305 multilingual_model: None,
306
307 adjustments: default_adjustments(),
308 }
309 }
310}
311
312pub const MAX_RECALL_CANDIDATES: usize = 500;
316pub const MAX_TOKEN_BUDGET: usize = 16_000;
318pub const MAX_RECALL_LIMIT: usize = 200;
320
321impl ScoringConfig {
322 pub fn apply_dos_caps(&mut self) {
327 self.max_recall_candidates = self.max_recall_candidates.min(MAX_RECALL_CANDIDATES);
328 self.default_token_budget = self.default_token_budget.min(MAX_TOKEN_BUDGET);
329 self.default_recall_limit = self.default_recall_limit.min(MAX_RECALL_LIMIT);
330 }
331}
332
333#[inline]
338pub fn is_cjk_char(c: char) -> bool {
339 matches!(c,
340 '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{20000}'..='\u{2A6DF}' | '\u{AC00}'..='\u{D7AF}' )
348}
349
350pub fn contains_cjk(text: &str) -> bool {
352 let chars: Vec<char> = text.chars().collect();
353 if chars.is_empty() {
354 return false;
355 }
356 let cjk = chars.iter().filter(|&&c| is_cjk_char(c)).count();
357 (cjk as f32) / (chars.len() as f32) > 0.15
358}
359
360pub fn needs_multilingual(text: &str) -> bool {
377 let alpha_chars: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
378 if alpha_chars.is_empty() {
379 return false;
380 }
381 let non_ascii_alpha = alpha_chars
382 .iter()
383 .filter(|&&c| !c.is_ascii_alphabetic())
384 .count();
385 (non_ascii_alpha as f32) / (alpha_chars.len() as f32) > 0.15
386}
387
388pub fn normalize_min_score(score: f64) -> Result<f32, crate::config::MinScoreError> {
390 if !score.is_finite() {
391 return Err(crate::config::MinScoreError::NotFinite);
392 }
393 if (0.0..=1.0).contains(&score) {
394 return Ok(score as f32);
395 }
396 if (1.0..=100.0).contains(&score) {
397 return Ok((score / 100.0) as f32);
398 }
399 Err(crate::config::MinScoreError::OutOfRange(score))
400}
401
402pub fn is_meaningful_query(query: &str) -> bool {
404 let trimmed = query.trim();
405 if trimmed.is_empty() {
406 return false;
407 }
408
409 let is_alpha_or_cjk = |c: char| c.is_alphabetic() || is_cjk_char(c);
410
411 let meaningful_chars: usize = trimmed.chars().filter(|c| is_alpha_or_cjk(*c)).count();
412 if meaningful_chars == 0 {
413 return false;
414 }
415
416 let cjk_chars: usize = trimmed.chars().filter(|c| is_cjk_char(*c)).count();
417 if meaningful_chars < 2 && cjk_chars == 0 {
418 return false;
419 }
420
421 let words: Vec<&str> = trimmed
423 .split_whitespace()
424 .filter(|w| w.chars().any(is_alpha_or_cjk))
425 .collect();
426 if !words.is_empty() {
427 let all_repeated = words.iter().all(|w| {
428 let chars: Vec<char> = w.chars().filter(|c| is_alpha_or_cjk(*c)).collect();
429 if chars.len() <= 2 {
430 return false;
431 }
432 let unique: std::collections::HashSet<char> = chars
433 .iter()
434 .map(|c| c.to_lowercase().next().unwrap_or(*c))
435 .collect();
436 (unique.len() as f32) / (chars.len() as f32) < 0.4
437 });
438 if all_repeated {
439 return false;
440 }
441 }
442
443 true
444}
445
446const NORMALIZED_RELEVANCE_CEILING: f32 = 0.82;
453
454const RRF_SIGNAL_THRESHOLD: f32 = 0.025;
457
458pub fn normalize_rank_fusion_scores(
460 scores: Vec<(Uuid, f32)>,
461 config: &ScoringConfig,
462) -> HashMap<Uuid, f32> {
463 if scores.is_empty() {
464 return HashMap::new();
465 }
466 let min_rrf = config.min_rrf_relevance;
467 let filtered: Vec<(Uuid, f32)> = scores
468 .into_iter()
469 .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
470 .collect();
471 if filtered.is_empty() {
472 return HashMap::new();
473 }
474 let max_score = filtered
475 .iter()
476 .map(|(_, s)| *s)
477 .fold(f32::NEG_INFINITY, f32::max);
478 if !max_score.is_finite() || max_score <= 0.0 {
479 return HashMap::new();
480 }
481 let min_score_seen = filtered
482 .iter()
483 .map(|(_, s)| *s)
484 .fold(f32::INFINITY, f32::min);
485 let span = max_score - min_score_seen;
486 let floor = config
487 .baseline_relevance
488 .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
489 let range = NORMALIZED_RELEVANCE_CEILING - floor;
490
491 let signal_strength = (max_score / RRF_SIGNAL_THRESHOLD).min(1.0);
492
493 filtered
494 .into_iter()
495 .map(|(id, score)| {
496 let calibrated = if span <= f32::EPSILON {
497 max_score.clamp(floor, NORMALIZED_RELEVANCE_CEILING)
498 } else {
499 let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
500 floor + percentile * range
501 };
502 (id, calibrated * signal_strength)
503 })
504 .collect()
505}
506
507pub fn normalize_rrf_scores(
509 scores: Vec<(Uuid, f32)>,
510 config: &ScoringConfig,
511) -> HashMap<Uuid, f32> {
512 if scores.is_empty() {
513 return HashMap::new();
514 }
515 let min_rrf = config.min_rrf_relevance;
516 let filtered: Vec<(Uuid, f32)> = scores
517 .into_iter()
518 .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
519 .collect();
520 if filtered.is_empty() {
521 return HashMap::new();
522 }
523 let max_score = filtered
524 .iter()
525 .map(|(_, s)| *s)
526 .fold(f32::NEG_INFINITY, f32::max);
527 if !max_score.is_finite() || max_score <= 0.0 {
528 return HashMap::new();
529 }
530 let min_score_seen = filtered
531 .iter()
532 .map(|(_, s)| *s)
533 .fold(f32::INFINITY, f32::min);
534 let span = max_score - min_score_seen;
535 let floor = config
536 .baseline_relevance
537 .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
538 let range = NORMALIZED_RELEVANCE_CEILING - floor;
539
540 filtered
541 .into_iter()
542 .map(|(id, score)| {
543 let calibrated = if span <= f32::EPSILON {
544 floor + range
545 } else {
546 let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
547 floor + percentile * range
548 };
549 (id, calibrated)
550 })
551 .collect()
552}
553
554pub struct ScoreInput<'a> {
559 pub salience: f32,
560 pub memory_type_str: &'a str,
561 pub content: &'a str,
562 pub created_at_millis: i64,
563 pub decay_factor: f32,
564 pub now_millis: i64,
565 pub relevance_score: f32,
566 pub entity_names: &'a [String],
567}
568
569pub fn calculate_score(input: &ScoreInput<'_>, config: &ScoringConfig) -> f32 {
577 let w = &config.weights;
578 let semantic_base = w.relevance * input.relevance_score;
579
580 let time_diff_days = ((input.now_millis - input.created_at_millis) as f32
581 / (24.0 * 60.0 * 60.0 * 1000.0))
582 .max(0.0);
583
584 let capped_decay = input.decay_factor.min(config.decay_cap);
585 let temporal_recency = (-capped_decay * time_diff_days).exp();
586
587 let temporal_boost = 1.0 + w.temporal * temporal_recency;
588 let salience_boost = 1.0 + w.salience * input.salience;
589
590 let mut score = semantic_base * temporal_boost * salience_boost;
591
592 let ctx = CandidateContext {
593 memory_type: input.memory_type_str,
594 age_days: time_diff_days,
595 salience: input.salience,
596 content: input.content,
597 entity_names: input.entity_names,
598 };
599
600 for adj in &config.adjustments {
601 score = adj.apply(score, &ctx);
602 }
603
604 score.clamp(0.0, 1.0)
605}
606
607#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn is_meaningful_query_rejects_empty() {
615 assert!(!is_meaningful_query(""));
616 assert!(!is_meaningful_query(" "));
617 }
618
619 #[test]
620 fn is_meaningful_query_rejects_symbols_only() {
621 assert!(!is_meaningful_query("!@#$%"));
622 assert!(!is_meaningful_query("..."));
623 }
624
625 #[test]
626 fn is_meaningful_query_rejects_single_latin_char() {
627 assert!(!is_meaningful_query("a"));
628 assert!(!is_meaningful_query("Z"));
629 }
630
631 #[test]
632 fn is_meaningful_query_rejects_repeated_gibberish() {
633 assert!(!is_meaningful_query("aaaa bbbb cccc"));
634 }
635
636 #[test]
637 fn is_meaningful_query_accepts_normal_queries() {
638 assert!(is_meaningful_query("what is the capital of France"));
639 assert!(is_meaningful_query("rust async runtime"));
640 assert!(is_meaningful_query("hello"));
641 }
642
643 #[test]
644 fn contains_cjk_detects_chinese() {
645 assert!(contains_cjk("你好世界"));
647 assert!(contains_cjk("世界 hi"));
649 assert!(!contains_cjk("hello 世界 world"));
651 }
652
653 #[test]
654 fn contains_cjk_ignores_latin() {
655 assert!(!contains_cjk("hello world"));
656 assert!(!contains_cjk(""));
657 }
658
659 #[test]
660 fn normalize_min_score_fraction_passthrough() {
661 let v = normalize_min_score(0.5).unwrap();
662 assert!((v - 0.5f32).abs() < 1e-6);
663 }
664
665 #[test]
666 fn normalize_min_score_percent_form() {
667 let v = normalize_min_score(50.0).unwrap();
668 assert!((v - 0.5f32).abs() < 1e-6);
669 }
670
671 #[test]
672 fn normalize_min_score_rejects_out_of_range() {
673 assert!(normalize_min_score(200.0).is_err());
674 assert!(normalize_min_score(-1.0).is_err());
675 assert!(normalize_min_score(f64::NAN).is_err());
676 }
677
678 #[test]
679 fn calculate_score_returns_unit_interval() {
680 let config = ScoringConfig::default();
681 let score = calculate_score(
682 &ScoreInput {
683 salience: 0.9,
684 memory_type_str: "episodic",
685 content: "test content",
686 created_at_millis: 0,
687 decay_factor: 0.01,
688 now_millis: 1000,
689 relevance_score: 0.8,
690 entity_names: &[],
691 },
692 &config,
693 );
694 assert!((0.0..=1.0).contains(&score), "score {score} out of [0,1]");
695 }
696
697 #[test]
698 fn calculate_score_high_salience_ranks_higher() {
699 let config = ScoringConfig {
700 adjustments: vec![],
701 ..ScoringConfig::default()
702 };
703 let now_ms = 1_000_000i64;
704 let score_high = calculate_score(
705 &ScoreInput {
706 salience: 0.9,
707 memory_type_str: "episodic",
708 content: "content",
709 created_at_millis: 0,
710 decay_factor: 0.01,
711 now_millis: now_ms,
712 relevance_score: 0.7,
713 entity_names: &[],
714 },
715 &config,
716 );
717 let score_low = calculate_score(
718 &ScoreInput {
719 salience: 0.1,
720 memory_type_str: "episodic",
721 content: "content",
722 created_at_millis: 0,
723 decay_factor: 0.01,
724 now_millis: now_ms,
725 relevance_score: 0.7,
726 entity_names: &[],
727 },
728 &config,
729 );
730 assert!(score_high > score_low, "high salience should rank higher");
731 }
732
733 #[test]
734 fn dos_caps_enforce_limits() {
735 let mut config = ScoringConfig {
736 max_recall_candidates: 9999,
737 default_token_budget: 99999,
738 default_recall_limit: 9999,
739 ..ScoringConfig::default()
740 };
741 config.apply_dos_caps();
742 assert_eq!(config.max_recall_candidates, MAX_RECALL_CANDIDATES);
743 assert_eq!(config.default_token_budget, MAX_TOKEN_BUDGET);
744 assert_eq!(config.default_recall_limit, MAX_RECALL_LIMIT);
745 }
746
747 #[test]
748 fn normalize_rrf_scores_preserves_ordering() {
749 let config = ScoringConfig::default();
750 let input = vec![
751 (Uuid::new_v4(), 0.030f32),
752 (Uuid::new_v4(), 0.020f32),
753 (Uuid::new_v4(), 0.015f32),
754 ];
755 let ids: Vec<Uuid> = input.iter().map(|(id, _)| *id).collect();
756 let output = normalize_rrf_scores(input, &config);
757 let s0 = output[&ids[0]];
758 let s1 = output[&ids[1]];
759 let s2 = output[&ids[2]];
760 assert!(s0 > s1 && s1 > s2, "ordering must be preserved");
761 assert!(s0 <= 1.0 && s2 >= 0.0, "scores must be in [0,1]");
762 }
763
764 #[test]
767 fn needs_multilingual_ascii_english_routes_primary() {
768 assert!(!needs_multilingual("hello world"));
769 assert!(!needs_multilingual("rust programming language"));
770 assert!(!needs_multilingual(""));
771 assert!(!needs_multilingual("42 items in the list"));
772 }
773
774 #[test]
775 fn needs_multilingual_cjk_routes_multilingual() {
776 assert!(needs_multilingual("你好世界"));
778 assert!(needs_multilingual("abc你好"));
780 }
781
782 #[test]
783 fn needs_multilingual_cyrillic_routes_multilingual() {
784 assert!(needs_multilingual("Привет мир"));
786 assert!(needs_multilingual("Привет hello")); }
790
791 #[test]
792 fn needs_multilingual_arabic_routes_multilingual() {
793 assert!(needs_multilingual("مرحبا بالعالم"));
795 }
796
797 #[test]
798 fn needs_multilingual_devanagari_routes_multilingual() {
799 assert!(needs_multilingual("नमस्ते दुनिया"));
801 }
802
803 #[test]
804 fn needs_multilingual_accented_latin_routes_multilingual() {
805 assert!(needs_multilingual("café résumé"));
808 assert!(needs_multilingual("Müller"));
810 assert!(needs_multilingual("niño"));
812 }
813
814 #[test]
815 fn needs_multilingual_alphabetic_denominator_is_stable_under_punctuation() {
816 assert!(needs_multilingual("Müller"));
819 assert!(needs_multilingual("Müller?"));
821 assert!(needs_multilingual("Müller!!!"));
823 assert!(needs_multilingual("42 Ü"));
825 assert!(!needs_multilingual(""));
827 assert!(!needs_multilingual("42 100 ???"));
828 }
829
830 #[test]
831 fn needs_multilingual_pure_ascii_accented_below_threshold_routes_primary() {
832 assert!(!needs_multilingual("hello naive")); assert!(needs_multilingual("über"));
838 }
839
840 #[test]
841 fn needs_multilingual_ascii_only_non_english_latin_routes_primary_known_limitation() {
842 assert!(!needs_multilingual("bonjour le monde"));
845 assert!(!needs_multilingual("como estas"));
846 assert!(!needs_multilingual("ich suche einen buchhalter"));
847 }
848}