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