use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
use model2vec_rs::model::StaticModel;
use crate::types::Chunk;
pub const DEFAULT_MODEL_NAME: &str = "minishlab/potion-code-16M-v2";
const DEFAULT_STUB_DIM: usize = 256;
fn fnv1a(s: &str) -> u32 {
let mut h: u32 = 0x811C_9DC5;
for unit in s.encode_utf16() {
h ^= unit as u32;
h = h.wrapping_mul(0x0100_0193);
}
h
}
struct Mulberry32 {
a: u32,
}
impl Mulberry32 {
fn new(seed: u32) -> Self {
Self { a: seed }
}
fn next_unit(&mut self) -> f64 {
self.a = self.a.wrapping_add(0x6D2B_79F5);
let mut t = self.a;
t = (t ^ (t >> 15)).wrapping_mul(t | 1);
t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 61));
((t ^ (t >> 14)) as f64) / 4_294_967_296.0
}
}
fn stub_embed(text: &str, dim: usize) -> Vec<f32> {
let mut rng = Mulberry32::new(fnv1a(text));
let mut v = vec![0f32; dim];
let mut norm: f64 = 0.0;
for slot in v.iter_mut() {
let u1 = rng.next_unit().max(1e-12);
let u2 = rng.next_unit();
let g = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
*slot = g as f32;
norm += g * g;
}
norm = norm.sqrt();
if norm == 0.0 || norm.is_nan() {
norm = 1.0; }
for slot in v.iter_mut() {
*slot = ((*slot as f64) / norm) as f32;
}
v
}
#[derive(Clone)]
pub enum Model {
Static { inner: Arc<StaticModel>, dim: usize },
Stub { dim: usize },
}
impl std::fmt::Debug for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Model::Static { dim, .. } => f.debug_struct("Model::Static").field("dim", dim).finish(),
Model::Stub { dim } => f.debug_struct("Model::Stub").field("dim", dim).finish(),
}
}
}
impl Model {
pub fn encode(&self, texts: &[String]) -> Vec<Vec<f32>> {
match self {
Model::Static { inner, .. } => inner.encode(texts),
Model::Stub { dim } => texts.iter().map(|t| stub_embed(t, *dim)).collect(),
}
}
pub fn kind(&self) -> &'static str {
match self {
Model::Static { .. } => "static",
Model::Stub { .. } => "stub",
}
}
pub fn dim(&self) -> usize {
match self {
Model::Static { dim, .. } | Model::Stub { dim } => *dim,
}
}
}
pub fn make_stub_model(dim: usize) -> Model {
Model::Stub { dim }
}
fn load_static(path: &str) -> Result<Model, String> {
let inner = StaticModel::from_pretrained(path, None, None, None).map_err(|e| e.to_string())?;
let dim = inner.encode_single("a").len();
if dim == 0 {
return Err(format!(
"model '{path}' produced a zero-dimension embedding"
));
}
Ok(Model::Static {
inner: Arc::new(inner),
dim,
})
}
static MODEL_CACHE: LazyLock<Mutex<HashMap<String, Model>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn load_model(model_path: Option<&str>) -> (Model, String) {
load_model_with(model_path, load_static)
}
fn load_model_with(
model_path: Option<&str>,
load: impl Fn(&str) -> Result<Model, String>,
) -> (Model, String) {
let resolved = model_path.unwrap_or(DEFAULT_MODEL_NAME).to_string();
let mut cache = MODEL_CACHE.lock().expect("model cache mutex");
if let Some(model) = cache.get(&resolved) {
return (model.clone(), resolved);
}
let model = load(&resolved).unwrap_or_else(|e| {
eprintln!(
"csp: could not load Model2Vec model '{resolved}': {e}. \
Falling back to the deterministic stub embedder — set --model to a valid \
Model2Vec id/path (and ensure network/HF cache) for real embeddings."
);
make_stub_model(DEFAULT_STUB_DIM)
});
cache.insert(resolved.clone(), model.clone());
(model, resolved)
}
pub fn embed_chunks(model: &Model, chunks: &[Chunk]) -> Vec<Vec<f32>> {
embed_chunk_refs(model, &chunks.iter().collect::<Vec<_>>())
}
pub fn embed_chunk_refs(model: &Model, chunks: &[&Chunk]) -> Vec<Vec<f32>> {
if chunks.is_empty() {
return Vec::new();
}
let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
model.encode(&texts)
}
mod backend;
pub use backend::{BasicArgs, SelectableBasicBackend};
#[cfg(test)]
mod tests;