mod concept_graph;
pub use concept_graph::ConceptGraph;
mod types;
pub use types::{CascadeConfig, CascadeResult, TierResult};
pub struct CascadeRetriever {
config: CascadeConfig,
episode_data: Vec<(String, String)>,
#[cfg(feature = "csm")]
concept_graph: ConceptGraph,
#[cfg(feature = "csm")]
bm25_index: super::Bm25Index,
#[cfg(feature = "csm")]
hdc_encoder: super::HdcEncoder,
#[cfg(feature = "csm")]
hdc_vectors: Vec<(String, super::HVec10240)>,
}
impl CascadeRetriever {
pub fn new(config: CascadeConfig) -> Self {
Self {
config,
episode_data: Vec::new(),
#[cfg(feature = "csm")]
concept_graph: ConceptGraph::from_embedded(),
#[cfg(feature = "csm")]
bm25_index: super::Bm25Index::new(),
#[cfg(feature = "csm")]
hdc_encoder: super::HdcEncoder::new(),
#[cfg(feature = "csm")]
hdc_vectors: Vec::new(),
}
}
#[must_use]
pub fn default_config() -> Self {
Self::new(CascadeConfig::default())
}
#[cfg(feature = "csm")]
fn tokenize(text: &str) -> Vec<String> {
super::HdcEncoder::tokenize(text, false, true)
}
pub fn add_episode(&mut self, id: &str, text: &str) {
self.episode_data.push((id.to_string(), text.to_string()));
#[cfg(feature = "csm")]
{
let tokens = Self::tokenize(text);
self.bm25_index.add_document(id, &tokens);
let hdc_vector = self.hdc_encoder.encode(text);
self.hdc_vectors.push((id.to_string(), hdc_vector));
}
}
pub fn clear(&mut self) {
self.episode_data.clear();
#[cfg(feature = "csm")]
{
self.bm25_index.clear();
self.hdc_vectors.clear();
}
}
#[must_use]
pub fn len(&self) -> usize {
self.episode_data.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.episode_data.is_empty()
}
pub fn retrieve(&self, query: &str) -> CascadeResult {
#[cfg(feature = "csm")]
{
self.retrieve_with_csm(query)
}
#[cfg(not(feature = "csm"))]
{
tracing::warn!(
"CSM feature not enabled; cascade retrieval returns empty results. \
Enable the `csm` feature for BM25/HDC/ConceptGraph retrieval."
);
let _ = query;
CascadeResult {
episode_ids: Vec::new(),
scores: Vec::new(),
contributing_tiers: Vec::new(),
api_calls: 0,
}
}
}
#[cfg(feature = "csm")]
fn retrieve_with_csm(&self, query: &str) -> CascadeResult {
use super::{compute_weights, merge_results};
let bm25_results = self.retrieve_bm25(query);
if bm25_results.sufficient {
return CascadeResult {
episode_ids: bm25_results.ids(),
scores: bm25_results.scores(),
contributing_tiers: vec!["bm25".to_string()],
api_calls: 0,
};
}
let hdc_results = self.retrieve_hdc(query);
if self.config.merge_results && !bm25_results.is_empty() {
let weights = compute_weights(query.len());
let merged = merge_results(&bm25_results.results, &hdc_results.results, weights);
if merged.len() >= self.config.min_results {
return CascadeResult {
episode_ids: merged.iter().map(|(id, _)| id.clone()).collect(),
scores: merged.iter().map(|(_, s)| *s).collect(),
contributing_tiers: vec!["bm25".to_string(), "hdc".to_string()],
api_calls: 0,
};
}
} else if hdc_results.sufficient {
return CascadeResult {
episode_ids: hdc_results.ids(),
scores: hdc_results.scores(),
contributing_tiers: vec!["hdc".to_string()],
api_calls: 0,
};
}
if self.config.enable_concept_expansion {
let concept_results = self.retrieve_concept_graph(query);
if concept_results.sufficient {
return CascadeResult {
episode_ids: concept_results.ids(),
scores: concept_results.scores(),
contributing_tiers: vec!["concept_graph".to_string()],
api_calls: 0,
};
}
}
let best_results: Vec<(String, f32)> = if self.config.merge_results {
let weights = compute_weights(query.len());
merge_results(&bm25_results.results, &hdc_results.results, weights)
} else if !hdc_results.is_empty() {
hdc_results.results.clone()
} else {
bm25_results.results.clone()
};
CascadeResult {
episode_ids: best_results.iter().map(|(id, _)| id.clone()).collect(),
scores: best_results.iter().map(|(_, s)| *s).collect(),
contributing_tiers: if best_results.is_empty() {
vec!["none".to_string()]
} else {
let mut tiers = Vec::new();
if !bm25_results.is_empty() {
tiers.push("bm25".to_string());
}
if !hdc_results.is_empty() {
tiers.push("hdc".to_string());
}
tiers.push("api_fallback_needed".to_string());
tiers
},
api_calls: 1, }
}
#[cfg(feature = "csm")]
fn retrieve_bm25(&self, query: &str) -> TierResult {
let query_tokens = Self::tokenize(query);
let raw_results = self.bm25_index.search(&query_tokens, self.config.top_k);
let results = super::normalize_scores(&raw_results);
let sufficient = results.len() >= self.config.min_results
&& results
.iter()
.any(|(_, s)| *s >= self.config.bm25_threshold);
TierResult {
tier: "bm25".to_string(),
results,
sufficient,
}
}
#[cfg(feature = "csm")]
fn retrieve_hdc(&self, query: &str) -> TierResult {
let query_vector = self.hdc_encoder.encode(query);
let mut similarities: Vec<(String, f32)> = self
.hdc_vectors
.iter()
.map(|(id, vec)| {
let sim = query_vector.cosine_similarity(vec);
(id.clone(), sim)
})
.collect();
let top_k = self.config.top_k;
let similarities = crate::search::select_top_k(&mut similarities, top_k, |a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
let sufficient = similarities.len() >= self.config.min_results
&& similarities
.iter()
.any(|(_, s)| *s >= self.config.hdc_threshold);
TierResult {
tier: "hdc".to_string(),
results: similarities,
sufficient,
}
}
#[cfg(feature = "csm")]
fn retrieve_concept_graph(&self, query: &str) -> TierResult {
let expanded_terms = self.concept_graph.expand_terms(query);
if expanded_terms.is_empty() {
return TierResult {
tier: "concept_graph".to_string(),
results: Vec::new(),
sufficient: false,
};
}
let mut scored: Vec<(String, f32)> = self
.episode_data
.iter()
.map(|(id, text)| {
let text_lower = text.to_lowercase();
let match_count = expanded_terms
.iter()
.filter(|term| text_lower.split_whitespace().any(|w| w == term.as_str()))
.count();
let score = if expanded_terms.is_empty() {
0.0
} else {
match_count as f32 / expanded_terms.len() as f32
};
(id.clone(), score)
})
.filter(|(_, s)| *s >= self.config.concept_graph_threshold)
.collect();
let top_k = self.config.top_k;
let scored = crate::search::select_top_k(&mut scored, top_k, |a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
let sufficient = scored.len() >= self.config.min_results;
TierResult {
tier: "concept_graph".to_string(),
results: scored,
sufficient,
}
}
pub fn config(&self) -> &CascadeConfig {
&self.config
}
pub fn estimate_api_call_probability(&self, query: &str) -> f32 {
let len = query.len() as f32;
let word_count = query.split_whitespace().count() as f32;
let length_factor: f32 = if len < 20.0 {
0.1
} else if len < 50.0 {
0.25
} else if len < 100.0 {
0.5
} else {
0.7
};
let avg_word_len = if word_count > 0.0 {
len / word_count
} else {
10.0
};
let keyword_factor: f32 = if avg_word_len < 5.0 {
0.0 } else if avg_word_len < 8.0 {
0.15
} else {
0.3 };
let code_token_count = query
.split_whitespace()
.filter(|w| w.contains('_') || w.contains("::") || w.contains('/'))
.count() as f32;
let code_factor: f32 = if word_count > 0.0 && code_token_count / word_count > 0.3 {
-0.15 } else {
0.0
};
(length_factor + keyword_factor + code_factor).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests;
#[cfg(feature = "csm")]
pub mod weights;
#[cfg(feature = "csm")]
pub use weights::compute_tier_weights;