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