lean_ctx/core/extractive/mod.rs
1//! Extractive prose compression: keep the most informative sentences within a
2//! char budget instead of truncating to the prefix.
3//!
4//! This is the premium replacement for [`crate::core::web::distill::squeeze_prose`]'s
5//! FIFO prefix truncation. It reuses the embedding model lean-ctx already ships
6//! (all-MiniLM-L6-v2, 384d, the `embeddings` feature) — **no new model, no new
7//! heavy dependency**.
8//!
9//! ## Determinism (#498)
10//!
11//! For a fixed `(text, budget, mode, anchor, model_version)` the output is
12//! byte-identical. Guaranteed by:
13//! 1. pure, allocation-only segmentation (the `segment` module);
14//! 2. embeddings that are run-to-run stable on a given build/host;
15//! 3. fixed-precision score quantization with an original-index tiebreak
16//! (the `ranker` module); and
17//! 4. re-emitting kept segments in their ORIGINAL order.
18//!
19//! A regression test asserts the byte-stability empirically.
20//!
21//! ## Graceful fallback
22//!
23//! When the embedding engine is unavailable — `embeddings` feature off, model
24//! not yet loaded, or `memory_profile=low` — [`rank_and_squeeze`] returns
25//! `None`, and callers fall back to the deterministic truncating squeeze. No
26//! build or OS regresses.
27
28mod ranker;
29mod segment;
30
31pub use ranker::RankMode;
32
33/// Too few units to rank meaningfully → let the caller fall back.
34const MIN_SEGMENTS: usize = 3;
35/// Centrality is `O(n²·d)`; above this many segments the cost is not worth it
36/// for a prose block, so we fall back to the linear truncating squeeze.
37const MAX_SEGMENTS: usize = 512;
38
39/// Rank the sentences of `text` and keep the highest-value ones within
40/// `budget_chars`, emitted in original order.
41///
42/// Returns `Some(compressed)` only when the embedding engine is available AND
43/// the result is actually smaller than the input (anti-inflation). Returns
44/// `None` otherwise — the caller then falls back to a truncating squeeze or
45/// leaves the text verbatim.
46///
47/// `anchor` is required for [`RankMode::Query`] (the task / recent user query)
48/// and ignored for [`RankMode::Centrality`].
49#[cfg(feature = "embeddings")]
50#[must_use]
51pub fn rank_and_squeeze(
52 text: &str,
53 budget_chars: usize,
54 mode: RankMode,
55 anchor: Option<&str>,
56) -> Option<String> {
57 let engine = crate::tools::ctx_knowledge::embeddings::embedding_engine_nonblocking()?;
58
59 let segs = segment::segment(text);
60 if segs.len() < MIN_SEGMENTS || segs.len() > MAX_SEGMENTS {
61 return None;
62 }
63
64 let texts: Vec<&str> = segs.iter().map(|s| s.text.as_str()).collect();
65 let embs = engine.embed_batch(&texts).ok()?;
66 if embs.len() != segs.len() {
67 return None;
68 }
69
70 let anchor_emb = match mode {
71 RankMode::Query => Some(engine.embed_query(anchor?).ok()?),
72 RankMode::Centrality => None,
73 };
74
75 let kept = ranker::select(&segs, &embs, mode, anchor_emb.as_deref(), budget_chars);
76 if kept.is_empty() {
77 return None;
78 }
79
80 let selected: Vec<&segment::Segment> = kept.iter().map(|&i| &segs[i]).collect();
81 let out = segment::reassemble(&selected);
82
83 (out.len() < text.len()).then_some(out)
84}
85
86/// Stub when the `embeddings` feature is disabled: always falls back.
87#[cfg(not(feature = "embeddings"))]
88#[must_use]
89pub fn rank_and_squeeze(
90 _text: &str,
91 _budget_chars: usize,
92 _mode: RankMode,
93 _anchor: Option<&str>,
94) -> Option<String> {
95 None
96}
97
98#[cfg(test)]
99mod tests;