Skip to main content

khive_pack_memory/
rerank.rs

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