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