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