use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AdjustmentCondition {
MemoryType { kind: String },
AgeRange {
#[serde(default)]
min_days: Option<f32>,
#[serde(default)]
max_days: Option<f32>,
},
SalienceRange {
#[serde(default)]
min: Option<f32>,
#[serde(default)]
max: Option<f32>,
},
EntityMatch,
EntityMiss,
All {
conditions: Vec<AdjustmentCondition>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AdjustmentOp {
Add { value: f32 },
Subtract { value: f32 },
Multiply { factor: f32 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreAdjustment {
pub condition: AdjustmentCondition,
pub operation: AdjustmentOp,
}
pub struct CandidateContext<'a> {
pub memory_type: &'a str,
pub age_days: f32,
pub salience: f32,
pub content: &'a str,
pub entity_names: &'a [String],
}
impl AdjustmentCondition {
pub fn matches(&self, ctx: &CandidateContext<'_>) -> bool {
match self {
Self::MemoryType { kind } => ctx.memory_type == kind.as_str(),
Self::AgeRange { min_days, max_days } => {
if let Some(min) = min_days {
if ctx.age_days < *min {
return false;
}
}
if let Some(max) = max_days {
if ctx.age_days > *max {
return false;
}
}
true
}
Self::SalienceRange { min, max } => {
if let Some(lo) = min {
if ctx.salience < *lo {
return false;
}
}
if let Some(hi) = max {
if ctx.salience > *hi {
return false;
}
}
true
}
Self::EntityMatch => {
if ctx.entity_names.is_empty() {
return false;
}
let lower = ctx.content.to_lowercase();
ctx.entity_names.iter().any(|e| lower.contains(e.as_str()))
}
Self::EntityMiss => {
if ctx.entity_names.is_empty() {
return false;
}
let lower = ctx.content.to_lowercase();
!ctx.entity_names.iter().any(|e| lower.contains(e.as_str()))
}
Self::All { conditions } => conditions.iter().all(|c| c.matches(ctx)),
}
}
}
impl AdjustmentOp {
pub fn apply(&self, score: f32) -> f32 {
match self {
Self::Add { value } => score + value,
Self::Subtract { value } => score - value,
Self::Multiply { factor } => score * factor,
}
}
}
impl ScoreAdjustment {
pub fn apply(&self, score: f32, ctx: &CandidateContext<'_>) -> f32 {
if self.condition.matches(ctx) {
self.operation.apply(score)
} else {
score
}
}
}
pub fn default_adjustments() -> Vec<ScoreAdjustment> {
vec![
ScoreAdjustment {
condition: AdjustmentCondition::All {
conditions: vec![
AdjustmentCondition::MemoryType {
kind: "episodic".into(),
},
AdjustmentCondition::AgeRange {
min_days: None,
max_days: Some(7.0),
},
],
},
operation: AdjustmentOp::Add { value: 0.05 },
},
ScoreAdjustment {
condition: AdjustmentCondition::All {
conditions: vec![
AdjustmentCondition::MemoryType {
kind: "semantic".into(),
},
AdjustmentCondition::AgeRange {
min_days: Some(30.0),
max_days: None,
},
AdjustmentCondition::SalienceRange {
min: Some(0.85),
max: None,
},
],
},
operation: AdjustmentOp::Subtract { value: 0.05 },
},
ScoreAdjustment {
condition: AdjustmentCondition::EntityMatch,
operation: AdjustmentOp::Multiply { factor: 1.3 },
},
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ScoringWeights {
pub salience: f32,
pub temporal: f32,
pub relevance: f32,
}
impl Default for ScoringWeights {
fn default() -> Self {
Self {
salience: 0.2,
temporal: 0.1,
relevance: 0.7,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ScoringConfig {
pub weights: ScoringWeights,
pub min_raw_relevance: f32,
pub min_rrf_relevance: f32,
pub baseline_relevance: f32,
pub decay_cap: f32,
pub max_recall_candidates: usize,
pub default_recall_limit: usize,
pub default_token_budget: usize,
pub chars_per_token: usize,
pub mmr_penalty: f32,
pub mmr_prefix_len: usize,
pub enable_supersedes_suppression: bool,
pub enable_cjk_routing: bool,
pub cjk_model: Option<String>,
pub adjustments: Vec<ScoreAdjustment>,
}
impl Default for ScoringConfig {
fn default() -> Self {
Self {
weights: ScoringWeights::default(),
min_raw_relevance: 0.10,
min_rrf_relevance: 0.0,
baseline_relevance: 0.15,
decay_cap: 0.05,
max_recall_candidates: 200,
default_recall_limit: 10,
default_token_budget: 4000,
chars_per_token: 4,
mmr_penalty: 0.1,
mmr_prefix_len: 100,
enable_supersedes_suppression: true,
enable_cjk_routing: true,
cjk_model: None,
adjustments: default_adjustments(),
}
}
}
pub const MAX_RECALL_CANDIDATES: usize = 500;
pub const MAX_TOKEN_BUDGET: usize = 16_000;
pub const MAX_RECALL_LIMIT: usize = 200;
impl ScoringConfig {
pub fn apply_dos_caps(&mut self) {
self.max_recall_candidates = self.max_recall_candidates.min(MAX_RECALL_CANDIDATES);
self.default_token_budget = self.default_token_budget.min(MAX_TOKEN_BUDGET);
self.default_recall_limit = self.default_recall_limit.min(MAX_RECALL_LIMIT);
}
}
#[inline]
pub fn is_cjk_char(c: char) -> bool {
matches!(c,
'\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}' )
}
pub fn contains_cjk(text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return false;
}
let cjk = chars.iter().filter(|&&c| is_cjk_char(c)).count();
(cjk as f32) / (chars.len() as f32) > 0.15
}
pub fn normalize_min_score(score: f64) -> Result<f32, crate::config::MinScoreError> {
if !score.is_finite() {
return Err(crate::config::MinScoreError::NotFinite);
}
if (0.0..=1.0).contains(&score) {
return Ok(score as f32);
}
if (1.0..=100.0).contains(&score) {
return Ok((score / 100.0) as f32);
}
Err(crate::config::MinScoreError::OutOfRange(score))
}
pub fn is_meaningful_query(query: &str) -> bool {
let trimmed = query.trim();
if trimmed.is_empty() {
return false;
}
let is_alpha_or_cjk = |c: char| c.is_alphabetic() || is_cjk_char(c);
let meaningful_chars: usize = trimmed.chars().filter(|c| is_alpha_or_cjk(*c)).count();
if meaningful_chars == 0 {
return false;
}
let cjk_chars: usize = trimmed.chars().filter(|c| is_cjk_char(*c)).count();
if meaningful_chars < 2 && cjk_chars == 0 {
return false;
}
let words: Vec<&str> = trimmed
.split_whitespace()
.filter(|w| w.chars().any(is_alpha_or_cjk))
.collect();
if !words.is_empty() {
let all_repeated = words.iter().all(|w| {
let chars: Vec<char> = w.chars().filter(|c| is_alpha_or_cjk(*c)).collect();
if chars.len() <= 2 {
return false;
}
let unique: std::collections::HashSet<char> = chars
.iter()
.map(|c| c.to_lowercase().next().unwrap_or(*c))
.collect();
(unique.len() as f32) / (chars.len() as f32) < 0.4
});
if all_repeated {
return false;
}
}
true
}
const NORMALIZED_RELEVANCE_CEILING: f32 = 0.82;
const RRF_SIGNAL_THRESHOLD: f32 = 0.025;
pub fn normalize_rank_fusion_scores(
scores: Vec<(Uuid, f32)>,
config: &ScoringConfig,
) -> HashMap<Uuid, f32> {
if scores.is_empty() {
return HashMap::new();
}
let min_rrf = config.min_rrf_relevance;
let filtered: Vec<(Uuid, f32)> = scores
.into_iter()
.filter(|(_, score)| score.is_finite() && *score >= min_rrf)
.collect();
if filtered.is_empty() {
return HashMap::new();
}
let max_score = filtered
.iter()
.map(|(_, s)| *s)
.fold(f32::NEG_INFINITY, f32::max);
if !max_score.is_finite() || max_score <= 0.0 {
return HashMap::new();
}
let min_score_seen = filtered
.iter()
.map(|(_, s)| *s)
.fold(f32::INFINITY, f32::min);
let span = max_score - min_score_seen;
let floor = config
.baseline_relevance
.clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
let range = NORMALIZED_RELEVANCE_CEILING - floor;
let signal_strength = (max_score / RRF_SIGNAL_THRESHOLD).min(1.0);
filtered
.into_iter()
.map(|(id, score)| {
let calibrated = if span <= f32::EPSILON {
max_score.clamp(floor, NORMALIZED_RELEVANCE_CEILING)
} else {
let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
floor + percentile * range
};
(id, calibrated * signal_strength)
})
.collect()
}
pub fn normalize_rrf_scores(
scores: Vec<(Uuid, f32)>,
config: &ScoringConfig,
) -> HashMap<Uuid, f32> {
if scores.is_empty() {
return HashMap::new();
}
let min_rrf = config.min_rrf_relevance;
let filtered: Vec<(Uuid, f32)> = scores
.into_iter()
.filter(|(_, score)| score.is_finite() && *score >= min_rrf)
.collect();
if filtered.is_empty() {
return HashMap::new();
}
let max_score = filtered
.iter()
.map(|(_, s)| *s)
.fold(f32::NEG_INFINITY, f32::max);
if !max_score.is_finite() || max_score <= 0.0 {
return HashMap::new();
}
let min_score_seen = filtered
.iter()
.map(|(_, s)| *s)
.fold(f32::INFINITY, f32::min);
let span = max_score - min_score_seen;
let floor = config
.baseline_relevance
.clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
let range = NORMALIZED_RELEVANCE_CEILING - floor;
filtered
.into_iter()
.map(|(id, score)| {
let calibrated = if span <= f32::EPSILON {
floor + range
} else {
let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
floor + percentile * range
};
(id, calibrated)
})
.collect()
}
pub struct ScoreInput<'a> {
pub salience: f32,
pub memory_type_str: &'a str,
pub content: &'a str,
pub created_at_millis: i64,
pub decay_factor: f32,
pub now_millis: i64,
pub relevance_score: f32,
pub entity_names: &'a [String],
}
pub fn calculate_score(input: &ScoreInput<'_>, config: &ScoringConfig) -> f32 {
let w = &config.weights;
let semantic_base = w.relevance * input.relevance_score;
let time_diff_days = ((input.now_millis - input.created_at_millis) as f32
/ (24.0 * 60.0 * 60.0 * 1000.0))
.max(0.0);
let capped_decay = input.decay_factor.min(config.decay_cap);
let temporal_recency = (-capped_decay * time_diff_days).exp();
let temporal_boost = 1.0 + w.temporal * temporal_recency;
let salience_boost = 1.0 + w.salience * input.salience;
let mut score = semantic_base * temporal_boost * salience_boost;
let ctx = CandidateContext {
memory_type: input.memory_type_str,
age_days: time_diff_days,
salience: input.salience,
content: input.content,
entity_names: input.entity_names,
};
for adj in &config.adjustments {
score = adj.apply(score, &ctx);
}
score.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_meaningful_query_rejects_empty() {
assert!(!is_meaningful_query(""));
assert!(!is_meaningful_query(" "));
}
#[test]
fn is_meaningful_query_rejects_symbols_only() {
assert!(!is_meaningful_query("!@#$%"));
assert!(!is_meaningful_query("..."));
}
#[test]
fn is_meaningful_query_rejects_single_latin_char() {
assert!(!is_meaningful_query("a"));
assert!(!is_meaningful_query("Z"));
}
#[test]
fn is_meaningful_query_rejects_repeated_gibberish() {
assert!(!is_meaningful_query("aaaa bbbb cccc"));
}
#[test]
fn is_meaningful_query_accepts_normal_queries() {
assert!(is_meaningful_query("what is the capital of France"));
assert!(is_meaningful_query("rust async runtime"));
assert!(is_meaningful_query("hello"));
}
#[test]
fn contains_cjk_detects_chinese() {
assert!(contains_cjk("你好世界"));
assert!(contains_cjk("世界 hi"));
assert!(!contains_cjk("hello 世界 world"));
}
#[test]
fn contains_cjk_ignores_latin() {
assert!(!contains_cjk("hello world"));
assert!(!contains_cjk(""));
}
#[test]
fn normalize_min_score_fraction_passthrough() {
let v = normalize_min_score(0.5).unwrap();
assert!((v - 0.5f32).abs() < 1e-6);
}
#[test]
fn normalize_min_score_percent_form() {
let v = normalize_min_score(50.0).unwrap();
assert!((v - 0.5f32).abs() < 1e-6);
}
#[test]
fn normalize_min_score_rejects_out_of_range() {
assert!(normalize_min_score(200.0).is_err());
assert!(normalize_min_score(-1.0).is_err());
assert!(normalize_min_score(f64::NAN).is_err());
}
#[test]
fn calculate_score_returns_unit_interval() {
let config = ScoringConfig::default();
let score = calculate_score(
&ScoreInput {
salience: 0.9,
memory_type_str: "episodic",
content: "test content",
created_at_millis: 0,
decay_factor: 0.01,
now_millis: 1000,
relevance_score: 0.8,
entity_names: &[],
},
&config,
);
assert!((0.0..=1.0).contains(&score), "score {score} out of [0,1]");
}
#[test]
fn calculate_score_high_salience_ranks_higher() {
let config = ScoringConfig {
adjustments: vec![],
..ScoringConfig::default()
};
let now_ms = 1_000_000i64;
let score_high = calculate_score(
&ScoreInput {
salience: 0.9,
memory_type_str: "episodic",
content: "content",
created_at_millis: 0,
decay_factor: 0.01,
now_millis: now_ms,
relevance_score: 0.7,
entity_names: &[],
},
&config,
);
let score_low = calculate_score(
&ScoreInput {
salience: 0.1,
memory_type_str: "episodic",
content: "content",
created_at_millis: 0,
decay_factor: 0.01,
now_millis: now_ms,
relevance_score: 0.7,
entity_names: &[],
},
&config,
);
assert!(score_high > score_low, "high salience should rank higher");
}
#[test]
fn dos_caps_enforce_limits() {
let mut config = ScoringConfig {
max_recall_candidates: 9999,
default_token_budget: 99999,
default_recall_limit: 9999,
..ScoringConfig::default()
};
config.apply_dos_caps();
assert_eq!(config.max_recall_candidates, MAX_RECALL_CANDIDATES);
assert_eq!(config.default_token_budget, MAX_TOKEN_BUDGET);
assert_eq!(config.default_recall_limit, MAX_RECALL_LIMIT);
}
#[test]
fn normalize_rrf_scores_preserves_ordering() {
let config = ScoringConfig::default();
let input = vec![
(Uuid::new_v4(), 0.030f32),
(Uuid::new_v4(), 0.020f32),
(Uuid::new_v4(), 0.015f32),
];
let ids: Vec<Uuid> = input.iter().map(|(id, _)| *id).collect();
let output = normalize_rrf_scores(input, &config);
let s0 = output[&ids[0]];
let s1 = output[&ids[1]];
let s2 = output[&ids[2]];
assert!(s0 > s1 && s1 > s2, "ordering must be preserved");
assert!(s0 <= 1.0 && s2 >= 0.0, "scores must be in [0,1]");
}
}