Skip to main content

khive_pack_memory/
rerank.rs

1use std::collections::HashMap;
2
3/// Input features available per recall candidate for weighted reranking.
4///
5/// Each field is a normalized scalar in roughly [0, 1] (though `temporal` and
6/// `salience` can technically exceed 1.0 only if the raw salience > 1.0 or age < 0,
7/// neither of which the runtime produces in practice).
8///
9/// Supported feature names (keys in `reranker_weights`):
10/// - `"relevance"` — fused retrieval score (RRF/weighted fusion output)
11/// - `"salience"` — note salience after decay (`salience * exp(-k * age)`)
12/// - `"temporal"` — recency score (`exp(-ln2/half_life * age_days)`)
13/// - `"text_match"` — 1.0 when candidate appeared in FTS text results, else 0.0
14/// - `"vector_match"` — 1.0 when candidate appeared in vector results, else 0.0
15#[derive(Debug, Clone)]
16pub struct RerankFeatures {
17    /// Fused retrieval score from RRF or weighted fusion.
18    pub relevance: f64,
19    /// Decay-adjusted salience value (raw salience × decay factor).
20    pub salience: f64,
21    /// Half-life–decay recency score independent of per-note decay_factor.
22    pub temporal: f64,
23    /// True when candidate appeared in FTS text search results.
24    pub text_match: bool,
25    /// True when candidate appeared in vector search results.
26    pub vector_match: bool,
27}
28
29/// Compute a weighted feature-combination rerank score, normalized by the sum of
30/// recognized positive weights.
31///
32/// Returns `Σ(weight × feature_value) / Σ(weight)` over all recognized feature
33/// names whose weight is > 0.  Dividing by the weight sum makes the output
34/// scale-invariant: doubling every weight leaves the score unchanged, which is the
35/// expected behavior for a comparison-stable ranking function.
36///
37/// Unrecognized keys are silently ignored — this lets callers add future feature
38/// names without breaking existing deployments that haven't upgraded yet.  Ignored
39/// keys do NOT contribute to the weight sum.
40///
41/// # Empty weights
42///
43/// When `weights` is empty (or all entries are zero / unrecognized) this returns
44/// `0.0`. The caller (`handle_recall`) uses this case as a passthrough signal: if
45/// `reranker_weights` is empty, skip reranking entirely and fall through to
46/// `compute_score`. The `0.0` return value itself is never used when weights are
47/// absent.
48///
49/// # Score range
50///
51/// Because features are in [0, 1] by construction and the score is normalized by
52/// the positive weight sum, the output is in [0, 1] for any non-negative weight
53/// configuration.  Negative weights are accepted (they reduce scores for the
54/// associated feature) but are excluded from the denominator — only positive
55/// weights normalize the result.
56pub fn weighted_rerank(features: &RerankFeatures, weights: &HashMap<String, f64>) -> f64 {
57    let mut numerator = 0.0_f64;
58    let mut weight_sum = 0.0_f64;
59    for (name, &weight) in weights {
60        if weight == 0.0 {
61            continue;
62        }
63        let feature_value = match name.as_str() {
64            "relevance" => features.relevance,
65            "salience" => features.salience,
66            "temporal" => features.temporal,
67            "text_match" => f64::from(features.text_match),
68            "vector_match" => f64::from(features.vector_match),
69            // Unknown feature names are silently ignored to allow forward-compat.
70            _ => continue,
71        };
72        numerator += weight * feature_value;
73        if weight > 0.0 {
74            weight_sum += weight;
75        }
76    }
77    if weight_sum == 0.0 {
78        return 0.0;
79    }
80    numerator / weight_sum
81}
82
83// ── Tests ─────────────────────────────────────────────────────────────────────
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn features() -> RerankFeatures {
90        RerankFeatures {
91            relevance: 0.8,
92            salience: 0.6,
93            temporal: 0.4,
94            text_match: true,
95            vector_match: false,
96        }
97    }
98
99    #[test]
100    fn empty_weights_returns_zero() {
101        let score = weighted_rerank(&features(), &HashMap::new());
102        assert_eq!(score, 0.0, "empty weights must return 0.0");
103    }
104
105    #[test]
106    fn single_relevance_weight_produces_expected_score() {
107        let weights: HashMap<String, f64> = [("relevance".to_string(), 1.0)].into_iter().collect();
108        let score = weighted_rerank(&features(), &weights);
109        let diff = (score - 0.8).abs();
110        assert!(
111            diff < 1e-12,
112            "relevance weight=1.0 on relevance=0.8 should give 0.8, got {score}"
113        );
114    }
115
116    #[test]
117    fn single_salience_weight_produces_expected_score() {
118        // After normalization: (2.0 * 0.6) / 2.0 = 0.6 — the weight magnitude
119        // cancels out; only the feature value remains.
120        let weights: HashMap<String, f64> = [("salience".to_string(), 2.0)].into_iter().collect();
121        let score = weighted_rerank(&features(), &weights);
122        let diff = (score - 0.6).abs();
123        assert!(
124            diff < 1e-12,
125            "salience weight=2.0 on salience=0.6 should normalize to 0.6, got {score}"
126        );
127    }
128
129    #[test]
130    fn multi_feature_weight_produces_expected_combination() {
131        // relevance*0.5 + salience*0.3 + temporal*0.2
132        // = 0.8*0.5 + 0.6*0.3 + 0.4*0.2
133        // = 0.40 + 0.18 + 0.08 = 0.66
134        let weights: HashMap<String, f64> = [
135            ("relevance".to_string(), 0.5),
136            ("salience".to_string(), 0.3),
137            ("temporal".to_string(), 0.2),
138        ]
139        .into_iter()
140        .collect();
141        let score = weighted_rerank(&features(), &weights);
142        let diff = (score - 0.66).abs();
143        assert!(
144            diff < 1e-12,
145            "multi-feature combination should give 0.66, got {score}"
146        );
147    }
148
149    #[test]
150    fn boolean_text_match_feature() {
151        // text_match=true → 1.0; vector_match=false → 0.0
152        // Normalized: (1.0*0.1 + 0.0*0.5) / (0.1 + 0.5) = 0.1 / 0.6 ≈ 0.16667
153        let weights: HashMap<String, f64> = [
154            ("text_match".to_string(), 0.1),
155            ("vector_match".to_string(), 0.5),
156        ]
157        .into_iter()
158        .collect();
159        let score = weighted_rerank(&features(), &weights);
160        let expected = 0.1_f64 / 0.6_f64;
161        let diff = (score - expected).abs();
162        assert!(
163            diff < 1e-12,
164            "boolean features: (text_match*0.1 + vector_match*0.5) / 0.6 ≈ 0.16667, got {score}"
165        );
166    }
167
168    #[test]
169    fn unknown_feature_key_is_silently_ignored() {
170        let weights: HashMap<String, f64> = [
171            ("relevance".to_string(), 1.0),
172            ("future_feature_xyz".to_string(), 999.0),
173        ]
174        .into_iter()
175        .collect();
176        let score = weighted_rerank(&features(), &weights);
177        // Only relevance should contribute: 0.8*1.0 = 0.8
178        let diff = (score - 0.8).abs();
179        assert!(
180            diff < 1e-12,
181            "unknown key should be ignored, expected 0.8, got {score}"
182        );
183    }
184
185    #[test]
186    fn zero_weight_entry_is_skipped() {
187        let weights: HashMap<String, f64> = [
188            ("relevance".to_string(), 0.0),
189            ("salience".to_string(), 1.0),
190        ]
191        .into_iter()
192        .collect();
193        let score = weighted_rerank(&features(), &weights);
194        // Only salience contributes: (0.6*1.0) / 1.0 = 0.6
195        let diff = (score - 0.6).abs();
196        assert!(
197            diff < 1e-12,
198            "zero-weight key should not contribute, expected 0.6, got {score}"
199        );
200    }
201
202    /// Normalization: doubling all weights must NOT change the output score.
203    /// Un-normalized: (0.8*2.0 + 0.6*0.6) / 1 = 1.96 — clearly wrong.
204    /// Normalized:    (0.8*2.0 + 0.6*0.6) / (2.0 + 0.6) = same as weight 1.0 + 0.3.
205    #[test]
206    fn doubling_all_weights_does_not_change_score() {
207        let weights_1x: HashMap<String, f64> = [
208            ("relevance".to_string(), 1.0),
209            ("salience".to_string(), 0.3),
210        ]
211        .into_iter()
212        .collect();
213        let weights_2x: HashMap<String, f64> = [
214            ("relevance".to_string(), 2.0),
215            ("salience".to_string(), 0.6),
216        ]
217        .into_iter()
218        .collect();
219        let score_1x = weighted_rerank(&features(), &weights_1x);
220        let score_2x = weighted_rerank(&features(), &weights_2x);
221        let diff = (score_1x - score_2x).abs();
222        assert!(
223            diff < 1e-12,
224            "doubling all weights must produce identical score: 1x={score_1x} 2x={score_2x}"
225        );
226    }
227
228    /// Normalization: a single weight of any positive magnitude returns the feature
229    /// value directly (the weight cancels out in numerator / denominator).
230    #[test]
231    fn single_weight_of_any_magnitude_returns_feature_value() {
232        let f = features(); // relevance=0.8
233        for &mag in &[0.5_f64, 1.0, 2.0, 100.0] {
234            let weights: HashMap<String, f64> =
235                [("relevance".to_string(), mag)].into_iter().collect();
236            let score = weighted_rerank(&f, &weights);
237            let diff = (score - f.relevance).abs();
238            assert!(
239                diff < 1e-12,
240                "single weight={mag}: expected feature value {}, got {score}",
241                f.relevance
242            );
243        }
244    }
245}