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
12use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17// ── Adjustment conditions ─────────────────────────────────────────────────────
18
19/// A condition that determines whether a score adjustment applies to a candidate.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum AdjustmentCondition {
23    /// Match by memory type ("episodic" or "semantic").
24    MemoryType { kind: String },
25    /// Match by age in days. Both bounds are optional (omit = no bound).
26    AgeRange {
27        #[serde(default)]
28        min_days: Option<f32>,
29        #[serde(default)]
30        max_days: Option<f32>,
31    },
32    /// Match by salience score. Both bounds are optional.
33    SalienceRange {
34        #[serde(default)]
35        min: Option<f32>,
36        #[serde(default)]
37        max: Option<f32>,
38    },
39    /// Match when query entity names appear in memory content.
40    EntityMatch,
41    /// Match when query entity names do NOT appear in memory content.
42    EntityMiss,
43    /// All sub-conditions must be true (conjunction).
44    All {
45        conditions: Vec<AdjustmentCondition>,
46    },
47}
48
49/// The operation to apply when a condition matches.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum AdjustmentOp {
53    /// Add a fixed value to the score.
54    Add { value: f32 },
55    /// Subtract a fixed value from the score.
56    Subtract { value: f32 },
57    /// Multiply the score by a factor.
58    Multiply { factor: f32 },
59}
60
61/// A conditional score adjustment: if `condition` matches, apply `operation`.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ScoreAdjustment {
64    pub condition: AdjustmentCondition,
65    pub operation: AdjustmentOp,
66}
67
68/// Context passed to condition evaluation — properties of the candidate + query.
69pub 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
146/// Default score adjustments: episodic recency bonus, semantic age penalty, entity boost.
147pub fn default_adjustments() -> Vec<ScoreAdjustment> {
148    vec![
149        // Episodic recency bonus: recent episodic memories get an additive boost.
150        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        // Semantic age penalty: old high-salience semantic memories get penalized
165        // to prevent reference docs from crowding out episodic content.
166        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        // Entity match boost: memories mentioning queried entities get boosted.
185        ScoreAdjustment {
186            condition: AdjustmentCondition::EntityMatch,
187            operation: AdjustmentOp::Multiply { factor: 1.3 },
188        },
189    ]
190}
191
192// ── Scoring weights ───────────────────────────────────────────────────────────
193
194/// Weights for the combined memory score (multiplicative model).
195///
196/// Semantic relevance is a **gate** (multiplicative base). Temporal recency
197/// and salience are **boosters** applied on top: `score = w_rel × relevance
198/// × (1 + w_temp × recency) × (1 + w_imp × salience)`.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(default)]
201pub struct ScoringWeights {
202    /// Multiplicative boost from salience in `(1 + w_imp × salience)`. Default: 0.2.
203    pub salience: f32,
204    /// Multiplicative boost from recency in `(1 + w_temp × recency)`. Default: 0.1.
205    pub temporal: f32,
206    /// Base multiplier applied to relevance. Default: 0.7.
207    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// ── ScoringConfig ─────────────────────────────────────────────────────────────
221
222/// Complete, fully-tunable scoring configuration for the memory recall pipeline.
223///
224/// All fields are `pub` and serde-friendly so tuning agents can sweep them
225/// without code changes. `Default::default()` reproduces the v1 archive behavior.
226///
227/// DoS caps: `max_recall_candidates ≤ 500`, `default_token_budget ≤ 16000`,
228/// `default_recall_limit ≤ 200`. The handler enforces these caps server-side
229/// even when a caller provides a `ScoringConfig` override.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(default)]
232pub struct ScoringConfig {
233    // ── Composite weights ──────────────────────────────────────────────────
234    pub weights: ScoringWeights,
235
236    // ── Relevance thresholds ───────────────────────────────────────────────
237    /// Minimum raw cosine similarity to include a vector hit. Hits below this
238    /// are excluded before RRF fusion (#2272). Default: 0.10.
239    pub min_raw_relevance: f32,
240    /// Minimum RRF score after fusion before normalization. Default: 0.0.
241    pub min_rrf_relevance: f32,
242    /// Relevance floor for the min-max normalization band. Default: 0.15.
243    pub baseline_relevance: f32,
244
245    // ── Temporal decay ─────────────────────────────────────────────────────
246    /// Upper cap on per-entry decay_factor before temporal recency calculation.
247    /// Default: 0.05.
248    pub decay_cap: f32,
249
250    // ── DoS caps (enforced server-side) ───────────────────────────────────
251    /// Maximum search candidates to retrieve. Server-side cap: 500. Default: 200.
252    pub max_recall_candidates: usize,
253    /// Default result limit when caller doesn't specify. Server-side cap: 200.
254    /// Default: 10.
255    pub default_recall_limit: usize,
256    /// Default token budget (tokens). Server-side cap: 16000. Default: 4000.
257    pub default_token_budget: usize,
258    /// Approximate characters per token (for token budget). Default: 4.
259    pub chars_per_token: usize,
260
261    // ── MMR diversity penalty ──────────────────────────────────────────────
262    /// Score penalty applied to results whose first `mmr_prefix_len` characters
263    /// match an earlier result. Default: 0.1.
264    pub mmr_penalty: f32,
265    /// Character prefix length used for MMR duplicate detection. Default: 100.
266    pub mmr_prefix_len: usize,
267
268    // ── Feature toggles ────────────────────────────────────────────────────
269    /// When true, suppress memories whose `properties.supersedes` value matches
270    /// the ID of another memory in the result set. Default: true.
271    pub enable_supersedes_suppression: bool,
272    /// When true and a multilingual embedding model is registered, route CJK
273    /// queries to it as the primary model. Default: true.
274    pub enable_cjk_routing: bool,
275    /// Name of the multilingual embedding model to use for CJK routing.
276    /// When None, the handler checks registered model names for substrings
277    /// "multilingual" or "paraphrase". Default: None.
278    pub cjk_model: Option<String>,
279
280    // ── Conditional adjustments ────────────────────────────────────────────
281    /// Score adjustments applied after the base formula. Default: episodic bonus,
282    /// semantic age penalty, entity boost.
283    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
314// ── Server-side DoS caps ──────────────────────────────────────────────────────
315
316/// Maximum candidates a caller may request (server-side cap).
317pub const MAX_RECALL_CANDIDATES: usize = 500;
318/// Maximum token budget a caller may request (server-side cap).
319pub const MAX_TOKEN_BUDGET: usize = 16_000;
320/// Maximum result limit a caller may request (server-side cap).
321pub const MAX_RECALL_LIMIT: usize = 200;
322
323impl ScoringConfig {
324    /// Clamp all DoS-cap fields to their server-side maximums.
325    ///
326    /// Called at the start of `handle_recall` so callers cannot trigger
327    /// unbounded candidate retrieval or token budget consumption.
328    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// ── Utility functions ─────────────────────────────────────────────────────────
336
337/// Returns `true` if `c` is a CJK character (Unified, Extension A/B, Hiragana,
338/// Katakana, Hangul).
339#[inline]
340pub fn is_cjk_char(c: char) -> bool {
341    matches!(c,
342        '\u{4E00}'..='\u{9FFF}'       // CJK Unified Ideographs
343        | '\u{3400}'..='\u{4DBF}'     // CJK Extension A
344        | '\u{F900}'..='\u{FAFF}'     // CJK Compatibility Ideographs
345        | '\u{3040}'..='\u{309F}'     // Hiragana
346        | '\u{30A0}'..='\u{30FF}'     // Katakana
347        | '\u{20000}'..='\u{2A6DF}'   // CJK Extension B
348        | '\u{AC00}'..='\u{D7AF}'     // Hangul Syllables
349    )
350}
351
352/// Returns `true` when >15% of the query's characters are CJK.
353pub 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
362/// Normalize `min_score` from caller-supplied value.
363///
364/// Accepts:
365/// - `0.0..=1.0` → passes through as-is (fraction form)
366/// - `1.0..=100.0` → divides by 100 (percent form)
367/// - anything else (NaN, Inf, negative, >100) → returns `Err`
368pub 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
381/// Returns `true` if the query has enough semantic content to produce meaningful recall.
382///
383/// Rejects: whitespace-only, symbols-only, single-char (except CJK), and
384/// repeated-character gibberish ("aaaa bbbb").
385pub 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    // Detect repeated-character patterns: "aaaa", "aaa bbb ccc" (gibberish).
404    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
428// ── Score normalization ────────────────────────────────────────────────────────
429
430/// Calibrated relevance ceiling for normalized scores.
431///
432/// Prevents the best candidate from entering the 1.0 saturation zone before
433/// temporal, salience, and entity adjustments are applied.
434const NORMALIZED_RELEVANCE_CEILING: f32 = 0.82;
435
436/// RRF score threshold above which the best result is considered a genuine
437/// relevance signal (~rank 20 in dual-source RRF(k=60)).
438const RRF_SIGNAL_THRESHOLD: f32 = 0.025;
439
440/// Normalize raw-cosine or BM25 scores (single-source) into a calibrated relevance band.
441///
442/// Applies `signal_strength` scaling when the best score is weak — prevents
443/// min-max normalization from inflating truly irrelevant results on small corpora.
444pub 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
492/// Normalize RRF-fused scores (dual-source) into a calibrated relevance band.
493///
494/// Unlike `normalize_rank_fusion_scores`, does NOT apply `signal_strength`:
495/// RRF scores are rank-based and their absolute magnitude carries no signal
496/// about query quality, so the scaling factor would cause cross-query inversions.
497pub 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
543// ── Composite scoring ─────────────────────────────────────────────────────────
544
545/// Input data for `calculate_score` — groups per-candidate fields to stay
546/// within clippy's 7-argument limit.
547pub 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
558/// Composite score for a single memory candidate.
559///
560/// Formula (semantic-gate model, multiplicative):
561///   `score = w_rel × relevance × (1 + w_temp × recency) × (1 + w_imp × salience)`
562///
563/// Then each `ScoreAdjustment` in `config.adjustments` is evaluated and applied in order.
564/// Result is clamped to `[0, 1]`.
565pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
597
598#[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        // Purely CJK — well above 15% threshold.
635        assert!(contains_cjk("你好世界"));
636        // Mixed string: "世界" = 2 CJK out of 6 chars = 33%, above threshold.
637        assert!(contains_cjk("世界 hi"));
638        // Mostly-Latin with 2 CJK chars out of 15 total = 13% → below threshold.
639        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}