candding 0.1.0

Pure candle embeddings for Rust with no ONNX: dense, sparse, multi-vector and rerank models today, image models on the roadmap
Documentation
use candle_core::DType;

use super::CanddingError;
use super::LengthKind;
use crate::registry::Backend;
use crate::registry::Catalog;
use crate::tokenizer::PromptTemplate;

fn on_device(backend: Backend, dtype: DType) -> String {
  CanddingError::UnsupportedOnDevice {
    model: "m".to_string(),
    backend,
    dtype,
  }
  .to_string()
}

#[test]
fn a_dtype_refusal_names_the_dtype_the_backend_and_the_reason() {
  let unsupported = CanddingError::UnsupportedDtype {
    model: "m".to_string(),
    dtype: DType::F16,
  };
  assert_eq!(
    unsupported.to_string(),
    "model `m` cannot run in f16: the catalog marks that dtype unsupported for this model"
  );
  assert_eq!(
    on_device(Backend::Cpu, DType::BF16),
    "model `m` cannot run on cpu: bf16 matmul is not supported"
  );
  assert_eq!(
    on_device(Backend::Cpu, DType::F16),
    "model `m` cannot run on cpu: f16 matmul for this checkpoint (it is accelerator-only there) is not supported"
  );
  assert_eq!(
    on_device(Backend::Metal, DType::F32),
    "model `m` cannot run on metal: f32 for this checkpoint (its registry cell reads unsupported there) is not supported"
  );
  assert_eq!(
    on_device(Backend::Cuda, DType::BF16),
    "model `m` cannot run on cuda: bf16 for this checkpoint (its registry cell reads unsupported there) is not supported"
  );
}

#[test]
fn a_catalog_refusal_names_the_builder_or_the_missing_descriptor() {
  let non_dense = |catalog| {
    CanddingError::NonDenseModel {
      model: "org/m".to_string(),
      catalog,
    }
    .to_string()
  };
  assert_eq!(
    non_dense(Catalog::Sparse),
    "model `org/m` is registered in the sparse catalog, not the dense one: load it with `SparseTextEmbedding::builder` (`candding embed --sparse`)"
  );
  assert_eq!(
    non_dense(Catalog::Bm25),
    "model `org/m` is registered in the sparse catalog, not the dense one: load it with `Bm25TextEmbedding::builder`"
  );
  assert_eq!(
    non_dense(Catalog::MultiVector),
    "model `org/m` is registered in the multi-vector catalog, not the dense one: load it with `MultiVectorTextEmbedding::builder` (`candding embed --multi-vector`)"
  );
  assert_eq!(
    non_dense(Catalog::Rerank),
    "model `org/m` is registered in the rerank catalog, not the dense one: load it with `TextCrossEncoder::builder` (`candding rerank`)"
  );
  let unregistered = |catalog| {
    CanddingError::UnregisteredModel {
      id: "org/m".to_string(),
      catalog,
    }
    .to_string()
  };
  assert_eq!(
    unregistered(Catalog::Sparse),
    "model `org/m` is not in the sparse registry, and no descriptor was supplied: an unregistered sparse model has no snapshot convention to infer its head or vocabulary from"
  );
  assert_eq!(
    unregistered(Catalog::MultiVector),
    "model `org/m` is not in the multi-vector registry, and no descriptor was supplied: an unregistered multi-vector model has no snapshot convention to infer its head or projection shape from"
  );
  assert_eq!(
    unregistered(Catalog::Rerank),
    "model `org/m` is not in the rerank registry, and no descriptor was supplied: an unregistered rerank model has no snapshot convention to infer its head or score activation from"
  );
}

#[test]
fn a_device_error_names_the_backend_and_its_feature_or_candles_reason() {
  let not_compiled = CanddingError::BackendNotCompiled {
    backend: Backend::Metal,
  };
  assert_eq!(
    not_compiled.to_string(),
    "device error: the metal backend is not compiled in; build candding with its `metal` feature"
  );
  let failed = CanddingError::Device {
    backend: Backend::Cuda,
    reason: "no device 3".to_string(),
  };
  assert_eq!(
    failed.to_string(),
    "device error: the cuda device could not be created: no device 3"
  );
}

#[test]
fn a_load_that_cannot_fit_names_both_sizes_in_gigabytes_and_the_ways_out() {
  let refused = CanddingError::InsufficientMemory {
    model: "Qwen/Qwen3-Embedding-8B".to_string(),
    backend: Backend::Metal,
    needed_bytes: 30_200_000_762,
    available_bytes: 30_150_672_384,
  };
  assert_eq!(
    refused.to_string(),
    "model `Qwen/Qwen3-Embedding-8B` needs 30.20 GB for its weights, but the metal device has 30.15 GB available: load it in a smaller dtype, on another device, or choose a smaller model"
  );
}

#[test]
fn a_length_refusal_names_its_minimum_and_what_the_fixed_tokens_take() {
  let too_small = |requested, minimum, kind| {
    CanddingError::LengthTooSmall {
      requested,
      minimum,
      kind,
    }
    .to_string()
  };
  assert_eq!(
    too_small(2, 3, LengthKind::Text { added: 2 }),
    "max_length 2 is below the minimum 3 for this tokenizer: its post-processor adds 2 special tokens to every text, and one real token has to fit beside them"
  );
  assert_eq!(
    too_small(3, 4, LengthKind::Pair { added: 3 }),
    "max_length 3 is below the minimum 4 for a (query, document) pair with this tokenizer: its post-processor adds 3 special tokens to every pair, and one real token has to fit beside them"
  );
  let prompt = LengthKind::Prompt {
    template: PromptTemplate::Qwen3Reranker,
    prefix: 39,
    suffix: 9,
  };
  assert_eq!(
    too_small(48, 49, prompt),
    "max_length 48 is below the minimum 49 for the qwen3_reranker prompt template with this tokenizer: its fixed prefix is 39 tokens and its suffix 9, and one content token has to fit between them"
  );
  let windowed = too_small(11, 12, LengthKind::Windowed { query_cap: 6 });
  assert!(
    windowed
      .starts_with("max_length 11 is below the minimum 12 for a windowed input with query cap 6:"),
    "{windowed}"
  );
  let query_cap = too_small(2, 3, LengthKind::QueryCap { added: 2 });
  assert!(
    query_cap.starts_with("query cap 2 is below the minimum 3 for a windowed input"),
    "{query_cap}"
  );
  assert!(
    query_cap.contains("adds 2 special tokens to a query"),
    "{query_cap}"
  );
}