Skip to main content

khive_pack_memory/
rerank.rs

1//! Weighted feature-combination reranking for memory recall candidates.
2
3use std::collections::HashMap;
4
5/// Input features per recall candidate for weighted reranking (relevance, salience, temporal, text_match, vector_match).
6#[derive(Debug, Clone)]
7pub struct RerankFeatures {
8    /// Fused retrieval score from RRF or weighted fusion.
9    pub relevance: f64,
10    /// Decay-adjusted salience value (raw salience × decay factor).
11    pub salience: f64,
12    /// Half-life–decay recency score independent of per-note decay_factor.
13    pub temporal: f64,
14    /// True when candidate appeared in FTS text search results.
15    pub text_match: bool,
16    /// True when candidate appeared in vector search results.
17    pub vector_match: bool,
18}
19
20/// Weighted feature-combination rerank score: `Σ(weight × feature) / Σ(positive_weight)`.
21/// Returns 0.0 when weights are empty or all unrecognized.
22pub fn weighted_rerank(features: &RerankFeatures, weights: &HashMap<String, f64>) -> f64 {
23    let mut numerator = 0.0_f64;
24    let mut weight_sum = 0.0_f64;
25    for (name, &weight) in weights {
26        if weight == 0.0 {
27            continue;
28        }
29        let feature_value = match name.as_str() {
30            "relevance" => features.relevance,
31            "salience" => features.salience,
32            "temporal" => features.temporal,
33            "text_match" => f64::from(features.text_match),
34            "vector_match" => f64::from(features.vector_match),
35            // Unknown feature names are silently ignored to allow forward-compat.
36            _ => continue,
37        };
38        numerator += weight * feature_value;
39        if weight > 0.0 {
40            weight_sum += weight;
41        }
42    }
43    if weight_sum == 0.0 {
44        return 0.0;
45    }
46    numerator / weight_sum
47}
48
49// ── Tests ─────────────────────────────────────────────────────────────────────
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    fn features() -> RerankFeatures {
56        RerankFeatures {
57            relevance: 0.8,
58            salience: 0.6,
59            temporal: 0.4,
60            text_match: true,
61            vector_match: false,
62        }
63    }
64
65    #[test]
66    fn empty_weights_returns_zero() {
67        let score = weighted_rerank(&features(), &HashMap::new());
68        assert_eq!(score, 0.0, "empty weights must return 0.0");
69    }
70
71    #[test]
72    fn single_relevance_weight_produces_expected_score() {
73        let weights: HashMap<String, f64> = [("relevance".to_string(), 1.0)].into_iter().collect();
74        let score = weighted_rerank(&features(), &weights);
75        let diff = (score - 0.8).abs();
76        assert!(
77            diff < 1e-12,
78            "relevance weight=1.0 on relevance=0.8 should give 0.8, got {score}"
79        );
80    }
81
82    #[test]
83    fn single_salience_weight_produces_expected_score() {
84        // After normalization: (2.0 * 0.6) / 2.0 = 0.6 — the weight magnitude
85        // cancels out; only the feature value remains.
86        let weights: HashMap<String, f64> = [("salience".to_string(), 2.0)].into_iter().collect();
87        let score = weighted_rerank(&features(), &weights);
88        let diff = (score - 0.6).abs();
89        assert!(
90            diff < 1e-12,
91            "salience weight=2.0 on salience=0.6 should normalize to 0.6, got {score}"
92        );
93    }
94
95    #[test]
96    fn multi_feature_weight_produces_expected_combination() {
97        // relevance*0.5 + salience*0.3 + temporal*0.2
98        // = 0.8*0.5 + 0.6*0.3 + 0.4*0.2
99        // = 0.40 + 0.18 + 0.08 = 0.66
100        let weights: HashMap<String, f64> = [
101            ("relevance".to_string(), 0.5),
102            ("salience".to_string(), 0.3),
103            ("temporal".to_string(), 0.2),
104        ]
105        .into_iter()
106        .collect();
107        let score = weighted_rerank(&features(), &weights);
108        let diff = (score - 0.66).abs();
109        assert!(
110            diff < 1e-12,
111            "multi-feature combination should give 0.66, got {score}"
112        );
113    }
114
115    #[test]
116    fn boolean_text_match_feature() {
117        // text_match=true → 1.0; vector_match=false → 0.0
118        // Normalized: (1.0*0.1 + 0.0*0.5) / (0.1 + 0.5) = 0.1 / 0.6 ≈ 0.16667
119        let weights: HashMap<String, f64> = [
120            ("text_match".to_string(), 0.1),
121            ("vector_match".to_string(), 0.5),
122        ]
123        .into_iter()
124        .collect();
125        let score = weighted_rerank(&features(), &weights);
126        let expected = 0.1_f64 / 0.6_f64;
127        let diff = (score - expected).abs();
128        assert!(
129            diff < 1e-12,
130            "boolean features: (text_match*0.1 + vector_match*0.5) / 0.6 ≈ 0.16667, got {score}"
131        );
132    }
133
134    #[test]
135    fn unknown_feature_key_is_silently_ignored() {
136        let weights: HashMap<String, f64> = [
137            ("relevance".to_string(), 1.0),
138            ("future_feature_xyz".to_string(), 999.0),
139        ]
140        .into_iter()
141        .collect();
142        let score = weighted_rerank(&features(), &weights);
143        // Only relevance should contribute: 0.8*1.0 = 0.8
144        let diff = (score - 0.8).abs();
145        assert!(
146            diff < 1e-12,
147            "unknown key should be ignored, expected 0.8, got {score}"
148        );
149    }
150
151    #[test]
152    fn zero_weight_entry_is_skipped() {
153        let weights: HashMap<String, f64> = [
154            ("relevance".to_string(), 0.0),
155            ("salience".to_string(), 1.0),
156        ]
157        .into_iter()
158        .collect();
159        let score = weighted_rerank(&features(), &weights);
160        // Only salience contributes: (0.6*1.0) / 1.0 = 0.6
161        let diff = (score - 0.6).abs();
162        assert!(
163            diff < 1e-12,
164            "zero-weight key should not contribute, expected 0.6, got {score}"
165        );
166    }
167
168    /// Normalization: doubling all weights must NOT change the output score.
169    /// Un-normalized: (0.8*2.0 + 0.6*0.6) / 1 = 1.96 — clearly wrong.
170    /// Normalized:    (0.8*2.0 + 0.6*0.6) / (2.0 + 0.6) = same as weight 1.0 + 0.3.
171    #[test]
172    fn doubling_all_weights_does_not_change_score() {
173        let weights_1x: HashMap<String, f64> = [
174            ("relevance".to_string(), 1.0),
175            ("salience".to_string(), 0.3),
176        ]
177        .into_iter()
178        .collect();
179        let weights_2x: HashMap<String, f64> = [
180            ("relevance".to_string(), 2.0),
181            ("salience".to_string(), 0.6),
182        ]
183        .into_iter()
184        .collect();
185        let score_1x = weighted_rerank(&features(), &weights_1x);
186        let score_2x = weighted_rerank(&features(), &weights_2x);
187        let diff = (score_1x - score_2x).abs();
188        assert!(
189            diff < 1e-12,
190            "doubling all weights must produce identical score: 1x={score_1x} 2x={score_2x}"
191        );
192    }
193
194    /// Normalization: a single weight of any positive magnitude returns the feature
195    /// value directly (the weight cancels out in numerator / denominator).
196    #[test]
197    fn single_weight_of_any_magnitude_returns_feature_value() {
198        let f = features(); // relevance=0.8
199        for &mag in &[0.5_f64, 1.0, 2.0, 100.0] {
200            let weights: HashMap<String, f64> =
201                [("relevance".to_string(), mag)].into_iter().collect();
202            let score = weighted_rerank(&f, &weights);
203            let diff = (score - f.relevance).abs();
204            assert!(
205                diff < 1e-12,
206                "single weight={mag}: expected feature value {}, got {score}",
207                f.relevance
208            );
209        }
210    }
211}