Skip to main content

lean_ctx/core/extractive/
ranker.rs

1//! Embedding-based scoring and budgeted MMR selection for extractive prose
2//! compression.
3//!
4//! Two scoring modes:
5//! * **Centrality** (query-free, universally safe): each segment scores as the
6//!   mean cosine similarity to every other segment — LexRank-style degree
7//!   centrality. Keeps the most representative sentences, drops the peripheral
8//!   and the redundant. Safe even for system prompts because nothing is judged
9//!   "irrelevant", only "less central".
10//! * **Query**: each segment scores as cosine similarity to an anchor embedding
11//!   (the task / most-recent user message). Used on RAG / research / tool paths.
12//!
13//! Selection is greedy by quantized score with an original-index tiebreak and a
14//! Maximal-Marginal-Relevance redundancy gate (cosine ≥ [`REDUNDANCY_COSINE`] to
15//! an already-kept segment ⇒ skip), reusing [`ScoringCtx`]. Protected segments
16//! are always kept first. The returned indices are sorted, so the caller emits
17//! kept segments in their ORIGINAL order. Deterministic by construction.
18
19use super::segment::Segment;
20use crate::core::embeddings::cosine_similarity;
21use crate::core::surprise::ScoringCtx;
22
23/// Cosine at or above which two segments are treated as semantic duplicates and
24/// the later (lower-ranked) one is dropped. Matches the redundancy threshold the
25/// entropy read path already uses (`core::surprise`).
26const REDUNDANCY_COSINE: f64 = 0.92;
27
28/// Which signal drives segment scoring.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum RankMode {
31    /// Query-free mean-cosine centrality. The default; never drops a segment for
32    /// being "off-topic", so it is safe on system/user instructions.
33    Centrality,
34    /// Cosine to an anchor embedding (task / recent user query).
35    Query,
36}
37
38/// Fixed-precision score quantization. Tiny floating-point jitter must never be
39/// able to reorder two near-equal scores, or the output would not be
40/// byte-stable (#498). `1e-4` is finer than any meaningful cosine gap yet coarse
41/// enough to absorb residual FP noise from batched inference.
42fn quantize(score: f32) -> i64 {
43    (f64::from(score) * 10_000.0).round() as i64
44}
45
46/// Mean cosine of each embedding to every OTHER embedding (degree centrality).
47/// `O(n²·d)`; the caller caps `n` (see [`super::MAX_SEGMENTS`]).
48pub(super) fn centrality_scores(embs: &[Vec<f32>]) -> Vec<f32> {
49    let n = embs.len();
50    if n <= 1 {
51        return vec![0.0; n];
52    }
53    let mut scores = vec![0.0f32; n];
54    for i in 0..n {
55        let mut sum = 0.0f32;
56        for j in 0..n {
57            if i != j {
58                sum += cosine_similarity(&embs[i], &embs[j]);
59            }
60        }
61        scores[i] = sum / (n as f32 - 1.0);
62    }
63    scores
64}
65
66/// Cosine of each embedding to the query anchor.
67pub(super) fn query_scores(embs: &[Vec<f32>], anchor: &[f32]) -> Vec<f32> {
68    embs.iter().map(|e| cosine_similarity(e, anchor)).collect()
69}
70
71/// Per-segment char cost, including the one separator char it adds on re-emit.
72fn cost_of(seg: &Segment) -> usize {
73    seg.text.len() + 1
74}
75
76/// Select the segment indices to keep, within `budget_chars`, applying the MMR
77/// redundancy gate. Returns indices sorted ascending (original order).
78///
79/// `embs[i]` MUST align with `segs[i]`. Protected segments are always kept.
80pub(super) fn select(
81    segs: &[Segment],
82    embs: &[Vec<f32>],
83    mode: RankMode,
84    anchor: Option<&[f32]>,
85    budget_chars: usize,
86) -> Vec<usize> {
87    debug_assert_eq!(segs.len(), embs.len());
88    let n = segs.len();
89
90    let scores = match mode {
91        RankMode::Centrality => centrality_scores(embs),
92        RankMode::Query => {
93            let Some(a) = anchor else {
94                return Vec::new();
95            };
96            query_scores(embs, a)
97        }
98    };
99
100    let mut kept: Vec<usize> = Vec::new();
101    let mut ctx = ScoringCtx::new();
102    let mut used = 0usize;
103
104    // 1) Protected segments are kept verbatim and seed the redundancy window.
105    for (i, seg) in segs.iter().enumerate() {
106        if seg.protected {
107            kept.push(i);
108            used += cost_of(seg);
109            ctx.push_kept(embs[i].clone());
110        }
111    }
112
113    // 2) Rank the rest by quantized score (desc) with an original-index tiebreak.
114    let mut ranked: Vec<usize> = (0..n).filter(|&i| !segs[i].protected).collect();
115    ranked.sort_by(|&a, &b| {
116        quantize(scores[b])
117            .cmp(&quantize(scores[a]))
118            .then(a.cmp(&b))
119    });
120
121    // 3) Greedily add under budget, skipping near-duplicates (MMR). Lower-ranked
122    //    but shorter segments may still fit after a long one is skipped.
123    for i in ranked {
124        let cost = cost_of(&segs[i]);
125        if used + cost > budget_chars && !kept.is_empty() {
126            continue;
127        }
128        if ctx.max_cosine(&embs[i]) >= REDUNDANCY_COSINE {
129            continue;
130        }
131        kept.push(i);
132        used += cost;
133        ctx.push_kept(embs[i].clone());
134    }
135
136    kept.sort_unstable();
137    kept
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    /// Build a tiny unit-norm embedding so cosine math is exact and the tests
145    /// need no ONNX engine.
146    fn unit(v: [f32; 3]) -> Vec<f32> {
147        let norm = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
148        vec![v[0] / norm, v[1] / norm, v[2] / norm]
149    }
150
151    fn seg(idx: usize, text: &str, protected: bool) -> Segment {
152        Segment {
153            idx,
154            para: 0,
155            text: text.to_string(),
156            protected,
157        }
158    }
159
160    #[test]
161    fn centrality_ranks_the_outlier_lowest() {
162        // Three near-identical vectors + one orthogonal outlier.
163        let embs = vec![
164            unit([1.0, 0.0, 0.0]),
165            unit([0.98, 0.02, 0.0]),
166            unit([0.97, 0.0, 0.03]),
167            unit([0.0, 0.0, 1.0]),
168        ];
169        let scores = centrality_scores(&embs);
170        let outlier = scores
171            .iter()
172            .enumerate()
173            .min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
174            .unwrap()
175            .0;
176        assert_eq!(outlier, 3, "the orthogonal vector is least central");
177    }
178
179    #[test]
180    fn query_mode_ranks_by_anchor() {
181        let embs = vec![unit([1.0, 0.0, 0.0]), unit([0.0, 1.0, 0.0])];
182        let anchor = unit([0.9, 0.1, 0.0]);
183        let scores = query_scores(&embs, &anchor);
184        assert!(scores[0] > scores[1], "segment aligned with anchor wins");
185    }
186
187    #[test]
188    fn select_keeps_protected_and_central_drops_peripheral() {
189        // idx0 protected; idx1/idx2 central; idx3 peripheral. Budget fits 3.
190        let segs = vec![
191            seg(0, "PROTECTED", true),
192            seg(1, "central a", false),
193            seg(2, "central b", false),
194            seg(3, "peripheral", false),
195        ];
196        // idx1/idx2 sit near the mass (high centrality) but are NOT duplicates
197        // (cos = -0.28 < REDUNDANCY_COSINE); idx3 is orthogonal (peripheral).
198        let embs = vec![
199            unit([1.0, 0.0, 0.0]),
200            unit([0.6, 0.8, 0.0]),
201            unit([0.6, -0.8, 0.0]),
202            unit([0.0, 0.0, 1.0]),
203        ];
204        let budget = "PROTECTED".len() + "central a".len() + "central b".len() + 3;
205        let kept = select(&segs, &embs, RankMode::Centrality, None, budget);
206        assert!(kept.contains(&0), "protected always kept");
207        assert!(kept.contains(&1) && kept.contains(&2), "central kept");
208        assert!(!kept.contains(&3), "peripheral dropped under budget");
209        // Sorted ascending → original order on re-emit.
210        let mut sorted = kept.clone();
211        sorted.sort_unstable();
212        assert_eq!(kept, sorted);
213    }
214
215    #[test]
216    fn mmr_drops_near_duplicate_segment() {
217        let segs = vec![
218            seg(0, "unique sentence one", false),
219            seg(1, "duplicate", false),
220            seg(2, "duplicate copy", false),
221        ];
222        // idx1 and idx2 are identical vectors → one must be dropped.
223        let embs = vec![
224            unit([1.0, 0.0, 0.0]),
225            unit([0.0, 1.0, 0.0]),
226            unit([0.0, 1.0, 0.0]),
227        ];
228        let kept = select(&segs, &embs, RankMode::Centrality, None, 10_000);
229        let dups = [1usize, 2].iter().filter(|i| kept.contains(i)).count();
230        assert_eq!(dups, 1, "MMR keeps only one of the duplicates");
231    }
232
233    #[test]
234    fn select_is_deterministic() {
235        let segs = vec![
236            seg(0, "alpha", false),
237            seg(1, "beta", false),
238            seg(2, "gamma", false),
239        ];
240        let embs = vec![
241            unit([1.0, 0.1, 0.0]),
242            unit([0.9, 0.2, 0.0]),
243            unit([0.0, 0.0, 1.0]),
244        ];
245        let a = select(&segs, &embs, RankMode::Centrality, None, 12);
246        let b = select(&segs, &embs, RankMode::Centrality, None, 12);
247        assert_eq!(a, b);
248    }
249}