pub mod download;
pub mod model_registry;
pub mod pooling;
pub mod tokenizer;
use std::path::{Path, PathBuf};
use model_registry::{EmbeddingModel, ModelConfig, VocabSource};
use tokenizer::{TokenizedInput, WordPieceTokenizer};
use rayon::prelude::*;
#[cfg(feature = "embeddings")]
use std::sync::Mutex;
pub struct EmbeddingEngine {
tokenizer: TokenizerKind,
dimensions: usize,
max_seq_len: usize,
model_id: EmbeddingModel,
model_config: ModelConfig,
#[cfg(feature = "embeddings")]
session: Mutex<ort::session::Session>,
#[cfg(feature = "embeddings")]
graph_inputs: GraphInputs,
#[cfg(feature = "embeddings")]
output_name: String,
}
enum TokenizerKind {
WordPiece(WordPieceTokenizer),
HfTokenizer(tokenizer::HfTokenizerWrapper),
}
#[cfg(feature = "embeddings")]
enum GraphInputs {
Transformer {
input_ids: String,
attention_mask: String,
token_type_ids: Option<String>,
},
EmbeddingBag {
input_ids: String,
offsets: String,
},
}
#[cfg(feature = "embeddings")]
fn is_embedding_bag_signature(input_names: &[String]) -> bool {
input_names.len() == 2 && input_names[1] == "offsets"
}
impl EmbeddingEngine {
#[cfg(feature = "embeddings")]
pub fn load(model_dir: &Path) -> anyhow::Result<Self> {
let selected = model_registry::resolve_model();
Self::load_model(model_dir, selected)
}
#[cfg(feature = "embeddings")]
pub fn load_model(base_dir: &Path, model_id: EmbeddingModel) -> anyhow::Result<Self> {
let config = model_id.config();
let model_dir = base_dir.join(model_id.storage_dir_name());
download::ensure_model(&model_dir, &config)?;
let tokenizer = load_tokenizer(&model_dir, &config)?;
let model_path = model_dir.join("model.onnx");
let deterministic = deterministic_inference();
let eps = if deterministic {
vec![ort::ep::CPU::default().build()]
} else {
crate::core::ort_execution_providers::execution_providers()
};
if !deterministic
&& let Some(msg) = crate::core::ort_execution_providers::gpu_fallback_warning()
{
tracing::warn!("{msg}");
}
let num_cpus = if deterministic {
1
} else {
std::thread::available_parallelism().map_or(4, |n| n.get().max(1))
};
tracing::info!(
model = %config.name,
model_path = %model_path.display(),
deterministic,
num_cpus,
execution_providers = eps.len(),
"Loading ONNX embedding model"
);
crate::core::ort_environment::ensure_ort_env(&eps)?;
tracing::debug!("ONNX Runtime environment ready; creating embedding session builder");
let mut session = ort::session::Session::builder()
.map_err(|e| anyhow::anyhow!("ORT builder: {e}"))?
.with_intra_threads(num_cpus)
.map_err(|e| anyhow::anyhow!("ORT intra threads: {e}"))?
.with_optimization_level(ort::session::builder::GraphOptimizationLevel::All)
.map_err(|e| anyhow::anyhow!("ORT optimization: {e}"))?
.commit_from_file(&model_path)
.map_err(|e| anyhow::anyhow!("ORT load model: {e}"))?;
tracing::debug!("ONNX embedding session loaded; inspecting graph signature");
let input_names: Vec<String> = session
.inputs()
.iter()
.map(|i| i.name().to_string())
.collect();
if input_names.len() < 2 {
anyhow::bail!(
"Expected model with at least 2 inputs (input_ids, attention_mask), got {}",
input_names.len()
);
}
let graph_inputs = if is_embedding_bag_signature(&input_names) {
GraphInputs::EmbeddingBag {
input_ids: input_names[0].clone(),
offsets: input_names[1].clone(),
}
} else {
let token_type_ids = if config.needs_token_type_ids {
if input_names.len() < 3 {
anyhow::bail!(
"Model {} requires token_type_ids but only has {} inputs",
config.name,
input_names.len()
);
}
Some(input_names[2].clone())
} else if input_names.len() >= 3 {
Some(input_names[2].clone())
} else {
None
};
GraphInputs::Transformer {
input_ids: input_names[0].clone(),
attention_mask: input_names[1].clone(),
token_type_ids,
}
};
let output_name = session
.outputs()
.first()
.map(|o| o.name().to_string())
.ok_or_else(|| anyhow::anyhow!("Model has no named outputs"))?;
let dimensions = detect_dimensions(
&mut session,
&tokenizer,
&graph_inputs,
&output_name,
config.max_seq_len,
)
.unwrap_or(config.dimensions);
tracing::debug!(dimensions, "ONNX embedding dimensions resolved");
tracing::info!(
"Embedding engine loaded: model={}, {}d, max_seq_len={}, topology={}",
config.name,
dimensions,
config.max_seq_len,
match graph_inputs {
GraphInputs::Transformer { .. } => "transformer",
GraphInputs::EmbeddingBag { .. } => "embedding-bag (model2vec)",
},
);
Ok(Self {
session: Mutex::new(session),
tokenizer,
dimensions,
max_seq_len: config.max_seq_len,
model_id,
model_config: config,
graph_inputs,
output_name,
})
}
#[cfg(not(feature = "embeddings"))]
pub fn load(_model_dir: &Path) -> anyhow::Result<Self> {
anyhow::bail!("Embeddings feature not enabled. Compile with --features embeddings")
}
pub fn load_default() -> anyhow::Result<Self> {
Self::load(&Self::model_directory())
}
pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
let prefixed;
let input_text = if let Some(prefix) = &self.model_config.document_prefix {
prefixed = format!("{prefix}{text}");
&prefixed
} else {
text
};
let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
self.run_inference(&input)
}
pub fn embed_query(&self, query: &str) -> anyhow::Result<Vec<f32>> {
let prefixed;
let input_text = if let Some(prefix) = &self.model_config.query_prefix {
prefixed = format!("{prefix}{query}");
&prefixed
} else {
query
};
let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
self.run_inference(&input)
}
pub fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let configured_batch_size: usize = std::env::var("LEAN_CTX_EMBEDDING_BATCH_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&v| v >= 1)
.unwrap_or_else(|| {
#[cfg(any(feature = "embeddings", feature = "neural"))]
if crate::core::ort_execution_providers::gpu_active() {
return 256;
}
64
});
let estimated_bytes_per_text = self
.max_seq_len
.saturating_mul(self.dimensions.max(1))
.saturating_mul(std::mem::size_of::<f32>())
.saturating_mul(8) as u64;
let batch_size = crate::core::memory_guard::adaptive_batch_size(
1,
configured_batch_size,
estimated_bytes_per_text.max(1),
);
let total = texts.len();
let total_batches = total.div_ceil(batch_size);
let mut results = Vec::with_capacity(total);
let start = std::time::Instant::now();
for (batch_idx, text_chunk) in texts.chunks(batch_size).enumerate() {
if crate::core::interrupt::is_cancelled()
|| crate::core::memory_guard::abort_requested()
{
anyhow::bail!(
"embedding cancelled after {done}/{total_batches} batches ({embedded}/{total} chunks)",
done = batch_idx,
embedded = results.len(),
);
}
let prefixed: Vec<String> = text_chunk
.iter()
.map(|text| {
if let Some(prefix) = &self.model_config.document_prefix {
format!("{prefix}{text}")
} else {
(*text).to_owned()
}
})
.collect();
let tokenized: Vec<TokenizedInput> = prefixed
.par_iter()
.map(|text| tokenize(&self.tokenizer, text, self.max_seq_len))
.collect();
let batch_start = std::time::Instant::now();
let batch_out = self.run_inference_batch(&tokenized)?;
results.extend(batch_out);
tracing::info!(
batch = batch_idx + 1,
total_batches,
embedded = results.len(),
total,
batch_ms = batch_start.elapsed().as_millis(),
elapsed_ms = start.elapsed().as_millis(),
"embedding progress"
);
}
Ok(results)
}
pub fn dimensions(&self) -> usize {
self.dimensions
}
pub fn model_id(&self) -> &EmbeddingModel {
&self.model_id
}
pub fn model_name(&self) -> &str {
&self.model_config.name
}
pub fn model_directory() -> PathBuf {
if let Ok(dir) = std::env::var("LEAN_CTX_MODELS_DIR") {
return PathBuf::from(dir);
}
if let Ok(d) = crate::core::paths::cache_dir() {
return d.join("models");
}
PathBuf::from("models")
}
pub fn is_available() -> bool {
let base_dir = Self::model_directory();
let selected = model_registry::resolve_model();
let config = selected.config();
let model_dir = base_dir.join(selected.storage_dir_name());
model_dir.join("model.onnx").exists()
&& model_dir.join(config.vocab_file.filename()).exists()
}
pub fn ensure_downloaded() -> anyhow::Result<()> {
let base_dir = Self::model_directory();
let selected = model_registry::resolve_model();
let config = selected.config();
let model_dir = base_dir.join(selected.storage_dir_name());
download::ensure_model(&model_dir, &config)?;
Ok(())
}
#[cfg(feature = "embeddings")]
fn run_inference(&self, input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
let seq_len = input.input_ids.len();
let mut embedding = match &self.graph_inputs {
GraphInputs::Transformer {
input_ids,
attention_mask,
token_type_ids,
} => {
let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
let mask_vec: Vec<i64> = input.attention_mask.iter().map(|&x| x as i64).collect();
let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec)?;
let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec)?;
let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
let hidden = if let Some(type_id) = token_type_ids {
let type_vec: Vec<i64> =
input.token_type_ids.iter().map(|&x| x as i64).collect();
let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec)?;
let type_tensor = ort::value::Tensor::from_array(type_array)?;
let mut _guard = self
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let outputs = _guard.run(ort::inputs![
input_ids.as_str() => ids_tensor,
attention_mask.as_str() => mask_tensor,
type_id.as_str() => type_tensor,
])?;
let (_, data) =
outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
data.to_vec()
} else {
let mut _guard = self
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let outputs = _guard.run(ort::inputs![
input_ids.as_str() => ids_tensor,
attention_mask.as_str() => mask_tensor,
])?;
let (_, data) =
outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
data.to_vec()
};
pooling::mean_pool(&hidden, &input.attention_mask, seq_len, self.dimensions)
}
GraphInputs::EmbeddingBag { input_ids, offsets } => {
if seq_len == 0 {
return Ok(vec![0.0; self.dimensions]);
}
let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec)?;
let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64])?;
let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
let mut _guard = self
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let outputs = _guard.run(ort::inputs![
input_ids.as_str() => ids_tensor,
offsets.as_str() => offsets_tensor,
])?;
let (_, data) = outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
data.to_vec()
}
};
pooling::normalize_l2(&mut embedding);
Ok(embedding)
}
#[cfg(feature = "embeddings")]
fn run_inference_batch(&self, inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
if inputs.is_empty() {
return Ok(Vec::new());
}
match &self.graph_inputs {
GraphInputs::Transformer {
input_ids: input_id,
attention_mask: mask_id,
token_type_ids,
} => {
let batch = inputs.len();
let max_len = inputs.iter().map(|i| i.input_ids.len()).max().unwrap_or(0);
let mut ids_data: Vec<i64> = Vec::with_capacity(batch * max_len);
let mut mask_data: Vec<i64> = Vec::with_capacity(batch * max_len);
let mut type_data: Vec<i64> = Vec::with_capacity(batch * max_len);
let mut per_seq_masks: Vec<&[i32]> = Vec::with_capacity(batch);
for inp in inputs {
let seq_len = inp.input_ids.len();
ids_data.extend(inp.input_ids.iter().map(|&x| x as i64));
ids_data.resize(ids_data.len() + (max_len - seq_len), 0);
mask_data.extend(inp.attention_mask.iter().map(|&x| x as i64));
mask_data.resize(mask_data.len() + (max_len - seq_len), 0);
type_data.extend(inp.token_type_ids.iter().map(|&x| x as i64));
type_data.resize(type_data.len() + (max_len - seq_len), 0);
per_seq_masks.push(inp.attention_mask.as_slice());
}
let ids_array = ndarray::Array2::from_shape_vec((batch, max_len), ids_data)?;
let mask_array = ndarray::Array2::from_shape_vec((batch, max_len), mask_data)?;
let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
let hidden = if let Some(type_id) = token_type_ids {
let type_array = ndarray::Array2::from_shape_vec((batch, max_len), type_data)?;
let type_tensor = ort::value::Tensor::from_array(type_array)?;
let mut _guard = self
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let outputs = _guard.run(ort::inputs![
input_id.as_str() => ids_tensor,
mask_id.as_str() => mask_tensor,
type_id.as_str() => type_tensor,
])?;
let (_, data) =
outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
data.to_vec()
} else {
let mut _guard = self
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let outputs = _guard.run(ort::inputs![
input_id.as_str() => ids_tensor,
mask_id.as_str() => mask_tensor,
])?;
let (_, data) =
outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
data.to_vec()
};
let mut results =
pooling::mean_pool_batch(&hidden, &per_seq_masks, max_len, self.dimensions);
for emb in &mut results {
pooling::normalize_l2(emb);
}
Ok(results)
}
GraphInputs::EmbeddingBag { input_ids, offsets } => {
let batch = inputs.len();
let mut flat_ids: Vec<i64> = Vec::new();
let mut adjusted_offsets: Vec<i64> = Vec::with_capacity(batch);
let mut last_offset = 0i64;
for inp in inputs {
adjusted_offsets.push(last_offset);
if !inp.input_ids.is_empty() {
flat_ids.extend(inp.input_ids.iter().map(|&x| x as i64));
last_offset = flat_ids.len() as i64;
}
}
if flat_ids.is_empty() {
return Ok(vec![vec![0.0; self.dimensions]; batch]);
}
let ids_array = ndarray::Array1::from_shape_vec(flat_ids.len(), flat_ids)?;
let offsets_array = ndarray::Array1::from_shape_vec(batch, adjusted_offsets)?;
let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
let mut _guard = self
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let outputs = _guard.run(ort::inputs![
input_ids.as_str() => ids_tensor,
offsets.as_str() => offsets_tensor,
])?;
let (_, out_data) =
outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
let out = out_data.to_vec();
let mut results: Vec<Vec<f32>> = out
.chunks_exact(self.dimensions)
.map(<[f32]>::to_vec)
.collect();
while results.len() < batch {
results.push(vec![0.0; self.dimensions]);
}
for emb in &mut results {
pooling::normalize_l2(emb);
}
Ok(results)
}
}
}
#[cfg(not(feature = "embeddings"))]
fn run_inference(&self, _input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
anyhow::bail!("Embeddings feature not enabled")
}
#[cfg(not(feature = "embeddings"))]
fn run_inference_batch(&self, _inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
anyhow::bail!("Embeddings feature not enabled")
}
}
fn load_tokenizer(model_dir: &Path, config: &ModelConfig) -> anyhow::Result<TokenizerKind> {
match &config.vocab_file {
VocabSource::VocabTxt(filename) => {
let path = model_dir.join(filename);
let tok = WordPieceTokenizer::from_file(&path)?;
Ok(TokenizerKind::WordPiece(tok))
}
VocabSource::TokenizerJson(filename) => {
let path = model_dir.join(filename);
let tok = tokenizer::HfTokenizerWrapper::from_file(&path).map_err(|e| {
anyhow::anyhow!(
"Failed to load tokenizer.json for {}: {e}. Custom models must ship a \
HuggingFace tokenizer.json with a supported model type (WordPiece/BPE).",
config.name
)
})?;
Ok(TokenizerKind::HfTokenizer(tok))
}
}
}
fn tokenize(tokenizer: &TokenizerKind, text: &str, max_len: usize) -> TokenizedInput {
match tokenizer {
TokenizerKind::WordPiece(wp) => wp.encode(text, max_len),
TokenizerKind::HfTokenizer(hf) => hf.encode(text, max_len),
}
}
#[cfg(feature = "embeddings")]
fn detect_dimensions(
session: &mut ort::session::Session,
tokenizer: &TokenizerKind,
graph_inputs: &GraphInputs,
output_name: &str,
max_seq_len: usize,
) -> Option<usize> {
let dummy = tokenize(tokenizer, "test", max_seq_len.min(8));
let seq_len = dummy.input_ids.len();
if seq_len == 0 {
return None;
}
let outputs = match graph_inputs {
GraphInputs::Transformer {
input_ids,
attention_mask,
token_type_ids,
} => {
let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
let mask_vec: Vec<i64> = dummy.attention_mask.iter().map(|&x| x as i64).collect();
let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec).ok()?;
let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec).ok()?;
let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
let mask_tensor = ort::value::Tensor::from_array(mask_array).ok()?;
if let Some(type_id) = token_type_ids {
let type_vec: Vec<i64> = dummy.token_type_ids.iter().map(|&x| x as i64).collect();
let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec).ok()?;
let type_tensor = ort::value::Tensor::from_array(type_array).ok()?;
session
.run(ort::inputs![
input_ids.as_str() => ids_tensor,
attention_mask.as_str() => mask_tensor,
type_id.as_str() => type_tensor,
])
.ok()?
} else {
session
.run(ort::inputs![
input_ids.as_str() => ids_tensor,
attention_mask.as_str() => mask_tensor,
])
.ok()?
}
}
GraphInputs::EmbeddingBag { input_ids, offsets } => {
let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec).ok()?;
let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64]).ok()?;
let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
let offsets_tensor = ort::value::Tensor::from_array(offsets_array).ok()?;
session
.run(ort::inputs![
input_ids.as_str() => ids_tensor,
offsets.as_str() => offsets_tensor,
])
.ok()?
}
};
let (shape, _) = outputs[output_name].try_extract_tensor::<f32>().ok()?;
match graph_inputs {
GraphInputs::Transformer { .. } => shape.last().copied().map(|s| s as usize),
GraphInputs::EmbeddingBag { .. } => {
if shape.len() == 2 {
shape.last().copied().map(|s| s as usize)
} else {
None
}
}
}
}
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
debug_assert_eq!(a.len(), b.len(), "vectors must have equal dimensions");
crate::core::embedding_quant::dot_f32(a, b)
}
pub fn cosine_similarity_raw(a: &[f32], b: &[f32]) -> f32 {
debug_assert_eq!(a.len(), b.len());
use crate::core::embedding_quant::dot_f32;
let dot = dot_f32(a, b);
let norm_a = dot_f32(a, a).sqrt();
let norm_b = dot_f32(b, b).sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
dot / (norm_a * norm_b)
}
#[cfg(feature = "embeddings")]
static SHARED_ENGINE: std::sync::OnceLock<anyhow::Result<EmbeddingEngine>> =
std::sync::OnceLock::new();
#[cfg(feature = "embeddings")]
pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
shared_engine_result().ok()
}
#[cfg(feature = "embeddings")]
pub fn shared_engine_result() -> anyhow::Result<&'static EmbeddingEngine> {
SHARED_ENGINE
.get_or_init(EmbeddingEngine::load_default)
.as_ref()
.map_err(|e| anyhow::anyhow!("{e}"))
}
#[cfg(feature = "embeddings")]
pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
SHARED_ENGINE.get()?.as_ref().ok()
}
#[cfg(feature = "embeddings")]
fn deterministic_inference() -> bool {
if let Ok(v) = std::env::var("LEAN_CTX_EMBEDDING_DETERMINISTIC") {
return matches!(
v.trim().to_lowercase().as_str(),
"1" | "true" | "yes" | "on"
);
}
crate::core::config::Config::load()
.embedding
.deterministic
.unwrap_or(false)
}
#[cfg(feature = "embeddings")]
pub fn background_load_allowed() -> bool {
if cfg!(test) {
return false;
}
!current_exe_is_test_artifact()
}
#[cfg(feature = "embeddings")]
fn current_exe_is_test_artifact() -> bool {
std::env::current_exe()
.ok()
.as_deref()
.is_some_and(exe_path_is_test_artifact)
}
#[cfg(feature = "embeddings")]
fn exe_path_is_test_artifact(path: &Path) -> bool {
path.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.is_some_and(|name| name == "deps")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cosine_similarity_identical() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
}
#[test]
fn cosine_similarity_orthogonal() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![0.0, 1.0, 0.0];
assert!(cosine_similarity(&a, &b).abs() < 1e-6);
}
#[test]
fn cosine_similarity_opposite() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![-1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
}
#[test]
fn cosine_similarity_raw_unnormalized() {
let a = vec![3.0, 4.0];
let b = vec![3.0, 4.0];
assert!((cosine_similarity_raw(&a, &b) - 1.0).abs() < 1e-6);
}
#[test]
fn cosine_similarity_raw_zero_vector() {
let a = vec![0.0, 0.0];
let b = vec![1.0, 2.0];
assert_eq!(cosine_similarity_raw(&a, &b), 0.0);
}
#[test]
fn model_directory_env_override_and_availability() {
let _lock = crate::core::data_dir::test_env_lock();
let unique = "/tmp/lean_ctx_test_embed_42xyz";
crate::test_env::set_var("LEAN_CTX_MODELS_DIR", unique);
let dir = EmbeddingEngine::model_directory();
assert_eq!(dir.to_string_lossy(), unique);
assert!(!EmbeddingEngine::is_available());
crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
}
#[test]
fn ensure_downloaded_is_offline_noop_when_model_present() {
let _lock = crate::core::data_dir::test_env_lock();
let tmp = std::env::temp_dir().join(format!("lc-embed-545-{}", std::process::id()));
crate::test_env::set_var("LEAN_CTX_MODELS_DIR", tmp.to_str().unwrap());
let selected = model_registry::resolve_model();
let config = selected.config();
let model_dir = tmp.join(selected.storage_dir_name());
std::fs::create_dir_all(&model_dir).unwrap();
std::fs::write(model_dir.join("model.onnx"), b"onnx").unwrap();
std::fs::write(model_dir.join(config.vocab_file.filename()), b"vocab").unwrap();
assert!(EmbeddingEngine::is_available());
assert!(EmbeddingEngine::ensure_downloaded().is_ok());
crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
#[cfg(feature = "embeddings")]
fn exe_path_test_artifact_detection() {
assert!(exe_path_is_test_artifact(Path::new(
"/repo/rust/target/debug/deps/conformance_suite-0a1b2c3d4e5f6789"
)));
assert!(exe_path_is_test_artifact(Path::new(
"/repo/rust/target/release/deps/lean_ctx-deadbeefcafef00d"
)));
assert!(!exe_path_is_test_artifact(Path::new(
"/usr/local/bin/lean-ctx"
)));
assert!(!exe_path_is_test_artifact(Path::new(
"/Users/x/.local/bin/lean-ctx"
)));
assert!(!exe_path_is_test_artifact(Path::new("/opt/tools/ctx")));
assert!(!exe_path_is_test_artifact(Path::new(
"/repo/rust/target/debug/lean-ctx"
)));
}
#[test]
#[cfg(feature = "embeddings")]
fn background_load_disallowed_in_tests() {
assert!(!background_load_allowed());
}
#[test]
#[cfg(feature = "embeddings")]
fn embedding_bag_signature_detection() {
assert!(is_embedding_bag_signature(&[
"input_ids".to_string(),
"offsets".to_string()
]));
assert!(!is_embedding_bag_signature(&[
"input_ids".to_string(),
"attention_mask".to_string()
]));
assert!(!is_embedding_bag_signature(&[
"input_ids".to_string(),
"attention_mask".to_string(),
"token_type_ids".to_string()
]));
assert!(!is_embedding_bag_signature(&["input_ids".to_string()]));
assert!(!is_embedding_bag_signature(&[
"input_ids".to_string(),
"offsets".to_string(),
"extra".to_string()
]));
}
#[test]
#[ignore = "downloads a real model from HuggingFace (~91MB)"]
#[cfg(feature = "embeddings")]
fn custom_hf_model_end_to_end() {
let model = model_registry::EmbeddingModel::from_str_name(
"hf:sentence-transformers/all-MiniLM-L6-v2@main",
)
.expect("valid hf: spec");
let base = std::env::temp_dir().join("lean_ctx_test_custom_hf_e2e");
let engine = EmbeddingEngine::load_model(&base, model.clone()).expect("load custom model");
assert_eq!(engine.dimensions(), 384, "probed dims from ONNX graph");
assert_eq!(
engine.model_name(),
"hf:sentence-transformers/all-MiniLM-L6-v2@main"
);
let v = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
assert_eq!(v.len(), 384);
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
let lock_path = base.join(model.storage_dir_name()).join("model.lock.json");
let lock: std::collections::BTreeMap<String, String> =
serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
assert!(lock.contains_key("model.onnx"));
assert!(lock.contains_key("tokenizer.json"));
let a = engine.embed("read a file from disk").unwrap();
let b = engine.embed("load file contents from filesystem").unwrap();
let c = engine.embed("the weather in Zurich is sunny").unwrap();
assert!(
cosine_similarity(&a, &b) > cosine_similarity(&a, &c),
"related texts must be closer"
);
}
#[test]
#[ignore = "downloads a real model from HuggingFace (~30MB)"]
#[cfg(feature = "embeddings")]
fn model2vec_potion_end_to_end() {
let model =
model_registry::EmbeddingModel::from_str_name("hf:minishlab/potion-base-8M@main")
.expect("valid hf: spec");
let base = std::env::temp_dir().join("lean_ctx_test_model2vec_e2e");
let engine =
EmbeddingEngine::load_model(&base, model.clone()).expect("load model2vec model");
assert_eq!(engine.dimensions(), 256, "probed dims from EmbeddingBag");
let code_vec = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
assert_eq!(code_vec.len(), 256);
let norm: f32 = code_vec.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
let read_vec = engine.embed("read a file from disk").unwrap();
let load_vec = engine.embed("load file contents from filesystem").unwrap();
let weather_vec = engine.embed("the weather in Zurich is sunny").unwrap();
assert!(
cosine_similarity(&read_vec, &load_vec) > cosine_similarity(&read_vec, &weather_vec),
"related texts must be closer"
);
let empty_vec = engine.embed("").unwrap();
assert_eq!(empty_vec.len(), 256);
}
}