Skip to main content

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;