1use std::collections::HashMap;
16
17use serde::{Deserialize, Serialize};
18use uuid::Uuid;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum AdjustmentCondition {
26 MemoryType { kind: String },
28 AgeRange {
30 #[serde(default)]
31 min_days: Option<f32>,
32 #[serde(default)]
33 max_days: Option<f32>,
34 },
35 SalienceRange {
37 #[serde(default)]
38 min: Option<f32>,
39 #[serde(default)]
40 max: Option<f32>,
41 },
42 EntityMatch,
44 EntityMiss,
46 All {
48 conditions: Vec<AdjustmentCondition>,
49 },
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum AdjustmentOp {
56 Add { value: f32 },
58 Subtract { value: f32 },
60 Multiply { factor: f32 },
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ScoreAdjustment {
67 pub condition: AdjustmentCondition,
68 pub operation: AdjustmentOp,
69}
70
71pub struct CandidateContext<'a> {
73 pub memory_type: &'a str,
74 pub age_days: f32,
75 pub salience: f32,
76 pub content: &'a str,
77 pub entity_names: &'a [String],
78}
79
80impl AdjustmentCondition {
81 pub fn matches(&self, ctx: &CandidateContext<'_>) -> bool {
83 match self {
84 Self::MemoryType { kind } => ctx.memory_type == kind.as_str(),
85 Self::AgeRange { min_days, max_days } => {
86 if let Some(min) = min_days {
87 if ctx.age_days < *min {
88 return false;
89 }
90 }
91 if let Some(max) = max_days {
92 if ctx.age_days > *max {
93 return false;
94 }
95 }
96 true
97 }
98 Self::SalienceRange { min, max } => {
99 if let Some(lo) = min {
100 if ctx.salience < *lo {
101 return false;
102 }
103 }
104 if let Some(hi) = max {
105 if ctx.salience > *hi {
106 return false;
107 }
108 }
109 true
110 }
111 Self::EntityMatch => {
112 if ctx.entity_names.is_empty() {
113 return false;
114 }
115 let lower = ctx.content.to_lowercase();
116 ctx.entity_names.iter().any(|e| lower.contains(e.as_str()))
117 }
118 Self::EntityMiss => {
119 if ctx.entity_names.is_empty() {
120 return false;
121 }
122 let lower = ctx.content.to_lowercase();
123 !ctx.entity_names.iter().any(|e| lower.contains(e.as_str()))
124 }
125 Self::All { conditions } => conditions.iter().all(|c| c.matches(ctx)),
126 }
127 }
128}
129
130impl AdjustmentOp {
131 pub fn apply(&self, score: f32) -> f32 {
133 match self {
134 Self::Add { value } => score + value,
135 Self::Subtract { value } => score - value,
136 Self::Multiply { factor } => score * factor,
137 }
138 }
139}
140
141impl ScoreAdjustment {
142 pub fn apply(&self, score: f32, ctx: &CandidateContext<'_>) -> f32 {
144 if self.condition.matches(ctx) {
145 self.operation.apply(score)
146 } else {
147 score
148 }
149 }
150}
151
152pub fn default_adjustments() -> Vec<ScoreAdjustment> {
154 vec![
155 ScoreAdjustment {
157 condition: AdjustmentCondition::All {
158 conditions: vec![
159 AdjustmentCondition::MemoryType {
160 kind: "episodic".into(),
161 },
162 AdjustmentCondition::AgeRange {
163 min_days: None,
164 max_days: Some(7.0),
165 },
166 ],
167 },
168 operation: AdjustmentOp::Add { value: 0.05 },
169 },
170 ScoreAdjustment {
173 condition: AdjustmentCondition::All {
174 conditions: vec![
175 AdjustmentCondition::MemoryType {
176 kind: "semantic".into(),
177 },
178 AdjustmentCondition::AgeRange {
179 min_days: Some(30.0),
180 max_days: None,
181 },
182 AdjustmentCondition::SalienceRange {
183 min: Some(0.85),
184 max: None,
185 },
186 ],
187 },
188 operation: AdjustmentOp::Subtract { value: 0.05 },
189 },
190 ScoreAdjustment {
192 condition: AdjustmentCondition::EntityMatch,
193 operation: AdjustmentOp::Multiply { factor: 1.3 },
194 },
195 ]
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(default)]
203pub struct ScoringWeights {
204 pub salience: f32,
206 pub temporal: f32,
208 pub relevance: f32,
210}
211
212impl Default for ScoringWeights {
213 fn default() -> Self {
214 Self {
215 salience: 0.2,
216 temporal: 0.1,
217 relevance: 0.7,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(default)]
228pub struct ScoringConfig {
229 pub weights: ScoringWeights,
231
232 pub min_raw_relevance: f32,
236 pub min_rrf_relevance: f32,
238 pub baseline_relevance: f32,
240
241 pub decay_cap: f32,
245
246 pub max_recall_candidates: usize,
249 pub default_recall_limit: usize,
252 pub default_token_budget: usize,
254 pub chars_per_token: usize,
256
257 pub mmr_penalty: f32,
261 pub mmr_prefix_len: usize,
263
264 pub enable_supersedes_suppression: bool,
268 pub enable_cjk_routing: bool,
271 pub cjk_model: Option<String>,
275
276 pub adjustments: Vec<ScoreAdjustment>,
280}
281
282impl Default for ScoringConfig {
283 fn default() -> Self {
284 Self {
285 weights: ScoringWeights::default(),
286
287 min_raw_relevance: 0.10,
288 min_rrf_relevance: 0.0,
289 baseline_relevance: 0.15,
290
291 decay_cap: 0.05,
292
293 max_recall_candidates: 200,
294 default_recall_limit: 10,
295 default_token_budget: 4000,
296 chars_per_token: 4,
297
298 mmr_penalty: 0.1,
299 mmr_prefix_len: 100,
300
301 enable_supersedes_suppression: true,
302 enable_cjk_routing: true,
303 cjk_model: None,
304
305 adjustments: default_adjustments(),
306 }
307 }
308}
309
310pub const MAX_RECALL_CANDIDATES: usize = 500;
314pub const MAX_TOKEN_BUDGET: usize = 16_000;
316pub const MAX_RECALL_LIMIT: usize = 200;
318
319impl ScoringConfig {
320 pub fn apply_dos_caps(&mut self) {
325 self.max_recall_candidates = self.max_recall_candidates.min(MAX_RECALL_CANDIDATES);
326 self.default_token_budget = self.default_token_budget.min(MAX_TOKEN_BUDGET);
327 self.default_recall_limit = self.default_recall_limit.min(MAX_RECALL_LIMIT);
328 }
329}
330
331#[inline]
336pub fn is_cjk_char(c: char) -> bool {
337 matches!(c,
338 '\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}' )
346}
347
348pub fn contains_cjk(text: &str) -> bool {
350 let chars: Vec<char> = text.chars().collect();
351 if chars.is_empty() {
352 return false;
353 }
354 let cjk = chars.iter().filter(|&&c| is_cjk_char(c)).count();
355 (cjk as f32) / (chars.len() as f32) > 0.15
356}
357
358pub fn normalize_min_score(score: f64) -> Result<f32, crate::config::MinScoreError> {
360 if !score.is_finite() {
361 return Err(crate::config::MinScoreError::NotFinite);
362 }
363 if (0.0..=1.0).contains(&score) {
364 return Ok(score as f32);
365 }
366 if (1.0..=100.0).contains(&score) {
367 return Ok((score / 100.0) as f32);
368 }
369 Err(crate::config::MinScoreError::OutOfRange(score))
370}
371
372pub fn is_meaningful_query(query: &str) -> bool {
374 let trimmed = query.trim();
375 if trimmed.is_empty() {
376 return false;
377 }
378
379 let is_alpha_or_cjk = |c: char| c.is_alphabetic() || is_cjk_char(c);
380
381 let meaningful_chars: usize = trimmed.chars().filter(|c| is_alpha_or_cjk(*c)).count();
382 if meaningful_chars == 0 {
383 return false;
384 }
385
386 let cjk_chars: usize = trimmed.chars().filter(|c| is_cjk_char(*c)).count();
387 if meaningful_chars < 2 && cjk_chars == 0 {
388 return false;
389 }
390
391 let words: Vec<&str> = trimmed
393 .split_whitespace()
394 .filter(|w| w.chars().any(is_alpha_or_cjk))
395 .collect();
396 if !words.is_empty() {
397 let all_repeated = words.iter().all(|w| {
398 let chars: Vec<char> = w.chars().filter(|c| is_alpha_or_cjk(*c)).collect();
399 if chars.len() <= 2 {
400 return false;
401 }
402 let unique: std::collections::HashSet<char> = chars
403 .iter()
404 .map(|c| c.to_lowercase().next().unwrap_or(*c))
405 .collect();
406 (unique.len() as f32) / (chars.len() as f32) < 0.4
407 });
408 if all_repeated {
409 return false;
410 }
411 }
412
413 true
414}
415
416const NORMALIZED_RELEVANCE_CEILING: f32 = 0.82;
423
424const RRF_SIGNAL_THRESHOLD: f32 = 0.025;
427
428pub fn normalize_rank_fusion_scores(
430 scores: Vec<(Uuid, f32)>,
431 config: &ScoringConfig,
432) -> HashMap<Uuid, f32> {
433 if scores.is_empty() {
434 return HashMap::new();
435 }
436 let min_rrf = config.min_rrf_relevance;
437 let filtered: Vec<(Uuid, f32)> = scores
438 .into_iter()
439 .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
440 .collect();
441 if filtered.is_empty() {
442 return HashMap::new();
443 }
444 let max_score = filtered
445 .iter()
446 .map(|(_, s)| *s)
447 .fold(f32::NEG_INFINITY, f32::max);
448 if !max_score.is_finite() || max_score <= 0.0 {
449 return HashMap::new();
450 }
451 let min_score_seen = filtered
452 .iter()
453 .map(|(_, s)| *s)
454 .fold(f32::INFINITY, f32::min);
455 let span = max_score - min_score_seen;
456 let floor = config
457 .baseline_relevance
458 .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
459 let range = NORMALIZED_RELEVANCE_CEILING - floor;
460
461 let signal_strength = (max_score / RRF_SIGNAL_THRESHOLD).min(1.0);
462
463 filtered
464 .into_iter()
465 .map(|(id, score)| {
466 let calibrated = if span <= f32::EPSILON {
467 max_score.clamp(floor, NORMALIZED_RELEVANCE_CEILING)
468 } else {
469 let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
470 floor + percentile * range
471 };
472 (id, calibrated * signal_strength)
473 })
474 .collect()
475}
476
477pub fn normalize_rrf_scores(
479 scores: Vec<(Uuid, f32)>,
480 config: &ScoringConfig,
481) -> HashMap<Uuid, f32> {
482 if scores.is_empty() {
483 return HashMap::new();
484 }
485 let min_rrf = config.min_rrf_relevance;
486 let filtered: Vec<(Uuid, f32)> = scores
487 .into_iter()
488 .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
489 .collect();
490 if filtered.is_empty() {
491 return HashMap::new();
492 }
493 let max_score = filtered
494 .iter()
495 .map(|(_, s)| *s)
496 .fold(f32::NEG_INFINITY, f32::max);
497 if !max_score.is_finite() || max_score <= 0.0 {
498 return HashMap::new();
499 }
500 let min_score_seen = filtered
501 .iter()
502 .map(|(_, s)| *s)
503 .fold(f32::INFINITY, f32::min);
504 let span = max_score - min_score_seen;
505 let floor = config
506 .baseline_relevance
507 .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
508 let range = NORMALIZED_RELEVANCE_CEILING - floor;
509
510 filtered
511 .into_iter()
512 .map(|(id, score)| {
513 let calibrated = if span <= f32::EPSILON {
514 floor + range
515 } else {
516 let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
517 floor + percentile * range
518 };
519 (id, calibrated)
520 })
521 .collect()
522}
523
524pub struct ScoreInput<'a> {
529 pub salience: f32,
530 pub memory_type_str: &'a str,
531 pub content: &'a str,
532 pub created_at_millis: i64,
533 pub decay_factor: f32,
534 pub now_millis: i64,
535 pub relevance_score: f32,
536 pub entity_names: &'a [String],
537}
538
539pub fn calculate_score(input: &ScoreInput<'_>, config: &ScoringConfig) -> f32 {
547 let w = &config.weights;
548 let semantic_base = w.relevance * input.relevance_score;
549
550 let time_diff_days = ((input.now_millis - input.created_at_millis) as f32
551 / (24.0 * 60.0 * 60.0 * 1000.0))
552 .max(0.0);
553
554 let capped_decay = input.decay_factor.min(config.decay_cap);
555 let temporal_recency = (-capped_decay * time_diff_days).exp();
556
557 let temporal_boost = 1.0 + w.temporal * temporal_recency;
558 let salience_boost = 1.0 + w.salience * input.salience;
559
560 let mut score = semantic_base * temporal_boost * salience_boost;
561
562 let ctx = CandidateContext {
563 memory_type: input.memory_type_str,
564 age_days: time_diff_days,
565 salience: input.salience,
566 content: input.content,
567 entity_names: input.entity_names,
568 };
569
570 for adj in &config.adjustments {
571 score = adj.apply(score, &ctx);
572 }
573
574 score.clamp(0.0, 1.0)
575}
576
577#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn is_meaningful_query_rejects_empty() {
585 assert!(!is_meaningful_query(""));
586 assert!(!is_meaningful_query(" "));
587 }
588
589 #[test]
590 fn is_meaningful_query_rejects_symbols_only() {
591 assert!(!is_meaningful_query("!@#$%"));
592 assert!(!is_meaningful_query("..."));
593 }
594
595 #[test]
596 fn is_meaningful_query_rejects_single_latin_char() {
597 assert!(!is_meaningful_query("a"));
598 assert!(!is_meaningful_query("Z"));
599 }
600
601 #[test]
602 fn is_meaningful_query_rejects_repeated_gibberish() {
603 assert!(!is_meaningful_query("aaaa bbbb cccc"));
604 }
605
606 #[test]
607 fn is_meaningful_query_accepts_normal_queries() {
608 assert!(is_meaningful_query("what is the capital of France"));
609 assert!(is_meaningful_query("rust async runtime"));
610 assert!(is_meaningful_query("hello"));
611 }
612
613 #[test]
614 fn contains_cjk_detects_chinese() {
615 assert!(contains_cjk("你好世界"));
617 assert!(contains_cjk("世界 hi"));
619 assert!(!contains_cjk("hello 世界 world"));
621 }
622
623 #[test]
624 fn contains_cjk_ignores_latin() {
625 assert!(!contains_cjk("hello world"));
626 assert!(!contains_cjk(""));
627 }
628
629 #[test]
630 fn normalize_min_score_fraction_passthrough() {
631 let v = normalize_min_score(0.5).unwrap();
632 assert!((v - 0.5f32).abs() < 1e-6);
633 }
634
635 #[test]
636 fn normalize_min_score_percent_form() {
637 let v = normalize_min_score(50.0).unwrap();
638 assert!((v - 0.5f32).abs() < 1e-6);
639 }
640
641 #[test]
642 fn normalize_min_score_rejects_out_of_range() {
643 assert!(normalize_min_score(200.0).is_err());
644 assert!(normalize_min_score(-1.0).is_err());
645 assert!(normalize_min_score(f64::NAN).is_err());
646 }
647
648 #[test]
649 fn calculate_score_returns_unit_interval() {
650 let config = ScoringConfig::default();
651 let score = calculate_score(
652 &ScoreInput {
653 salience: 0.9,
654 memory_type_str: "episodic",
655 content: "test content",
656 created_at_millis: 0,
657 decay_factor: 0.01,
658 now_millis: 1000,
659 relevance_score: 0.8,
660 entity_names: &[],
661 },
662 &config,
663 );
664 assert!((0.0..=1.0).contains(&score), "score {score} out of [0,1]");
665 }
666
667 #[test]
668 fn calculate_score_high_salience_ranks_higher() {
669 let config = ScoringConfig {
670 adjustments: vec![],
671 ..ScoringConfig::default()
672 };
673 let now_ms = 1_000_000i64;
674 let score_high = calculate_score(
675 &ScoreInput {
676 salience: 0.9,
677 memory_type_str: "episodic",
678 content: "content",
679 created_at_millis: 0,
680 decay_factor: 0.01,
681 now_millis: now_ms,
682 relevance_score: 0.7,
683 entity_names: &[],
684 },
685 &config,
686 );
687 let score_low = calculate_score(
688 &ScoreInput {
689 salience: 0.1,
690 memory_type_str: "episodic",
691 content: "content",
692 created_at_millis: 0,
693 decay_factor: 0.01,
694 now_millis: now_ms,
695 relevance_score: 0.7,
696 entity_names: &[],
697 },
698 &config,
699 );
700 assert!(score_high > score_low, "high salience should rank higher");
701 }
702
703 #[test]
704 fn dos_caps_enforce_limits() {
705 let mut config = ScoringConfig {
706 max_recall_candidates: 9999,
707 default_token_budget: 99999,
708 default_recall_limit: 9999,
709 ..ScoringConfig::default()
710 };
711 config.apply_dos_caps();
712 assert_eq!(config.max_recall_candidates, MAX_RECALL_CANDIDATES);
713 assert_eq!(config.default_token_budget, MAX_TOKEN_BUDGET);
714 assert_eq!(config.default_recall_limit, MAX_RECALL_LIMIT);
715 }
716
717 #[test]
718 fn normalize_rrf_scores_preserves_ordering() {
719 let config = ScoringConfig::default();
720 let input = vec![
721 (Uuid::new_v4(), 0.030f32),
722 (Uuid::new_v4(), 0.020f32),
723 (Uuid::new_v4(), 0.015f32),
724 ];
725 let ids: Vec<Uuid> = input.iter().map(|(id, _)| *id).collect();
726 let output = normalize_rrf_scores(input, &config);
727 let s0 = output[&ids[0]];
728 let s1 = output[&ids[1]];
729 let s2 = output[&ids[2]];
730 assert!(s0 > s1 && s1 > s2, "ordering must be preserved");
731 assert!(s0 <= 1.0 && s2 >= 0.0, "scores must be in [0,1]");
732 }
733}