use std::collections::{BTreeSet, HashMap, HashSet};
use super::source_gate::{MAX_SOURCES, SourceGate};
use super::{BM25Index, SearchResult};
const MAX_RANKINGS: usize = 16;
pub(super) fn is_excluded(excluded: &[bool], idx: usize) -> bool {
excluded.get(idx).copied().unwrap_or(false)
}
pub(super) fn doc_freq(postings: &[(usize, f64)], excluded: &[bool]) -> usize {
let mut documents = 0usize;
let mut last = None;
for &(idx, _) in postings {
if last == Some(idx) {
continue;
}
last = Some(idx);
if !is_excluded(excluded, idx) {
documents += 1;
}
}
documents
}
impl BM25Index {
pub(super) fn served_corpus(&self, excluded: &[bool]) -> (usize, f64) {
let mut documents = 0usize;
let mut total_len = 0usize;
for (idx, chunk) in self.chunks.iter().enumerate() {
if !is_excluded(excluded, idx) {
documents += 1;
total_len += chunk.token_count;
}
}
if documents == 0 {
return (0, 0.0);
}
(documents, total_len as f64 / documents as f64)
}
pub(crate) fn search_gated(
&self,
query: &str,
top_k: usize,
gate: &mut Option<SourceGate>,
) -> Vec<SearchResult> {
let Some(gate) = gate.as_mut() else {
let mut ranked = self.rank(query, None);
ranked.truncate(top_k);
return ranked;
};
if gate.protected() {
return self.search_admitted_corpus(query, top_k, gate);
}
let mut ranked = self.rank(query, None);
let mut refused: HashSet<usize> = HashSet::new();
for _ in 0..MAX_RANKINGS {
let known = refused.len();
let served = self.walk(ranked, top_k, gate, &mut refused);
if refused.len() == known {
if known > 0 {
tracing::debug!(
"[bm25] {known} chunk(s) withheld and left out of the ranking: \
source no longer admitted"
);
self.evict_gone(gate);
}
return served;
}
let excluded: Vec<bool> = self
.chunks
.iter()
.enumerate()
.map(|(idx, chunk)| {
refused.contains(&idx)
|| gate.decided(&chunk.file_path, &chunk.content) == Some(false)
})
.collect();
ranked = self.rank(query, Some(&excluded));
}
tracing::debug!("[bm25] search withheld: ranking bound reached while sources changed");
Vec::new()
}
fn search_admitted_corpus(
&self,
query: &str,
top_k: usize,
gate: &mut SourceGate,
) -> Vec<SearchResult> {
let sources = self.candidate_sources(query);
let mut chunks_of: HashMap<&str, Vec<usize>> =
sources.iter().map(|source| (*source, Vec::new())).collect();
for (idx, chunk) in self.chunks.iter().enumerate() {
if let Some(indices) = chunks_of.get_mut(chunk.file_path.as_str()) {
indices.push(idx);
}
}
let mut excluded = vec![true; self.chunks.len()];
let mut refused = false;
for source in &sources {
for &idx in chunks_of.get(source).into_iter().flatten() {
let chunk = &self.chunks[idx];
if gate.admits_unserved(&chunk.file_path, &chunk.content) {
excluded[idx] = false;
} else {
refused = true;
}
}
}
if refused {
self.evict_gone(gate);
}
let mut ranked = self.rank(query, Some(&excluded));
ranked.truncate(top_k);
for result in &ranked {
if let Some(chunk) = self.chunks.get(result.chunk_idx) {
SourceGate::note_served(&chunk.file_path, chunk.input_origin.as_ref());
}
}
ranked
}
#[cfg(any(feature = "qdrant", feature = "pgvector"))]
pub(crate) fn admitted_sources(&self, query: &str, gate: &mut SourceGate) -> Vec<String> {
let mut order = self.candidate_sources(query);
let lexical: HashSet<&str> = order.iter().copied().collect();
let rest: BTreeSet<&str> = self
.chunks
.iter()
.map(|chunk| chunk.file_path.as_str())
.filter(|source| !lexical.contains(source))
.collect();
order.extend(rest);
order.truncate(MAX_SOURCES);
let mut chunks_of: HashMap<&str, Vec<usize>> =
order.iter().map(|source| (*source, Vec::new())).collect();
for (idx, chunk) in self.chunks.iter().enumerate() {
if let Some(indices) = chunks_of.get_mut(chunk.file_path.as_str()) {
indices.push(idx);
}
}
let mut admitted: BTreeSet<&str> = BTreeSet::new();
for source in order {
let served = chunks_of
.get(source)
.into_iter()
.flatten()
.any(|&idx| gate.admits_unserved(source, &self.chunks[idx].content));
if served {
admitted.insert(source);
}
}
admitted.into_iter().map(str::to_owned).collect()
}
fn candidate_sources(&self, query: &str) -> Vec<&str> {
let mut terms: Vec<String> = super::tokenize(query)
.iter()
.map(|term| term.to_lowercase())
.collect();
terms.sort();
terms.dedup();
let mut found: HashMap<&str, (usize, usize)> = HashMap::new();
for term in &terms {
let Some(postings) = self.inverted.get(term) else {
continue;
};
let mut holding: HashSet<&str> = HashSet::new();
for &(idx, _) in postings {
let Some(chunk) = self.chunks.get(idx) else {
continue;
};
let source = chunk.file_path.as_str();
let entry = found.entry(source).or_default();
entry.1 += 1;
if holding.insert(source) {
entry.0 += 1;
}
}
}
let mut sources: Vec<(&str, (usize, usize))> = found.into_iter().collect();
sources.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
sources.truncate(MAX_SOURCES);
sources.into_iter().map(|(source, _)| source).collect()
}
fn walk(
&self,
ranked: Vec<SearchResult>,
top_k: usize,
gate: &mut SourceGate,
refused: &mut HashSet<usize>,
) -> Vec<SearchResult> {
let mut served = Vec::with_capacity(top_k.min(ranked.len()));
for result in ranked {
if served.len() >= top_k {
break;
}
if self.chunks.get(result.chunk_idx).is_some_and(|chunk| {
gate.serves_with(
&chunk.file_path,
&chunk.content,
chunk.input_origin.as_ref(),
)
}) {
served.push(result);
} else {
refused.insert(result.chunk_idx);
}
}
served
}
fn evict_gone(&self, gate: &SourceGate) {
let Some(root) = self.source_root.as_deref() else {
return;
};
let gone: BTreeSet<String> = gate
.refused_sources()
.filter(|source| !source.contains("://") && !root.join(source).exists())
.map(str::to_owned)
.collect();
if gone.is_empty() || Self::persisted_chunk_count(root) != Some(self.chunks.len() as u64) {
return;
}
let removed = Self::evict_sources(root, &gone);
tracing::debug!(
"[bm25] {removed} chunk(s) of {} deleted source(s) evicted from the store",
gone.len()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::policy::runtime::TestPolicyOverride;
#[test]
fn a_withdrawn_source_does_not_count_in_the_ranking_of_the_others() {
let _data = crate::core::data_dir::isolated_data_dir();
let community = TestPolicyOverride::set(None);
let project = tempfile::tempdir().expect("project");
let canonical = project.path().canonicalize().expect("canonical root");
let root = canonical.as_path();
let source = |name: &str, body: &str| {
std::fs::write(root.join(name), body).expect("source");
};
source(
"billing.rs",
"pub fn settle_billing() -> u32 {\n let reconciliation = 7319;\n reconciliation\n}\n",
);
source(
"ledger.rs",
"pub fn settle_ledger() -> u32 {\n let reconciliation = 42;\n let carried = reconciliation + 1;\n carried * 2\n}\n",
);
source(
"audit.rs",
"pub fn audit_trail(entries: &[u32]) -> u32 {\n let reconciliation: u32 = entries.iter().sum();\n let checked = reconciliation % 97;\n let folded = checked + entries.len() as u32;\n folded\n}\n",
);
source(
"unrelated.rs",
"pub fn render_banner(width: usize) -> String {\n let line = \"=\".repeat(width);\n format!(\"{line}\\nlean\\n{line}\")\n}\n",
);
BM25Index::build_from_directory(root)
.save(root)
.expect("persisted");
let loaded = BM25Index::load(root).expect("index of the current policy");
let ranking = |index: &BM25Index| -> Vec<(String, String, usize, f64)> {
index
.search("reconciliation", 10)
.into_iter()
.map(|hit| (hit.file_path, hit.symbol_name, hit.start_line, hit.score))
.collect()
};
let all = ranking(&loaded);
assert!(all.iter().any(|hit| hit.0 == "billing.rs"), "{all:?}");
std::fs::remove_file(root.join("billing.rs")).expect("withdrawn");
let served = ranking(&loaded);
let without = ranking(&BM25Index::build_from_directory(root));
assert!(!without.is_empty());
assert_eq!(served, without);
let filtered: Vec<_> = all
.into_iter()
.filter(|hit| hit.0 != "billing.rs")
.collect();
assert_ne!(served, filtered, "the statistics must change the scores");
let stored = BM25Index::load_persisted(root).expect("persisted file");
assert!(stored.chunks.iter().all(|c| c.file_path != "billing.rs"));
assert_eq!(
ranking(&loaded),
without,
"a resident copy stays consistent"
);
drop(community);
let _live_policy = crate::core::policy::runtime::lock_test_override();
std::fs::create_dir_all(root.join(".lean-ctx")).expect("policy directory");
std::fs::write(
root.join(".lean-ctx").join("policy.toml"),
"name='ranking'\nversion='1.0.0'\ndescription='fixture'\n",
)
.expect("policy pack");
std::fs::remove_file(root.join("unrelated.rs")).expect("withdrawn");
let holding = |files: &[&str]| {
BM25Index::from_chunks_for_test(
loaded
.chunks
.iter()
.filter(|chunk| files.contains(&chunk.file_path.as_str()))
.cloned()
.collect(),
)
};
let protected = ranking(&loaded);
assert!(!protected.is_empty());
assert_eq!(protected, ranking(&holding(&["audit.rs", "ledger.rs"])));
assert_ne!(
protected,
ranking(&holding(&["audit.rs", "ledger.rs", "unrelated.rs"])),
"an unasked source must not count in the statistics"
);
}
}