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
//! Error type shared by the whole crate. The enum is here; the length budgets and the device refusals it names,
//! with their messages, have a submodule each.

mod device;
mod length;
mod memory;
#[cfg(test)]
mod tests;

use std::path::PathBuf;

use candle_core::DType;
pub use length::LengthKind;
use thiserror::Error;

use crate::registry::Backend;
use crate::registry::Catalog;

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CanddingError {
  /// A model the cache lacks, in a build without the `download` feature; `hint` is the `candding fetch` command.
  #[error(
    "model `{id}` is not in the hugging face cache, and this build of candding cannot download it: enable its `download` feature, or fetch the model with the candding CLI, `{hint}`"
  )]
  ModelNotFound { id: String, hint: String },
  #[error(
    "model `{id}` is not in the hugging face cache, and HF_HUB_OFFLINE forbids downloading it"
  )]
  Offline { id: String },
  #[error(
    "model `{id}` is gated: accept its license on huggingface.co, then set HF_TOKEN to an access token of that account"
  )]
  GatedModel { id: String },
  /// The hub answers an anonymous request for a private repository the same way as for a missing one.
  #[error(
    "model `{id}` was not found on the hugging face hub: check the id, or set HF_TOKEN if the repository is private"
  )]
  RepositoryNotFound { id: String },
  #[error("model `{id}` has no revision `{revision}` on the hugging face hub")]
  RevisionNotFound { id: String, revision: String },
  #[error("could not reach the hugging face hub to download `{id}`: {reason}")]
  Network { id: String, reason: String },
  #[error("downloading `{id}` failed: {reason}")]
  DownloadFailed { id: String, reason: String },
  /// A path the hub listed, or the commit it named, that would not stay inside the cache; refused before any download.
  #[error("the hub's answer for `{id}` names `{path}`, which {reason}; nothing was downloaded")]
  UnsafeHubPath {
    id: String,
    path: String,
    reason: String,
  },
  #[error("unsupported architecture `{0}`")]
  UnsupportedArchitecture(String),
  /// An id `catalog` does not carry and no descriptor for it: nothing on disk states its head or conventions, so it
  /// is refused rather than guessed.
  #[error(
    "model `{id}` is not in the {} registry, and no descriptor was supplied: an unregistered {} model has no snapshot convention to infer {} from",
    .catalog.name(),
    .catalog.name(),
    .catalog.unstated_facts()
  )]
  UnregisteredModel { id: String, catalog: Catalog },
  /// An id only `catalog` registers, refused by the dense builder with or without a descriptor: its encoder would
  /// otherwise run through the dense pipeline and return a vector with no error.
  #[error(
    "model `{model}` is registered in the {} catalog, not the dense one: load it with {}",
    .catalog.name(),
    .catalog.entry_point()
  )]
  NonDenseModel { model: String, catalog: Catalog },
  /// The catalog refuses `dtype` for this model on every backend.
  #[error(
    "model `{model}` cannot run in {}: the catalog marks that dtype unsupported for this model",
    .dtype.as_str()
  )]
  UnsupportedDtype { model: String, dtype: DType },
  /// `dtype` cannot run on `backend`: candle has no CPU BF16 matmul, or the catalog refuses that one cell.
  #[error(
    "model `{model}` cannot run on {}: {} is not supported",
    .backend.name(),
    device::refusal(.backend, .dtype)
  )]
  UnsupportedOnDevice {
    model: String,
    backend: Backend,
    dtype: DType,
  },
  #[error("model `{0}` does not support matryoshka truncation")]
  MrlUnsupported(String),
  #[error("invalid matryoshka dimension {requested}: model dimension is {dim}")]
  InvalidDim { requested: usize, dim: usize },
  #[error("dense module activation `{0}` is not supported; expected Identity or Tanh")]
  UnsupportedDenseActivation(String),
  /// Six sentence-transformers pooling modes exist and candding implements three; the other three return a
  /// different vector from a mean, so a repository that asks for one is refused rather than pooled as a mean.
  #[error(
    "pooling mode `{0}` is not implemented; candding pools `pooling_mode_cls_token`, `pooling_mode_mean_tokens` or `pooling_mode_lasttoken`"
  )]
  UnsupportedPooling(String),
  /// A `modules.json` type candding does not run cannot be skipped: it sits in the pipeline the repository
  /// publishes and the vectors it produces are part of the model's output.
  #[error(
    "modules.json entry `{0}` is not implemented; candding runs Transformer, Pooling, Dense and Normalize modules"
  )]
  UnsupportedModule(String),
  /// A `modules.json` type only the sparse pipeline runs, reached through `TextEmbeddingBuilder`.
  #[error(
    "modules.json entry `{0}` is a sparse-pipeline module; this repository publishes a sparse pipeline — load it with SparseTextEmbedding's builder instead of TextEmbedding's"
  )]
  SparseOnlyModule(String),
  /// A caller-supplied descriptor names `SparseHead::MiniCoil` without the second repository its per-word files
  /// come from.
  #[error(
    "SparseHead::MiniCoil needs its own per-word matrix, vocabulary and stopword list from a second repository, and this descriptor names none — supply one through SparseTextEmbeddingBuilder::descriptor with a head_source, or use a registered id"
  )]
  MissingHeadSource,
  /// `SparseTextEmbeddingBuilder` was given `Qdrant/bm25`, which has no encoder; `hint` names its entry point.
  #[error(
    "`{hint}` computes bm25 directly, with no device or dtype and no weights to load — SparseTextEmbedding's builder is for a head that runs a real encoder, which this one does not"
  )]
  ComputedSparseModel { hint: String },
  /// A multi-vector descriptor's `projection_in` differs from the hidden size of the checkpoint's encoder.
  #[error(
    "declared projection_in {declared} does not match the encoder's real hidden size {actual}: the registry entry and the checkpoint's own weights disagree"
  )]
  ProjectionInMismatch { declared: usize, actual: usize },
  /// A `MultiVectorHead::StanfordColbert` descriptor without the `query_length` its fixed-length query padding needs.
  #[error(
    "model `{model}` runs MultiVectorHead::StanfordColbert, which requires MultiVectorDescriptor::query_length to build a fixed-length, `[MASK]`-padded query tokenizer; this descriptor names none"
  )]
  MissingQueryLength { model: String },
  #[error("tensor `{name}` is missing from `{file}`")]
  MissingTensor { file: PathBuf, name: String },
  #[error("dense modules on disk {found:?} do not match the descriptor's {expected:?}")]
  DenseMismatch {
    expected: Vec<String>,
    found: Vec<String>,
  },
  #[error("missing file `{0}` in model directory")]
  MissingFile(PathBuf),
  /// A `.npy` file whose header cannot be read: no magic bytes, an unknown format version, or a truncated header.
  #[error("`{file}` is not a readable numpy array file: {reason}")]
  NpyHeader { file: PathBuf, reason: String },
  /// A `.npy` array in Fortran order; only C order is read, and nothing is transposed.
  #[error(
    "`{0}` stores its array in fortran (column-major) order; candding reads only C (row-major) order and refuses rather than silently transposing"
  )]
  NpyFortranOrder(PathBuf),
  /// A `.npy` dtype with no plain candle counterpart; `f4`, `f8`, `i2`, `i4`, `i8`, `u1` and `u4` are read.
  #[error(
    "`{file}` declares numpy dtype `{descr}`, which this reader does not map to a candle tensor dtype"
  )]
  NpyUnsupportedDtype { file: PathBuf, descr: String },
  /// A `.npy` byte order other than little-endian or native, or `|` on a dtype wider than one byte.
  #[error(
    "`{file}` declares byte order `{order}` in dtype `{descr}`; this reader only reads little-endian (`<`) or native (`=`) arrays, plus `|` for single-byte dtypes"
  )]
  NpyUnsupportedByteOrder {
    file: PathBuf,
    order: char,
    descr: String,
  },
  /// A `.npy` data section whose size differs from what its shape and dtype require.
  #[error(
    "`{file}` declares shape {shape:?} but its data section holds {actual_bytes} bytes, not the {expected_bytes} its shape and dtype require"
  )]
  NpyShapeMismatch {
    file: PathBuf,
    shape: Vec<usize>,
    expected_bytes: usize,
    actual_bytes: usize,
  },
  /// A refetch helps a registered id only: an unregistered pickle-only repository is fetched with the safetensors
  /// patterns, so the message also names the download a refetch cannot supply.
  #[error(
    "no weights in `{0}`: expected `model.safetensors`, `model.safetensors.index.json` or `pytorch_model.bin`. A snapshot fetched before its entry was registered can hold the configuration without the weights; delete that directory and fetch the model again. A repository whose only weights are a torch pickle and whose id no catalog carries is fetched without them, since the fetch asks for safetensors unless an entry declares the pickle format: download its `pytorch_model.bin` into `{0}` yourself, from `https://huggingface.co/<repo>/resolve/<revision>/pytorch_model.bin`, or pass the builder a local directory that holds it"
  )]
  MissingWeights(PathBuf),
  #[error("model directory `{0}` does not exist")]
  MissingDir(PathBuf),
  #[error("tokenizer error: {0}")]
  Tokenizer(String),
  /// An `EncoderInput` whose `lengths` do not hold one real-token count per batch row.
  #[error(
    "{lengths} lengths for a batch of {rows} rows: EncoderInput needs one real-token length per row"
  )]
  LengthsMismatch { lengths: usize, rows: usize },
  /// A length with no room for one real token beside the tokens `kind` names; the tokenizers crate would otherwise
  /// subtract them unchecked, a panic in a debug build and a wrapped budget in a release one.
  #[error("{}", length::message(.requested, .minimum, .kind))]
  LengthTooSmall {
    requested: usize,
    minimum: usize,
    kind: LengthKind,
  },
  /// A rendered prompt whose ids do not end in the ids the template's suffix encodes to alone: the tokenizer merges
  /// the suffix into the text before it, or the rendering and the template disagree, so the cut cannot restore it.
  #[error(
    "the {template} prompt does not end in its suffix ids with this tokenizer: expected {expected:?}, found {found:?}; this tokenizer does not keep the template's suffix apart from the text before it"
  )]
  PromptSuffixMismatch {
    template: String,
    expected: Vec<u32>,
    found: Vec<u32>,
  },
  /// An instruction handed to a reranker whose input is a segment pair: the pair has no slot to put it in, and
  /// scoring the pair without it would silently drop what the caller asked for.
  #[error(
    "model `{model}` reads a (query, document) segment pair, which has no instruction slot; only a prompt reranker takes an instruction"
  )]
  InstructionOnPairInput { model: String },
  /// A checkpoint whose `config.json` ties its embeddings while its weights also carry an `lm_head.weight`, so the
  /// logit-score head cannot tell which matrix produces the logits.
  #[error(
    "config.json says tie_word_embeddings is true, and the weights also carry an lm_head.weight: the logit-score head cannot tell which matrix produces the logits"
  )]
  TiedEmbeddingsWithLmHead,
  /// An untied checkpoint without the `lm_head.weight` its logits come from.
  #[error(
    "config.json says tie_word_embeddings is false, and the weights carry no lm_head.weight: an untied checkpoint's logits come from that matrix"
  )]
  MissingLmHead,
  /// A registry entry's `Unembedding` and the checkpoint's own `tie_word_embeddings` disagree: the head would read its
  /// two rows from another matrix than the one the entry was verified with.
  #[error(
    "the entry says the logit-score head's unembedding is {declared}, but config.json says tie_word_embeddings is {tie_word_embeddings}"
  )]
  UnembeddingDisagreesWithConfig {
    declared: String,
    tie_word_embeddings: bool,
  },
  /// A flash checkpoint's entry and its `config.json` name different position schemes, or one names none: the
  /// family's two implementation repositories default `position_embedding_type` differently.
  #[error(
    "the entry declares {declared} positions and config.json's position_embedding_type is {config}: a flash checkpoint loads only when both name the same scheme"
  )]
  PositionSchemeDisagreesWithConfig { declared: String, config: String },
  /// A logit-score token id the checkpoint's vocabulary does not reach.
  #[error("logit-score token id {token_id} is outside the vocabulary of {vocab_size} tokens")]
  LogitTokenOutOfRange { token_id: u32, vocab_size: usize },
  #[error("config error: {0}")]
  Config(#[from] serde_json::Error),
  #[error(transparent)]
  Candle(#[from] candle_core::Error),
  #[error(transparent)]
  Io(#[from] std::io::Error),
  /// `backend` is not compiled into this build of candding.
  #[error(
    "device error: the {} backend is not compiled in; build candding with its `{}` feature",
    .backend.name(),
    .backend.name()
  )]
  BackendNotCompiled { backend: Backend },
  /// A compiled-in `backend` could not create the device; `reason` is candle's message.
  #[error("device error: the {} device could not be created: {reason}", .backend.name())]
  Device { backend: Backend, reason: String },
  /// The weights need more than `backend` has available, so the load stops before any of them is read.
  #[error(
    "model `{model}` needs {} for its weights, but the {} device has {} available: load it in a smaller dtype, on another device, or choose a smaller model",
    memory::gb(.needed_bytes),
    .backend.name(),
    memory::gb(.available_bytes)
  )]
  InsufficientMemory {
    model: String,
    backend: Backend,
    needed_bytes: u64,
    available_bytes: u64,
  },
}

pub type Result<T> = std::result::Result<T, CanddingError>;