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