Skip to main content

khive_pack_memory/
scoring.rs

1//! Composite memory scoring — ported from the archived internal service (v1 archive).
2//!
3//! Provides a fully-tunable scoring pipeline:
4//!   1. `ScoringConfig`  — all knobs, all `pub`, all serde-friendly for agent sweeps.
5//!   2. `calculate_score` — multiplicative formula: `w_rel × relevance × (1 + w_temp × recency) × (1 + w_imp × salience)`.
6//!   3. `ScoreAdjustment` — declarative conditional rules applied after the base formula.
7//!   4. `normalize_rrf_scores` / `normalize_rank_fusion_scores` — RRF and raw-cosine normalization.
8//!   5. `normalize_min_score` — dual-scale input (0.0–1.0 fraction or 0–100 integer).
9//!   6. `is_meaningful_query` — noise gate before embedding compute.
10//!   7. `contains_cjk` — CJK routing decision.
11//!   8. `needs_multilingual` — broad multilingual routing gate (non-ASCII alphabetic script).
12// FILE SIZE JUSTIFICATION: scoring.rs bundles ScoringConfig, all normalization helpers,
13// CJK routing, and the full test suite for the scoring pipeline. The tests require access
14// to module-private helpers; splitting would require pub(crate) promotion of private fns.
15
16use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21// ── Adjustment conditions ─────────────────────────────────────────────────────
22
23/// A condition that determines whether a score adjustment applies to a candidate.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(tag = "type", rename_all = "snake_case")]
26pub enum AdjustmentCondition {
27    /// Match by memory type ("episodic" or "semantic").
28    MemoryType { kind: String },
29    /// Match by age in days. Both bounds are optional (omit = no bound).
30    AgeRange {
31        #[serde(default)]
32        min_days: Option<f32>,
33        #[serde(default)]
34        max_days: Option<f32>,
35    },
36    /// Match by salience score. Both bounds are optional.
37    SalienceRange {
38        #[serde(default)]
39        min: Option<f32>,
40        #[serde(default)]
41        max: Option<f32>,
42    },
43    /// Match when query entity names appear in memory content.
44    EntityMatch,
45    /// Match when query entity names do NOT appear in memory content.
46    EntityMiss,
47    /// All sub-conditions must be true (conjunction).
48    All {
49        conditions: Vec<AdjustmentCondition>,
50    },
51}
52
53/// The operation to apply when a condition matches.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(tag = "type", rename_all = "snake_case")]
56pub enum AdjustmentOp {
57    /// Add a fixed value to the score.
58    Add { value: f32 },
59    /// Subtract a fixed value from the score.
60    Subtract { value: f32 },
61    /// Multiply the score by a factor.
62    Multiply { factor: f32 },
63}
64
65/// A conditional score adjustment: if `condition` matches, apply `operation`.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ScoreAdjustment {
68    pub condition: AdjustmentCondition,
69    pub operation: AdjustmentOp,
70}
71
72/// Context passed to condition evaluation — properties of the candidate + query.
73pub 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
81impl AdjustmentCondition {
82    /// Return `true` when this condition applies to the given candidate context.
83    pub fn matches(&self, ctx: &CandidateContext<'_>) -> bool {
84        match self {
85            Self::MemoryType { kind } => ctx.memory_type == kind.as_str(),
86            Self::AgeRange { min_days, max_days } => {
87                if let Some(min) = min_days {
88                    if ctx.age_days < *min {
89                        return false;
90                    }
91                }
92                if let Some(max) = max_days {
93                    if ctx.age_days > *max {
94                        return false;
95                    }
96                }
97                true
98            }
99            Self::SalienceRange { min, max } => {
100                if let Some(lo) = min {
101                    if ctx.salience < *lo {
102                        return false;
103                    }
104                }
105                if let Some(hi) = max {
106                    if ctx.salience > *hi {
107                        return false;
108                    }
109                }
110                true
111            }
112            Self::EntityMatch => {
113                if ctx.entity_names.is_empty() {
114                    return false;
115                }
116                let lower = ctx.content.to_lowercase();
117                ctx.entity_names.iter().any(|e| lower.contains(e.as_str()))
118            }
119            Self::EntityMiss => {
120                if ctx.entity_names.is_empty() {
121                    return false;
122                }
123                let lower = ctx.content.to_lowercase();
124                !ctx.entity_names.iter().any(|e| lower.contains(e.as_str()))
125            }
126            Self::All { conditions } => conditions.iter().all(|c| c.matches(ctx)),
127        }
128    }
129}
130
131impl AdjustmentOp {
132    /// Apply this operation to `score` and return the adjusted value.
133    pub fn apply(&self, score: f32) -> f32 {
134        match self {
135            Self::Add { value } => score + value,
136            Self::Subtract { value } => score - value,
137            Self::Multiply { factor } => score * factor,
138        }
139    }
140}
141
142impl ScoreAdjustment {
143    /// Apply this adjustment to `score` if the condition matches, otherwise return `score` unchanged.
144    pub fn apply(&self, score: f32, ctx: &CandidateContext<'_>) -> f32 {
145        if self.condition.matches(ctx) {
146            self.operation.apply(score)
147        } else {
148            score
149        }
150    }
151}
152
153/// Default score adjustments: episodic recency bonus, semantic age penalty, entity boost.
154pub fn default_adjustments() -> Vec<ScoreAdjustment> {
155    vec![
156        // Episodic recency bonus: recent episodic memories get an additive boost.
157        ScoreAdjustment {
158            condition: AdjustmentCondition::All {
159                conditions: vec![
160                    AdjustmentCondition::MemoryType {
161                        kind: "episodic".into(),
162                    },
163                    AdjustmentCondition::AgeRange {
164                        min_days: None,
165                        max_days: Some(7.0),
166                    },
167                ],
168            },
169            operation: AdjustmentOp::Add { value: 0.05 },
170        },
171        // Semantic age penalty: old high-salience semantic memories get penalized
172        // to prevent reference docs from crowding out episodic content.
173        ScoreAdjustment {
174            condition: AdjustmentCondition::All {
175                conditions: vec![
176                    AdjustmentCondition::MemoryType {
177                        kind: "semantic".into(),
178                    },
179                    AdjustmentCondition::AgeRange {
180                        min_days: Some(30.0),
181                        max_days: None,
182                    },
183                    AdjustmentCondition::SalienceRange {
184                        min: Some(0.85),
185                        max: None,
186                    },
187                ],
188            },
189            operation: AdjustmentOp::Subtract { value: 0.05 },
190        },
191        // Entity match boost: memories mentioning queried entities get boosted.
192        ScoreAdjustment {
193            condition: AdjustmentCondition::EntityMatch,
194            operation: AdjustmentOp::Multiply { factor: 1.3 },
195        },
196    ]
197}
198
199// ── Scoring weights ───────────────────────────────────────────────────────────
200
201/// Weights for the combined memory score: `score = w_rel × relevance × (1 + w_temp × recency) × (1 + w_imp × salience)`.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(default)]
204pub struct ScoringWeights {
205    /// Multiplicative boost from salience in `(1 + w_imp × salience)`. Default: 0.2.
206    pub salience: f32,
207    /// Multiplicative boost from recency in `(1 + w_temp × recency)`. Default: 0.1.
208    pub temporal: f32,
209    /// Base multiplier applied to relevance. Default: 0.7.
210    pub relevance: f32,
211}
212
213impl Default for ScoringWeights {
214    fn default() -> Self {
215        Self {
216            salience: 0.2,
217            temporal: 0.1,
218            relevance: 0.7,
219        }
220    }
221}
222
223// ── ScoringConfig ─────────────────────────────────────────────────────────────
224
225/// Complete, tunable scoring configuration for the memory recall pipeline.
226/// DoS caps: max_recall_candidates≤500, default_token_budget≤16000, default_recall_limit≤200.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(default)]
229pub struct ScoringConfig {
230    // ── Composite weights ──────────────────────────────────────────────────
231    pub weights: ScoringWeights,
232
233    // ── Relevance thresholds ───────────────────────────────────────────────
234    /// Minimum raw cosine similarity to include a vector hit. Hits below this
235    /// are excluded before RRF fusion (#2272). Default: 0.10.
236    pub min_raw_relevance: f32,
237    /// Minimum RRF score after fusion before normalization. Default: 0.0.
238    pub min_rrf_relevance: f32,
239    /// Relevance floor for the min-max normalization band. Default: 0.15.
240    pub baseline_relevance: f32,
241
242    // ── Temporal decay ─────────────────────────────────────────────────────
243    /// Upper cap on per-entry decay_factor before temporal recency calculation.
244    /// Default: 0.05.
245    pub decay_cap: f32,
246
247    // ── DoS caps (enforced server-side) ───────────────────────────────────
248    /// Maximum search candidates to retrieve. Server-side cap: 500. Default: 200.
249    pub max_recall_candidates: usize,
250    /// Default result limit when caller doesn't specify. Server-side cap: 200.
251    /// Default: 10.
252    pub default_recall_limit: usize,
253    /// Default token budget (tokens). Server-side cap: 16000. Default: 4000.
254    pub default_token_budget: usize,
255    /// Approximate characters per token (for token budget). Default: 4.
256    pub chars_per_token: usize,
257
258    // ── MMR diversity penalty ──────────────────────────────────────────────
259    /// Score penalty applied to results whose first `mmr_prefix_len` characters
260    /// match an earlier result. Default: 0.1.
261    pub mmr_penalty: f32,
262    /// Character prefix length used for MMR duplicate detection. Default: 100.
263    pub mmr_prefix_len: usize,
264
265    // ── Feature toggles ────────────────────────────────────────────────────
266    /// When true, suppress memories whose `properties.supersedes` value matches
267    /// the ID of another memory in the result set. Default: true.
268    pub enable_supersedes_suppression: bool,
269    /// When true and a multilingual embedding model is registered, route
270    /// non-ASCII-script queries (CJK, Cyrillic, Arabic, accented-Latin, …) to
271    /// it as the primary dense model. Default: true.
272    pub enable_multilingual_routing: bool,
273    /// Name of the multilingual embedding model to prefer for dense routing.
274    /// When None, the handler checks registered model names for substrings
275    /// "multilingual" or "paraphrase". Default: None.
276    pub multilingual_model: Option<String>,
277
278    // ── Conditional adjustments ────────────────────────────────────────────
279    /// Score adjustments applied after the base formula. Default: episodic bonus,
280    /// semantic age penalty, entity boost.
281    pub adjustments: Vec<ScoreAdjustment>,
282}
283
284impl Default for ScoringConfig {
285    fn default() -> Self {
286        Self {
287            weights: ScoringWeights::default(),
288
289            min_raw_relevance: 0.10,
290            min_rrf_relevance: 0.0,
291            baseline_relevance: 0.15,
292
293            decay_cap: 0.05,
294
295            max_recall_candidates: 200,
296            default_recall_limit: 10,
297            default_token_budget: 4000,
298            chars_per_token: 4,
299
300            mmr_penalty: 0.1,
301            mmr_prefix_len: 100,
302
303            enable_supersedes_suppression: true,
304            enable_multilingual_routing: true,
305            multilingual_model: None,
306
307            adjustments: default_adjustments(),
308        }
309    }
310}
311
312// ── Server-side DoS caps ──────────────────────────────────────────────────────
313
314/// Maximum candidates a caller may request (server-side cap).
315pub const MAX_RECALL_CANDIDATES: usize = 500;
316/// Maximum token budget a caller may request (server-side cap).
317pub const MAX_TOKEN_BUDGET: usize = 16_000;
318/// Maximum result limit a caller may request (server-side cap).
319pub const MAX_RECALL_LIMIT: usize = 200;
320
321impl ScoringConfig {
322    /// Clamp all DoS-cap fields to their server-side maximums.
323    ///
324    /// Called at the start of `handle_recall` so callers cannot trigger
325    /// unbounded candidate retrieval or token budget consumption.
326    pub fn apply_dos_caps(&mut self) {
327        self.max_recall_candidates = self.max_recall_candidates.min(MAX_RECALL_CANDIDATES);
328        self.default_token_budget = self.default_token_budget.min(MAX_TOKEN_BUDGET);
329        self.default_recall_limit = self.default_recall_limit.min(MAX_RECALL_LIMIT);
330    }
331}
332
333// ── Utility functions ─────────────────────────────────────────────────────────
334
335/// Returns `true` if `c` is a CJK character (Unified, Extension A/B, Hiragana,
336/// Katakana, Hangul).
337#[inline]
338pub fn is_cjk_char(c: char) -> bool {
339    matches!(c,
340        '\u{4E00}'..='\u{9FFF}'       // CJK Unified Ideographs
341        | '\u{3400}'..='\u{4DBF}'     // CJK Extension A
342        | '\u{F900}'..='\u{FAFF}'     // CJK Compatibility Ideographs
343        | '\u{3040}'..='\u{309F}'     // Hiragana
344        | '\u{30A0}'..='\u{30FF}'     // Katakana
345        | '\u{20000}'..='\u{2A6DF}'   // CJK Extension B
346        | '\u{AC00}'..='\u{D7AF}'     // Hangul Syllables
347    )
348}
349
350/// Returns `true` when >15% of the query's characters are CJK.
351pub fn contains_cjk(text: &str) -> bool {
352    let chars: Vec<char> = text.chars().collect();
353    if chars.is_empty() {
354        return false;
355    }
356    let cjk = chars.iter().filter(|&&c| is_cjk_char(c)).count();
357    (cjk as f32) / (chars.len() as f32) > 0.15
358}
359
360/// Returns `true` when `text` should be routed to the multilingual embedding model
361/// for dense retrieval.
362///
363/// Triggers when >15% of **alphabetic** characters are non-ASCII-alphabetic.
364/// Denominator is alphabetic-character count (not total characters), so punctuation,
365/// digits, and whitespace do not dilute the signal. This means `Müller` and
366/// `Müller?` and `Müller!!!` all route identically.
367///
368/// Covers: CJK, Cyrillic, Arabic, Devanagari, Hebrew, Thai, accented-Latin
369/// (é, ü, ñ, …) and any other non-ASCII script Unicode recognises as alphabetic,
370/// without introducing new crate dependencies.
371///
372/// **Known limitation**: ASCII-only non-English Latin (`bonjour le monde`,
373/// `como estas`, `ich suche einen buchhalter`) is NOT detected and routes to the
374/// primary model. Real language detection for that case requires a dedicated crate
375/// and is tracked as a follow-up to issue #101.
376pub fn needs_multilingual(text: &str) -> bool {
377    let alpha_chars: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
378    if alpha_chars.is_empty() {
379        return false;
380    }
381    let non_ascii_alpha = alpha_chars
382        .iter()
383        .filter(|&&c| !c.is_ascii_alphabetic())
384        .count();
385    (non_ascii_alpha as f32) / (alpha_chars.len() as f32) > 0.15
386}
387
388/// Normalize `min_score`: 0–1 passes through, 1–100 divides by 100, others return Err.
389pub fn normalize_min_score(score: f64) -> Result<f32, crate::config::MinScoreError> {
390    if !score.is_finite() {
391        return Err(crate::config::MinScoreError::NotFinite);
392    }
393    if (0.0..=1.0).contains(&score) {
394        return Ok(score as f32);
395    }
396    if (1.0..=100.0).contains(&score) {
397        return Ok((score / 100.0) as f32);
398    }
399    Err(crate::config::MinScoreError::OutOfRange(score))
400}
401
402/// Returns `true` if the query has enough semantic content for meaningful recall.
403pub fn is_meaningful_query(query: &str) -> bool {
404    let trimmed = query.trim();
405    if trimmed.is_empty() {
406        return false;
407    }
408
409    let is_alpha_or_cjk = |c: char| c.is_alphabetic() || is_cjk_char(c);
410
411    let meaningful_chars: usize = trimmed.chars().filter(|c| is_alpha_or_cjk(*c)).count();
412    if meaningful_chars == 0 {
413        return false;
414    }
415
416    let cjk_chars: usize = trimmed.chars().filter(|c| is_cjk_char(*c)).count();
417    if meaningful_chars < 2 && cjk_chars == 0 {
418        return false;
419    }
420
421    // Detect repeated-character patterns: "aaaa", "aaa bbb ccc" (gibberish).
422    let words: Vec<&str> = trimmed
423        .split_whitespace()
424        .filter(|w| w.chars().any(is_alpha_or_cjk))
425        .collect();
426    if !words.is_empty() {
427        let all_repeated = words.iter().all(|w| {
428            let chars: Vec<char> = w.chars().filter(|c| is_alpha_or_cjk(*c)).collect();
429            if chars.len() <= 2 {
430                return false;
431            }
432            let unique: std::collections::HashSet<char> = chars
433                .iter()
434                .map(|c| c.to_lowercase().next().unwrap_or(*c))
435                .collect();
436            (unique.len() as f32) / (chars.len() as f32) < 0.4
437        });
438        if all_repeated {
439            return false;
440        }
441    }
442
443    true
444}
445
446// ── Score normalization ────────────────────────────────────────────────────────
447
448/// Calibrated relevance ceiling for normalized scores.
449///
450/// Prevents the best candidate from entering the 1.0 saturation zone before
451/// temporal, salience, and entity adjustments are applied.
452const NORMALIZED_RELEVANCE_CEILING: f32 = 0.82;
453
454/// RRF score threshold above which the best result is considered a genuine
455/// relevance signal (~rank 20 in dual-source RRF(k=60)).
456const RRF_SIGNAL_THRESHOLD: f32 = 0.025;
457
458/// Normalize raw-cosine or BM25 scores (single-source) into a calibrated relevance band.
459pub fn normalize_rank_fusion_scores(
460    scores: Vec<(Uuid, f32)>,
461    config: &ScoringConfig,
462) -> HashMap<Uuid, f32> {
463    if scores.is_empty() {
464        return HashMap::new();
465    }
466    let min_rrf = config.min_rrf_relevance;
467    let filtered: Vec<(Uuid, f32)> = scores
468        .into_iter()
469        .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
470        .collect();
471    if filtered.is_empty() {
472        return HashMap::new();
473    }
474    let max_score = filtered
475        .iter()
476        .map(|(_, s)| *s)
477        .fold(f32::NEG_INFINITY, f32::max);
478    if !max_score.is_finite() || max_score <= 0.0 {
479        return HashMap::new();
480    }
481    let min_score_seen = filtered
482        .iter()
483        .map(|(_, s)| *s)
484        .fold(f32::INFINITY, f32::min);
485    let span = max_score - min_score_seen;
486    let floor = config
487        .baseline_relevance
488        .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
489    let range = NORMALIZED_RELEVANCE_CEILING - floor;
490
491    let signal_strength = (max_score / RRF_SIGNAL_THRESHOLD).min(1.0);
492
493    filtered
494        .into_iter()
495        .map(|(id, score)| {
496            let calibrated = if span <= f32::EPSILON {
497                max_score.clamp(floor, NORMALIZED_RELEVANCE_CEILING)
498            } else {
499                let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
500                floor + percentile * range
501            };
502            (id, calibrated * signal_strength)
503        })
504        .collect()
505}
506
507/// Normalize RRF-fused scores (dual-source) into a calibrated relevance band.
508pub fn normalize_rrf_scores(
509    scores: Vec<(Uuid, f32)>,
510    config: &ScoringConfig,
511) -> HashMap<Uuid, f32> {
512    if scores.is_empty() {
513        return HashMap::new();
514    }
515    let min_rrf = config.min_rrf_relevance;
516    let filtered: Vec<(Uuid, f32)> = scores
517        .into_iter()
518        .filter(|(_, score)| score.is_finite() && *score >= min_rrf)
519        .collect();
520    if filtered.is_empty() {
521        return HashMap::new();
522    }
523    let max_score = filtered
524        .iter()
525        .map(|(_, s)| *s)
526        .fold(f32::NEG_INFINITY, f32::max);
527    if !max_score.is_finite() || max_score <= 0.0 {
528        return HashMap::new();
529    }
530    let min_score_seen = filtered
531        .iter()
532        .map(|(_, s)| *s)
533        .fold(f32::INFINITY, f32::min);
534    let span = max_score - min_score_seen;
535    let floor = config
536        .baseline_relevance
537        .clamp(0.0, NORMALIZED_RELEVANCE_CEILING);
538    let range = NORMALIZED_RELEVANCE_CEILING - floor;
539
540    filtered
541        .into_iter()
542        .map(|(id, score)| {
543            let calibrated = if span <= f32::EPSILON {
544                floor + range
545            } else {
546                let percentile = ((score - min_score_seen) / span).clamp(0.0, 1.0);
547                floor + percentile * range
548            };
549            (id, calibrated)
550        })
551        .collect()
552}
553
554// ── Composite scoring ─────────────────────────────────────────────────────────
555
556/// Input data for `calculate_score` — groups per-candidate fields to stay
557/// within clippy's 7-argument limit.
558pub struct ScoreInput<'a> {
559    pub salience: f32,
560    pub memory_type_str: &'a str,
561    pub content: &'a str,
562    pub created_at_millis: i64,
563    pub decay_factor: f32,
564    pub now_millis: i64,
565    pub relevance_score: f32,
566    pub entity_names: &'a [String],
567}
568
569/// Composite score for a single memory candidate.
570///
571/// Formula (semantic-gate model, multiplicative):
572///   `score = w_rel × relevance × (1 + w_temp × recency) × (1 + w_imp × salience)`
573///
574/// Then each `ScoreAdjustment` in `config.adjustments` is evaluated and applied in order.
575/// Result is clamped to `[0, 1]`.
576pub fn calculate_score(input: &ScoreInput<'_>, config: &ScoringConfig) -> f32 {
577    let w = &config.weights;
578    let semantic_base = w.relevance * input.relevance_score;
579
580    let time_diff_days = ((input.now_millis - input.created_at_millis) as f32
581        / (24.0 * 60.0 * 60.0 * 1000.0))
582        .max(0.0);
583
584    let capped_decay = input.decay_factor.min(config.decay_cap);
585    let temporal_recency = (-capped_decay * time_diff_days).exp();
586
587    let temporal_boost = 1.0 + w.temporal * temporal_recency;
588    let salience_boost = 1.0 + w.salience * input.salience;
589
590    let mut score = semantic_base * temporal_boost * salience_boost;
591
592    let ctx = CandidateContext {
593        memory_type: input.memory_type_str,
594        age_days: time_diff_days,
595        salience: input.salience,
596        content: input.content,
597        entity_names: input.entity_names,
598    };
599
600    for adj in &config.adjustments {
601        score = adj.apply(score, &ctx);
602    }
603
604    score.clamp(0.0, 1.0)
605}
606
607// ── Tests ─────────────────────────────────────────────────────────────────────
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn is_meaningful_query_rejects_empty() {
615        assert!(!is_meaningful_query(""));
616        assert!(!is_meaningful_query("   "));
617    }
618
619    #[test]
620    fn is_meaningful_query_rejects_symbols_only() {
621        assert!(!is_meaningful_query("!@#$%"));
622        assert!(!is_meaningful_query("..."));
623    }
624
625    #[test]
626    fn is_meaningful_query_rejects_single_latin_char() {
627        assert!(!is_meaningful_query("a"));
628        assert!(!is_meaningful_query("Z"));
629    }
630
631    #[test]
632    fn is_meaningful_query_rejects_repeated_gibberish() {
633        assert!(!is_meaningful_query("aaaa bbbb cccc"));
634    }
635
636    #[test]
637    fn is_meaningful_query_accepts_normal_queries() {
638        assert!(is_meaningful_query("what is the capital of France"));
639        assert!(is_meaningful_query("rust async runtime"));
640        assert!(is_meaningful_query("hello"));
641    }
642
643    #[test]
644    fn contains_cjk_detects_chinese() {
645        // Purely CJK — well above 15% threshold.
646        assert!(contains_cjk("你好世界"));
647        // Mixed string: "世界" = 2 CJK out of 6 chars = 33%, above threshold.
648        assert!(contains_cjk("世界 hi"));
649        // Mostly-Latin with 2 CJK chars out of 15 total = 13% → below threshold.
650        assert!(!contains_cjk("hello 世界 world"));
651    }
652
653    #[test]
654    fn contains_cjk_ignores_latin() {
655        assert!(!contains_cjk("hello world"));
656        assert!(!contains_cjk(""));
657    }
658
659    #[test]
660    fn normalize_min_score_fraction_passthrough() {
661        let v = normalize_min_score(0.5).unwrap();
662        assert!((v - 0.5f32).abs() < 1e-6);
663    }
664
665    #[test]
666    fn normalize_min_score_percent_form() {
667        let v = normalize_min_score(50.0).unwrap();
668        assert!((v - 0.5f32).abs() < 1e-6);
669    }
670
671    #[test]
672    fn normalize_min_score_rejects_out_of_range() {
673        assert!(normalize_min_score(200.0).is_err());
674        assert!(normalize_min_score(-1.0).is_err());
675        assert!(normalize_min_score(f64::NAN).is_err());
676    }
677
678    #[test]
679    fn calculate_score_returns_unit_interval() {
680        let config = ScoringConfig::default();
681        let score = calculate_score(
682            &ScoreInput {
683                salience: 0.9,
684                memory_type_str: "episodic",
685                content: "test content",
686                created_at_millis: 0,
687                decay_factor: 0.01,
688                now_millis: 1000,
689                relevance_score: 0.8,
690                entity_names: &[],
691            },
692            &config,
693        );
694        assert!((0.0..=1.0).contains(&score), "score {score} out of [0,1]");
695    }
696
697    #[test]
698    fn calculate_score_high_salience_ranks_higher() {
699        let config = ScoringConfig {
700            adjustments: vec![],
701            ..ScoringConfig::default()
702        };
703        let now_ms = 1_000_000i64;
704        let score_high = calculate_score(
705            &ScoreInput {
706                salience: 0.9,
707                memory_type_str: "episodic",
708                content: "content",
709                created_at_millis: 0,
710                decay_factor: 0.01,
711                now_millis: now_ms,
712                relevance_score: 0.7,
713                entity_names: &[],
714            },
715            &config,
716        );
717        let score_low = calculate_score(
718            &ScoreInput {
719                salience: 0.1,
720                memory_type_str: "episodic",
721                content: "content",
722                created_at_millis: 0,
723                decay_factor: 0.01,
724                now_millis: now_ms,
725                relevance_score: 0.7,
726                entity_names: &[],
727            },
728            &config,
729        );
730        assert!(score_high > score_low, "high salience should rank higher");
731    }
732
733    #[test]
734    fn dos_caps_enforce_limits() {
735        let mut config = ScoringConfig {
736            max_recall_candidates: 9999,
737            default_token_budget: 99999,
738            default_recall_limit: 9999,
739            ..ScoringConfig::default()
740        };
741        config.apply_dos_caps();
742        assert_eq!(config.max_recall_candidates, MAX_RECALL_CANDIDATES);
743        assert_eq!(config.default_token_budget, MAX_TOKEN_BUDGET);
744        assert_eq!(config.default_recall_limit, MAX_RECALL_LIMIT);
745    }
746
747    #[test]
748    fn normalize_rrf_scores_preserves_ordering() {
749        let config = ScoringConfig::default();
750        let input = vec![
751            (Uuid::new_v4(), 0.030f32),
752            (Uuid::new_v4(), 0.020f32),
753            (Uuid::new_v4(), 0.015f32),
754        ];
755        let ids: Vec<Uuid> = input.iter().map(|(id, _)| *id).collect();
756        let output = normalize_rrf_scores(input, &config);
757        let s0 = output[&ids[0]];
758        let s1 = output[&ids[1]];
759        let s2 = output[&ids[2]];
760        assert!(s0 > s1 && s1 > s2, "ordering must be preserved");
761        assert!(s0 <= 1.0 && s2 >= 0.0, "scores must be in [0,1]");
762    }
763
764    // ── needs_multilingual ────────────────────────────────────────────────────
765
766    #[test]
767    fn needs_multilingual_ascii_english_routes_primary() {
768        assert!(!needs_multilingual("hello world"));
769        assert!(!needs_multilingual("rust programming language"));
770        assert!(!needs_multilingual(""));
771        assert!(!needs_multilingual("42 items in the list"));
772    }
773
774    #[test]
775    fn needs_multilingual_cjk_routes_multilingual() {
776        // Pure CJK: 4/4 = 100% non-ASCII alpha → well above 15%.
777        assert!(needs_multilingual("你好世界"));
778        // Mixed: 2 CJK out of 5 chars = 40%.
779        assert!(needs_multilingual("abc你好"));
780    }
781
782    #[test]
783    fn needs_multilingual_cyrillic_routes_multilingual() {
784        // "Привет мир" (Hello world in Russian): all Cyrillic alphabetic.
785        assert!(needs_multilingual("Привет мир"));
786        // Single Cyrillic word mixed with Latin — Г alone is 1/7 ≈ 14% (below threshold).
787        // Verify with a word that has enough Cyrillic chars to cross 15%.
788        assert!(needs_multilingual("Привет hello")); // Привет = 6 non-ASCII alpha, total chars including space = 12 → 6/12 = 50%
789    }
790
791    #[test]
792    fn needs_multilingual_arabic_routes_multilingual() {
793        // "مرحبا" (Hello in Arabic): all Arabic alphabetic.
794        assert!(needs_multilingual("مرحبا بالعالم"));
795    }
796
797    #[test]
798    fn needs_multilingual_devanagari_routes_multilingual() {
799        // "नमस्ते" (Namaste in Hindi/Devanagari).
800        assert!(needs_multilingual("नमस्ते दुनिया"));
801    }
802
803    #[test]
804    fn needs_multilingual_accented_latin_routes_multilingual() {
805        // French: "café résumé" — é is alphabetic and non-ASCII.
806        // Alphabetic chars: c,a,f,é,r,é,s,u,m,é = 10; non-ASCII alpha: 3; 3/10 = 30% > 15%.
807        assert!(needs_multilingual("café résumé"));
808        // German: "Müller" — ü is 1/6 alpha = 16.7% > 15%.
809        assert!(needs_multilingual("Müller"));
810        // Spanish: "niño" — ñ is 1/4 alpha = 25% > 15%.
811        assert!(needs_multilingual("niño"));
812    }
813
814    #[test]
815    fn needs_multilingual_alphabetic_denominator_is_stable_under_punctuation() {
816        // INVARIANT 2: punctuation/digits must not change routing decision.
817        // "Müller" alpha: M,ü,l,l,e,r = 6; non-ASCII alpha: ü = 1; 1/6 = 16.7% > 15%.
818        assert!(needs_multilingual("Müller"));
819        // "Müller?" — same 6 alpha chars; ? is not alphabetic; 1/6 = 16.7% → still routes.
820        assert!(needs_multilingual("Müller?"));
821        // "Müller!!!" — same 6 alpha chars; 1/6 = 16.7% → still routes.
822        assert!(needs_multilingual("Müller!!!"));
823        // Digit-heavy short accented query: "42 Ü" — alpha: Ü = 1; non-ASCII: 1; 1/1 = 100%.
824        assert!(needs_multilingual("42 Ü"));
825        // Empty / no-alpha: must return false without divide-by-zero.
826        assert!(!needs_multilingual(""));
827        assert!(!needs_multilingual("42 100 ???"));
828    }
829
830    #[test]
831    fn needs_multilingual_pure_ascii_accented_below_threshold_routes_primary() {
832        // "hello naïve" — ï is 1 non-ASCII alpha out of 10 alpha = 10% < 15% → primary.
833        // (Deliberate design decision: a single diacritic in an otherwise English
834        // sentence does not warrant multilingual routing.)
835        assert!(!needs_multilingual("hello naive")); // no diacritics, definitely primary
836                                                     // "über" — ü is 1/4 alpha = 25% → routes multilingual (German word).
837        assert!(needs_multilingual("über"));
838    }
839
840    #[test]
841    fn needs_multilingual_ascii_only_non_english_latin_routes_primary_known_limitation() {
842        // INVARIANT 4: ASCII-only non-English Latin is NOT detected here.
843        // Real language detection is tracked as a follow-up to issue #101.
844        assert!(!needs_multilingual("bonjour le monde"));
845        assert!(!needs_multilingual("como estas"));
846        assert!(!needs_multilingual("ich suche einen buchhalter"));
847    }
848}