1use std::collections::{HashMap, HashSet};
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
81fn contains_at_word_boundary(haystack: &str, needle: &str) -> bool {
98 if needle.is_empty() {
99 return false;
100 }
101 let haystack_chars: Vec<char> = haystack.chars().collect();
102 let needle_chars: Vec<char> = needle.chars().collect();
103 let n = needle_chars.len();
104 if n == 0 || haystack_chars.len() < n {
105 return false;
106 }
107 for start in 0..=(haystack_chars.len() - n) {
108 if haystack_chars[start..start + n] != needle_chars[..] {
109 continue;
110 }
111 let before_ok = start == 0 || !haystack_chars[start - 1].is_alphanumeric();
112 let after_idx = start + n;
113 let after_ok =
114 after_idx >= haystack_chars.len() || !haystack_chars[after_idx].is_alphanumeric();
115 if before_ok && after_ok {
116 return true;
117 }
118 }
119 false
120}
121
122impl AdjustmentCondition {
123 pub fn matches(&self, ctx: &CandidateContext<'_>) -> bool {
125 match self {
126 Self::MemoryType { kind } => ctx.memory_type == kind.as_str(),
127 Self::AgeRange { min_days, max_days } => {
128 if let Some(min) = min_days {
129 if ctx.age_days < *min {
130 return false;
131 }
132 }
133 if let Some(max) = max_days {
134 if ctx.age_days > *max {
135 return false;
136 }
137 }
138 true
139 }
140 Self::SalienceRange { min, max } => {
141 if let Some(lo) = min {
142 if ctx.salience < *lo {
143 return false;
144 }
145 }
146 if let Some(hi) = max {
147 if ctx.salience > *hi {
148 return false;
149 }
150 }
151 true
152 }
153 Self::EntityMatch => {
154 if ctx.entity_names.is_empty() {
155 return false;
156 }
157 let lower = ctx.content.to_lowercase();
158 ctx.entity_names
159 .iter()
160 .any(|e| contains_at_word_boundary(&lower, e))
161 }
162 Self::EntityMiss => {
163 if ctx.entity_names.is_empty() {
164 return false;
165 }
166 let lower = ctx.content.to_lowercase();
167 !ctx.entity_names
168 .iter()
169 .any(|e| contains_at_word_boundary(&lower, e))
170 }
171 Self::All { conditions } => conditions.iter().all(|c| c.matches(ctx)),
172 }
173 }
174}
175
176impl AdjustmentOp {
177 pub fn apply(&self, score: f32) -> f32 {
179 match self {
180 Self::Add { value } => score + value,
181 Self::Subtract { value } => score - value,
182 Self::Multiply { factor } => score * factor,
183 }
184 }
185}
186
187impl ScoreAdjustment {
188 pub fn apply(&self, score: f32, ctx: &CandidateContext<'_>) -> f32 {
190 if self.condition.matches(ctx) {
191 self.operation.apply(score)
192 } else {
193 score
194 }
195 }
196}
197
198pub fn default_adjustments() -> Vec<ScoreAdjustment> {
200 vec![
201 ScoreAdjustment {
203 condition: AdjustmentCondition::All {
204 conditions: vec![
205 AdjustmentCondition::MemoryType {
206 kind: "episodic".into(),
207 },
208 AdjustmentCondition::AgeRange {
209 min_days: None,
210 max_days: Some(7.0),
211 },
212 ],
213 },
214 operation: AdjustmentOp::Add { value: 0.05 },
215 },
216 ScoreAdjustment {
219 condition: AdjustmentCondition::All {
220 conditions: vec![
221 AdjustmentCondition::MemoryType {
222 kind: "semantic".into(),
223 },
224 AdjustmentCondition::AgeRange {
225 min_days: Some(30.0),
226 max_days: None,
227 },
228 AdjustmentCondition::SalienceRange {
229 min: Some(0.85),
230 max: None,
231 },
232 ],
233 },
234 operation: AdjustmentOp::Subtract { value: 0.05 },
235 },
236 ScoreAdjustment {
238 condition: AdjustmentCondition::EntityMatch,
239 operation: AdjustmentOp::Multiply { factor: 1.3 },
240 },
241 ]
242}
243
244const ENTITY_STOPWORDS: &[&str] = &[
253 "a", "an", "the", "and", "or", "but", "of", "in", "on", "at", "to", "for", "with", "by",
254 "from", "is", "are", "was", "were", "be", "been", "being", "this", "that", "these", "those",
255 "it", "its", "he", "she", "his", "her", "him", "they", "them", "their", "we", "us", "our",
256 "you", "your", "i", "my", "me", "as", "if", "so", "not", "no", "yes", "do", "does", "did",
257 "has", "have", "had", "will", "would", "can", "could", "should", "may", "might", "about",
258 "into", "than", "then", "there", "here", "what", "which", "who", "whom", "when", "where",
259 "why", "how",
260];
261
262pub const MAX_AUTO_ENTITY_NAMES: usize = 8;
270
271fn strip_token_punctuation(token: &str) -> &str {
275 token.trim_matches(|c: char| !c.is_alphanumeric())
276}
277
278pub fn extract_entity_candidates(query: &str) -> Vec<String> {
312 let mut seen: HashSet<String> = HashSet::new();
313 let mut out: Vec<String> = Vec::new();
314
315 for raw in query.split_whitespace() {
316 let stripped = strip_token_punctuation(raw);
317 if stripped.is_empty() {
318 continue;
319 }
320 if !stripped.chars().next().is_some_and(char::is_uppercase) {
321 continue;
322 }
323 let lower = stripped.to_lowercase();
324 if ENTITY_STOPWORDS.contains(&lower.as_str()) {
325 continue;
326 }
327 if seen.insert(lower.clone()) {
328 out.push(lower);
329 if out.len() >= MAX_AUTO_ENTITY_NAMES {
330 break;
331 }
332 }
333 }
334 out
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(default)]
342pub struct ScoringWeights {
343 pub salience: f32,
345 pub temporal: f32,
347 pub relevance: f32,
349}
350
351impl Default for ScoringWeights {
352 fn default() -> Self {
353 Self {
354 salience: 0.2,
355 temporal: 0.1,
356 relevance: 0.7,
357 }
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(default)]
367pub struct ScoringConfig {
368 pub weights: ScoringWeights,
370
371 pub min_raw_relevance: f32,
375 pub min_rrf_relevance: f32,
377 pub baseline_relevance: f32,
379
380 pub decay_cap: f32,
384
385 pub max_recall_candidates: usize,
388 pub default_recall_limit: usize,
391 pub default_token_budget: usize,
393 pub chars_per_token: usize,
395
396 pub mmr_penalty: f32,
400 pub mmr_prefix_len: usize,
402
403 pub enable_supersedes_suppression: bool,
407 pub enable_multilingual_routing: bool,
411 pub multilingual_model: Option<String>,
415
416 pub adjustments: Vec<ScoreAdjustment>,
420}
421
422impl Default for ScoringConfig {
423 fn default() -> Self {
424 Self {
425 weights: ScoringWeights::default(),
426
427 min_raw_relevance: 0.10,
428 min_rrf_relevance: 0.0,
429 baseline_relevance: 0.15,
430
431 decay_cap: 0.05,
432
433 max_recall_candidates: 200,
434 default_recall_limit: 10,
435 default_token_budget: 4000,
436 chars_per_token: 4,
437
438 mmr_penalty: 0.1,
439 mmr_prefix_len: 100,
440
441 enable_supersedes_suppression: true,
442 enable_multilingual_routing: true,
443 multilingual_model: None,
444
445 adjustments: default_adjustments(),
446 }
447 }
448}
449
450pub const MAX_RECALL_CANDIDATES: usize = 500;
454pub const MAX_TOKEN_BUDGET: usize = 16_000;
456pub const MAX_RECALL_LIMIT: usize = 200;
458
459impl ScoringConfig {
460 pub fn apply_dos_caps(&mut self) {
465 self.max_recall_candidates = self.max_recall_candidates.min(MAX_RECALL_CANDIDATES);
466 self.default_token_budget = self.default_token_budget.min(MAX_TOKEN_BUDGET);
467 self.default_recall_limit = self.default_recall_limit.min(MAX_RECALL_LIMIT);
468 }
469}
470
471#[inline]
476pub fn is_cjk_char(c: char) -> bool {
477 matches!(c,
478 '\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}' )
486}
487
488pub fn contains_cjk(text: &str) -> bool {
490 let chars: Vec<char> = text.chars().collect();
491 if chars.is_empty() {
492 return false;
493 }
494 let cjk = chars.iter().filter(|&&c| is_cjk_char(c)).count();
495 (cjk as f32) / (chars.len() as f32) > 0.15
496}
497
498pub fn needs_multilingual(text: &str) -> bool {
515 let alpha_chars: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
516 if alpha_chars.is_empty() {
517 return false;
518 }
519 let non_ascii_alpha = alpha_chars
520 .iter()
521 .filter(|&&c| !c.is_ascii_alphabetic())
522 .count();
523 (non_ascii_alpha as f32) / (alpha_chars.len() as f32) > 0.15
524}
525
526pub fn normalize_min_score(score: f64) -> Result<f32, crate::config::MinScoreError> {
528 if !score.is_finite() {
529 return Err(crate::config::MinScoreError::NotFinite);
530 }
531 if (0.0..=1.0).contains(&score) {
532 return Ok(score as f32);
533 }
534 if (1.0..=100.0).contains(&score) {
535 return Ok((score / 100.0) as f32);
536 }
537 Err(crate::config::MinScoreError::OutOfRange(score))
538}
539
540pub fn is_meaningful_query(query: &str) -> bool {
542 let trimmed = query.trim();
543 if trimmed.is_empty() {
544 return false;
545 }
546
547 let is_alpha_or_cjk = |c: char| c.is_alphabetic() || is_cjk_char(c);
548
549 let meaningful_chars: usize = trimmed.chars().filter(|c| is_alpha_or_cjk(*c)).count();
550 if meaningful_chars == 0 {
551 return false;
552 }
553
554 let cjk_chars: usize = trimmed.chars().filter(|c| is_cjk_char(*c)).count();
555 if meaningful_chars < 2 && cjk_chars == 0 {
556 return false;
557 }
558
559 let words: Vec<&str> = trimmed
561 .split_whitespace()
562 .filter(|w| w.chars().any(is_alpha_or_cjk))
563 .collect();
564 if !words.is_empty() {
565 let all_repeated = words.iter().all(|w| {
566 let chars: Vec<char> = w.chars().filter(|c| is_alpha_or_cjk(*c)).collect();
567 if chars.len() <= 2 {
568 return false;
569 }
570 let unique: std::collections::HashSet<char> = chars
571 .iter()
572 .map(|c| c.to_lowercase().next().unwrap_or(*c))
573 .collect();
574 (unique.len() as f32) / (chars.len() as f32) < 0.4
575 });
576 if all_repeated {
577 return false;
578 }
579 }
580
581 true
582}
583
584const NORMALIZED_RELEVANCE_CEILING: f32 = 0.82;
591
592const RRF_SIGNAL_THRESHOLD: f32 = 0.025;
595
596pub fn normalize_rank_fusion_scores(
598 scores: Vec<(Uuid, f32)>,
599 config: &ScoringConfig,
600) -> HashMap<Uuid, f32> {
601 if scores.is_empty() {
602 return HashMap::new();
603 }
604 let min_rrf = config.min_rrf_relevance;
605 let filtered: Vec<(Uuid, f32)> = scores
606 .into_iter()
607 .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
608 .collect();
609 if filtered.is_empty() {
610 return HashMap::new();
611 }
612 let max_score = filtered
613 .iter()
614 .map(|(_, s)| *s)
615 .fold(f32::NEG_INFINITY, f32::max);
616 if !max_score.is_finite() || max_score <= 0.0 {
617 return HashMap::new();
618 }
619 let min_score_seen = filtered
620 .iter()
621 .map(|(_, s)| *s)
622 .fold(f32::INFINITY, f32::min);
623 let span = max_score - min_score_seen;
624 let floor = config
625 .baseline_relevance
626 .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
627 let range = NORMALIZED_RELEVANCE_CEILING - floor;
628
629 let signal_strength = (max_score / RRF_SIGNAL_THRESHOLD).min(1.0);
630
631 filtered
632 .into_iter()
633 .map(|(id, score)| {
634 let calibrated = if span <= f32::EPSILON {
635 max_score.clamp(floor, NORMALIZED_RELEVANCE_CEILING)
636 } else {
637 let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
638 floor + percentile * range
639 };
640 (id, calibrated * signal_strength)
641 })
642 .collect()
643}
644
645pub fn normalize_rrf_scores(
647 scores: Vec<(Uuid, f32)>,
648 config: &ScoringConfig,
649) -> HashMap<Uuid, f32> {
650 if scores.is_empty() {
651 return HashMap::new();
652 }
653 let min_rrf = config.min_rrf_relevance;
654 let filtered: Vec<(Uuid, f32)> = scores
655 .into_iter()
656 .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
657 .collect();
658 if filtered.is_empty() {
659 return HashMap::new();
660 }
661 let max_score = filtered
662 .iter()
663 .map(|(_, s)| *s)
664 .fold(f32::NEG_INFINITY, f32::max);
665 if !max_score.is_finite() || max_score <= 0.0 {
666 return HashMap::new();
667 }
668 let min_score_seen = filtered
669 .iter()
670 .map(|(_, s)| *s)
671 .fold(f32::INFINITY, f32::min);
672 let span = max_score - min_score_seen;
673 let floor = config
674 .baseline_relevance
675 .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
676 let range = NORMALIZED_RELEVANCE_CEILING - floor;
677
678 filtered
679 .into_iter()
680 .map(|(id, score)| {
681 let calibrated = if span <= f32::EPSILON {
682 floor + range
683 } else {
684 let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
685 floor + percentile * range
686 };
687 (id, calibrated)
688 })
689 .collect()
690}
691
692pub struct ScoreInput<'a> {
697 pub salience: f32,
698 pub memory_type_str: &'a str,
699 pub content: &'a str,
700 pub created_at_millis: i64,
701 pub decay_factor: f32,
702 pub now_millis: i64,
703 pub relevance_score: f32,
704 pub entity_names: &'a [String],
705}
706
707pub fn calculate_score(input: &ScoreInput<'_>, config: &ScoringConfig) -> f32 {
715 let w = &config.weights;
716 let semantic_base = w.relevance * input.relevance_score;
717
718 let time_diff_days = ((input.now_millis - input.created_at_millis) as f32
719 / (24.0 * 60.0 * 60.0 * 1000.0))
720 .max(0.0);
721
722 let capped_decay = input.decay_factor.min(config.decay_cap);
723 let temporal_recency = (-capped_decay * time_diff_days).exp();
724
725 let temporal_boost = 1.0 + w.temporal * temporal_recency;
726 let salience_boost = 1.0 + w.salience * input.salience;
727
728 let mut score = semantic_base * temporal_boost * salience_boost;
729
730 let ctx = CandidateContext {
731 memory_type: input.memory_type_str,
732 age_days: time_diff_days,
733 salience: input.salience,
734 content: input.content,
735 entity_names: input.entity_names,
736 };
737
738 for adj in &config.adjustments {
739 score = adj.apply(score, &ctx);
740 }
741
742 score.clamp(0.0, 1.0)
743}
744
745pub const ENTITY_POSTERIOR_WEIGHT: f32 = 0.3;
751
752pub const ENTITY_POSTERIOR_CLAMP_MIN: f32 = 0.85;
755
756pub const ENTITY_POSTERIOR_CLAMP_MAX: f32 = 1.15;
759
760pub fn entity_posterior_term(entity_posterior_mean: Option<f64>, w_ent: f32) -> f32 {
767 match entity_posterior_mean {
768 Some(mean) => (1.0 + w_ent * (mean as f32 - 0.5))
769 .clamp(ENTITY_POSTERIOR_CLAMP_MIN, ENTITY_POSTERIOR_CLAMP_MAX),
770 None => 1.0,
771 }
772}
773
774#[cfg(test)]
777mod tests {
778 use super::*;
779
780 #[test]
781 fn is_meaningful_query_rejects_empty() {
782 assert!(!is_meaningful_query(""));
783 assert!(!is_meaningful_query(" "));
784 }
785
786 #[test]
787 fn is_meaningful_query_rejects_symbols_only() {
788 assert!(!is_meaningful_query("!@#$%"));
789 assert!(!is_meaningful_query("..."));
790 }
791
792 #[test]
793 fn is_meaningful_query_rejects_single_latin_char() {
794 assert!(!is_meaningful_query("a"));
795 assert!(!is_meaningful_query("Z"));
796 }
797
798 #[test]
799 fn is_meaningful_query_rejects_repeated_gibberish() {
800 assert!(!is_meaningful_query("aaaa bbbb cccc"));
801 }
802
803 #[test]
804 fn is_meaningful_query_accepts_normal_queries() {
805 assert!(is_meaningful_query("what is the capital of France"));
806 assert!(is_meaningful_query("rust async runtime"));
807 assert!(is_meaningful_query("hello"));
808 }
809
810 #[test]
811 fn contains_cjk_detects_chinese() {
812 assert!(contains_cjk("你好世界"));
814 assert!(contains_cjk("世界 hi"));
816 assert!(!contains_cjk("hello 世界 world"));
818 }
819
820 #[test]
821 fn contains_cjk_ignores_latin() {
822 assert!(!contains_cjk("hello world"));
823 assert!(!contains_cjk(""));
824 }
825
826 #[test]
827 fn normalize_min_score_fraction_passthrough() {
828 let v = normalize_min_score(0.5).unwrap();
829 assert!((v - 0.5f32).abs() < 1e-6);
830 }
831
832 #[test]
833 fn normalize_min_score_percent_form() {
834 let v = normalize_min_score(50.0).unwrap();
835 assert!((v - 0.5f32).abs() < 1e-6);
836 }
837
838 #[test]
839 fn normalize_min_score_rejects_out_of_range() {
840 assert!(normalize_min_score(200.0).is_err());
841 assert!(normalize_min_score(-1.0).is_err());
842 assert!(normalize_min_score(f64::NAN).is_err());
843 }
844
845 #[test]
846 fn calculate_score_returns_unit_interval() {
847 let config = ScoringConfig::default();
848 let score = calculate_score(
849 &ScoreInput {
850 salience: 0.9,
851 memory_type_str: "episodic",
852 content: "test content",
853 created_at_millis: 0,
854 decay_factor: 0.01,
855 now_millis: 1000,
856 relevance_score: 0.8,
857 entity_names: &[],
858 },
859 &config,
860 );
861 assert!((0.0..=1.0).contains(&score), "score {score} out of [0,1]");
862 }
863
864 #[test]
865 fn calculate_score_high_salience_ranks_higher() {
866 let config = ScoringConfig {
867 adjustments: vec![],
868 ..ScoringConfig::default()
869 };
870 let now_ms = 1_000_000i64;
871 let score_high = calculate_score(
872 &ScoreInput {
873 salience: 0.9,
874 memory_type_str: "episodic",
875 content: "content",
876 created_at_millis: 0,
877 decay_factor: 0.01,
878 now_millis: now_ms,
879 relevance_score: 0.7,
880 entity_names: &[],
881 },
882 &config,
883 );
884 let score_low = calculate_score(
885 &ScoreInput {
886 salience: 0.1,
887 memory_type_str: "episodic",
888 content: "content",
889 created_at_millis: 0,
890 decay_factor: 0.01,
891 now_millis: now_ms,
892 relevance_score: 0.7,
893 entity_names: &[],
894 },
895 &config,
896 );
897 assert!(score_high > score_low, "high salience should rank higher");
898 }
899
900 #[test]
901 fn calculate_score_entity_match_from_auto_extraction_lifts_equal_relevance_candidate() {
902 let config = ScoringConfig::default();
908 let now_ms = 1_000_000i64;
909
910 let query = "sibling transfer to Gamma university";
915 let entity_names = extract_entity_candidates(query);
916 assert_eq!(
917 entity_names,
918 vec!["gamma"],
919 "capitalized-signal query must extract exactly the one proper noun: {entity_names:?}"
920 );
921
922 let matching_score = calculate_score(
923 &ScoreInput {
924 salience: 0.5,
925 memory_type_str: "semantic",
926 content: "sibling transferred to gamma university this fall",
927 created_at_millis: 0,
928 decay_factor: 0.005,
929 now_millis: now_ms,
930 relevance_score: 0.5,
931 entity_names: &entity_names,
932 },
933 &config,
934 );
935 let non_matching_score = calculate_score(
936 &ScoreInput {
937 salience: 0.5,
938 memory_type_str: "semantic",
939 content: "sibling transferred to a different university this fall",
940 created_at_millis: 0,
941 decay_factor: 0.005,
942 now_millis: now_ms,
943 relevance_score: 0.5,
944 entity_names: &entity_names,
945 },
946 &config,
947 );
948
949 assert!(
950 matching_score > non_matching_score,
951 "memory matching an auto-extracted entity name must outrank an \
952 equal-relevance memory that doesn't: matching={matching_score} \
953 non_matching={non_matching_score}"
954 );
955 let ratio = matching_score / non_matching_score;
960 assert!(
961 (ratio - 1.3).abs() < 0.01,
962 "expected ~1.3x lift from the EntityMatch adjustment, got ratio {ratio}"
963 );
964 }
965
966 #[test]
967 fn dos_caps_enforce_limits() {
968 let mut config = ScoringConfig {
969 max_recall_candidates: 9999,
970 default_token_budget: 99999,
971 default_recall_limit: 9999,
972 ..ScoringConfig::default()
973 };
974 config.apply_dos_caps();
975 assert_eq!(config.max_recall_candidates, MAX_RECALL_CANDIDATES);
976 assert_eq!(config.default_token_budget, MAX_TOKEN_BUDGET);
977 assert_eq!(config.default_recall_limit, MAX_RECALL_LIMIT);
978 }
979
980 #[test]
981 fn normalize_rrf_scores_preserves_ordering() {
982 let config = ScoringConfig::default();
983 let input = vec![
984 (Uuid::new_v4(), 0.030f32),
985 (Uuid::new_v4(), 0.020f32),
986 (Uuid::new_v4(), 0.015f32),
987 ];
988 let ids: Vec<Uuid> = input.iter().map(|(id, _)| *id).collect();
989 let output = normalize_rrf_scores(input, &config);
990 let s0 = output[&ids[0]];
991 let s1 = output[&ids[1]];
992 let s2 = output[&ids[2]];
993 assert!(s0 > s1 && s1 > s2, "ordering must be preserved");
994 assert!(s0 <= 1.0 && s2 >= 0.0, "scores must be in [0,1]");
995 }
996
997 #[test]
1000 fn needs_multilingual_ascii_english_routes_primary() {
1001 assert!(!needs_multilingual("hello world"));
1002 assert!(!needs_multilingual("rust programming language"));
1003 assert!(!needs_multilingual(""));
1004 assert!(!needs_multilingual("42 items in the list"));
1005 }
1006
1007 #[test]
1008 fn needs_multilingual_cjk_routes_multilingual() {
1009 assert!(needs_multilingual("你好世界"));
1011 assert!(needs_multilingual("abc你好"));
1013 }
1014
1015 #[test]
1016 fn needs_multilingual_cyrillic_routes_multilingual() {
1017 assert!(needs_multilingual("Привет мир"));
1019 assert!(needs_multilingual("Привет hello")); }
1023
1024 #[test]
1025 fn needs_multilingual_arabic_routes_multilingual() {
1026 assert!(needs_multilingual("مرحبا بالعالم"));
1028 }
1029
1030 #[test]
1031 fn needs_multilingual_devanagari_routes_multilingual() {
1032 assert!(needs_multilingual("नमस्ते दुनिया"));
1034 }
1035
1036 #[test]
1037 fn needs_multilingual_accented_latin_routes_multilingual() {
1038 assert!(needs_multilingual("café résumé"));
1041 assert!(needs_multilingual("Müller"));
1043 assert!(needs_multilingual("niño"));
1045 }
1046
1047 #[test]
1048 fn needs_multilingual_alphabetic_denominator_is_stable_under_punctuation() {
1049 assert!(needs_multilingual("Müller"));
1052 assert!(needs_multilingual("Müller?"));
1054 assert!(needs_multilingual("Müller!!!"));
1056 assert!(needs_multilingual("42 Ü"));
1058 assert!(!needs_multilingual(""));
1060 assert!(!needs_multilingual("42 100 ???"));
1061 }
1062
1063 #[test]
1064 fn needs_multilingual_pure_ascii_accented_below_threshold_routes_primary() {
1065 assert!(!needs_multilingual("hello naive")); assert!(needs_multilingual("über"));
1071 }
1072
1073 #[test]
1074 fn needs_multilingual_ascii_only_non_english_latin_routes_primary_known_limitation() {
1075 assert!(!needs_multilingual("bonjour le monde"));
1078 assert!(!needs_multilingual("como estas"));
1079 assert!(!needs_multilingual("ich suche einen buchhalter"));
1080 }
1081
1082 #[test]
1085 fn extract_entity_candidates_capitalized_tokens_only() {
1086 let out = extract_entity_candidates("Alex sibling college Acme Beta Gamma");
1090 assert_eq!(out, vec!["alex", "acme", "beta", "gamma"]);
1091 }
1092
1093 #[test]
1094 fn extract_entity_candidates_all_lowercase_query_extracts_nothing() {
1095 let out = extract_entity_candidates("alex sibling college acme beta gamma");
1100 assert!(out.is_empty());
1101 }
1102
1103 #[test]
1104 fn extract_entity_candidates_strips_stopwords() {
1105 let out = extract_entity_candidates("what is the capital of France");
1106 assert_eq!(out, vec!["france"]);
1109 }
1110
1111 #[test]
1112 fn extract_entity_candidates_strips_punctuation() {
1113 let out = extract_entity_candidates("Alex's sibling, Acme!");
1114 assert_eq!(out, vec!["alex's", "acme"]);
1115 }
1116
1117 #[test]
1118 fn extract_entity_candidates_caps_at_max_auto_entity_names() {
1119 let query = "Alpha Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliet";
1120 let out = extract_entity_candidates(query);
1121 assert_eq!(out.len(), MAX_AUTO_ENTITY_NAMES);
1122 assert_eq!(
1123 out,
1124 vec!["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"]
1125 );
1126 }
1127
1128 #[test]
1129 fn extract_entity_candidates_dedupes_case_insensitively() {
1130 let out = extract_entity_candidates("Acme ACME college");
1131 assert_eq!(out, vec!["acme"]);
1134 }
1135
1136 #[test]
1137 fn extract_entity_candidates_empty_query_returns_empty() {
1138 assert!(extract_entity_candidates("").is_empty());
1139 assert!(extract_entity_candidates(" ").is_empty());
1140 }
1141
1142 #[test]
1143 fn extract_entity_candidates_all_stopwords_returns_empty() {
1144 assert!(extract_entity_candidates("is the a of").is_empty());
1145 }
1146
1147 fn entity_match_ctx<'a>(content: &'a str, entity_names: &'a [String]) -> CandidateContext<'a> {
1150 CandidateContext {
1151 memory_type: "episodic",
1152 age_days: 0.0,
1153 salience: 0.5,
1154 content,
1155 entity_names,
1156 }
1157 }
1158
1159 #[test]
1160 fn contains_at_word_boundary_rejects_substring_inside_another_word() {
1161 assert!(!contains_at_word_boundary("alphabet soup", "beta"));
1162 assert!(!contains_at_word_boundary("buy a betamax player", "beta"));
1163 assert!(!contains_at_word_boundary(
1164 "water scarcity is rising",
1165 "car"
1166 ));
1167 }
1168
1169 #[test]
1170 fn contains_at_word_boundary_accepts_the_word_itself() {
1171 assert!(contains_at_word_boundary("beta release notes", "beta"));
1172 assert!(contains_at_word_boundary("drove the car home", "car"));
1173 assert!(contains_at_word_boundary("beta", "beta"));
1175 assert!(contains_at_word_boundary("notes beta", "beta"));
1176 }
1177
1178 #[test]
1179 fn contains_at_word_boundary_accepts_multi_word_phrase() {
1180 assert!(contains_at_word_boundary(
1184 "the knowledge graph shows this",
1185 "knowledge graph"
1186 ));
1187 assert!(!contains_at_word_boundary(
1188 "prior knowledge, graphical view",
1189 "knowledge graph"
1190 ));
1191 }
1192
1193 #[test]
1194 fn entity_match_condition_rejects_substring_false_positives() {
1195 let entity_names = vec!["beta".to_string()];
1196 let ctx = entity_match_ctx("alphabet soup for dinner", &entity_names);
1197 assert!(
1198 !AdjustmentCondition::EntityMatch.matches(&ctx),
1199 "\"beta\" must not match inside \"alphabet\""
1200 );
1201
1202 let entity_names = vec!["car".to_string()];
1203 let ctx = entity_match_ctx("water scarcity is rising", &entity_names);
1204 assert!(
1205 !AdjustmentCondition::EntityMatch.matches(&ctx),
1206 "\"car\" must not match inside \"scarcity\""
1207 );
1208 }
1209
1210 #[test]
1211 fn entity_match_condition_matches_multi_word_explicit_name() {
1212 let entity_names = vec!["knowledge graph".to_string()];
1213 let ctx = entity_match_ctx("notes on the knowledge graph design", &entity_names);
1214 assert!(
1215 AdjustmentCondition::EntityMatch.matches(&ctx),
1216 "explicit multi-word entity name must still match on its own boundaries"
1217 );
1218 }
1219
1220 #[test]
1221 fn entity_match_condition_still_matches_real_word_occurrence() {
1222 let entity_names = vec!["beta".to_string()];
1223 let ctx = entity_match_ctx("the beta release ships tomorrow", &entity_names);
1224 assert!(
1225 AdjustmentCondition::EntityMatch.matches(&ctx),
1226 "boundary anchoring must not break matching the actual word"
1227 );
1228 }
1229
1230 #[test]
1233 fn entity_posterior_term_neutral_when_no_posterior() {
1234 assert_eq!(entity_posterior_term(None, ENTITY_POSTERIOR_WEIGHT), 1.0);
1235 }
1236
1237 #[test]
1238 fn entity_posterior_term_neutral_at_uninformative_prior_mean() {
1239 let term = entity_posterior_term(Some(0.5), ENTITY_POSTERIOR_WEIGHT);
1242 assert!((term - 1.0).abs() < 1e-6, "got {term}");
1243 }
1244
1245 #[test]
1246 fn entity_posterior_term_reaches_low_clamp_bound_at_mean_zero() {
1247 let term = entity_posterior_term(Some(0.0), ENTITY_POSTERIOR_WEIGHT);
1248 assert!(
1249 (term - ENTITY_POSTERIOR_CLAMP_MIN).abs() < 1e-6,
1250 "w_ent=0.3 at mean=0.0 must land exactly on the 0.85 clamp bound, got {term}"
1251 );
1252 }
1253
1254 #[test]
1255 fn entity_posterior_term_reaches_high_clamp_bound_at_mean_one() {
1256 let term = entity_posterior_term(Some(1.0), ENTITY_POSTERIOR_WEIGHT);
1257 assert!(
1258 (term - ENTITY_POSTERIOR_CLAMP_MAX).abs() < 1e-6,
1259 "w_ent=0.3 at mean=1.0 must land exactly on the 1.15 clamp bound, got {term}"
1260 );
1261 }
1262
1263 #[test]
1264 fn entity_posterior_term_never_exceeds_clamp_bounds_for_out_of_range_input() {
1265 let low = entity_posterior_term(Some(-5.0), ENTITY_POSTERIOR_WEIGHT);
1269 let high = entity_posterior_term(Some(5.0), ENTITY_POSTERIOR_WEIGHT);
1270 assert_eq!(low, ENTITY_POSTERIOR_CLAMP_MIN);
1271 assert_eq!(high, ENTITY_POSTERIOR_CLAMP_MAX);
1272 }
1273
1274 #[test]
1275 fn entity_posterior_term_moves_monotonically_with_mean() {
1276 let low = entity_posterior_term(Some(0.2), ENTITY_POSTERIOR_WEIGHT);
1277 let mid = entity_posterior_term(Some(0.5), ENTITY_POSTERIOR_WEIGHT);
1278 let high = entity_posterior_term(Some(0.8), ENTITY_POSTERIOR_WEIGHT);
1279 assert!(low < mid, "low={low} mid={mid}");
1280 assert!(mid < high, "mid={mid} high={high}");
1281 }
1282}