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;
29#[cfg(feature = "embeddings")]
30mod segment;
31
32pub use ranker::RankMode;
33
34/// Too few units to rank meaningfully → let the caller fall back.
35#[cfg(feature = "embeddings")]
36const MIN_SEGMENTS: usize = 3;
37/// Centrality is `O(n²·d)`; above this many segments the cost is not worth it
38/// for a prose block, so we fall back to the linear truncating squeeze.
39#[cfg(feature = "embeddings")]
40const MAX_SEGMENTS: usize = 512;
41
42/// Rank the sentences of `text` and keep the highest-value ones within
43/// `budget_chars`, emitted in original order.
44///
45/// Returns `Some(compressed)` only when the embedding engine is available AND
46/// the result is actually smaller than the input (anti-inflation). Returns
47/// `None` otherwise — the caller then falls back to a truncating squeeze or
48/// leaves the text verbatim.
49///
50/// `anchor` is required for [`RankMode::Query`] (the task / recent user query)
51/// and ignored for [`RankMode::Centrality`].
52#[cfg(feature = "embeddings")]
53#[must_use]
54pub fn rank_and_squeeze(
55    text: &str,
56    budget_chars: usize,
57    mode: RankMode,
58    anchor: Option<&str>,
59) -> Option<String> {
60    let engine = crate::core::embedding_access::embedding_engine_nonblocking()?;
61
62    let segs = segment::segment(text);
63    if segs.len() < MIN_SEGMENTS || segs.len() > MAX_SEGMENTS {
64        return None;
65    }
66
67    let texts: Vec<&str> = segs.iter().map(|s| s.text.as_str()).collect();
68    let embs = engine.embed_batch(&texts).ok()?;
69    if embs.len() != segs.len() {
70        return None;
71    }
72
73    let anchor_emb = match mode {
74        RankMode::Query => Some(engine.embed_query(anchor?).ok()?),
75        RankMode::Centrality => None,
76    };
77
78    let kept = ranker::select(&segs, &embs, mode, anchor_emb.as_deref(), budget_chars);
79    if kept.is_empty() {
80        return None;
81    }
82
83    let selected: Vec<&segment::Segment> = kept.iter().map(|&i| &segs[i]).collect();
84    let out = segment::reassemble(&selected);
85
86    (out.len() < text.len()).then_some(out)
87}
88
89/// Stub when the `embeddings` feature is disabled: always falls back.
90#[cfg(not(feature = "embeddings"))]
91#[must_use]
92pub fn rank_and_squeeze(
93    _text: &str,
94    _budget_chars: usize,
95    _mode: RankMode,
96    _anchor: Option<&str>,
97) -> Option<String> {
98    None
99}
100
101#[cfg(test)]
102mod tests;