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::Device;
use candle_nn::VarMap;
use tempfile::tempdir;

use super::*;
use crate::layers::activation::Activation;
use crate::models::bert::BertConfig;

fn tiny_config() -> BertConfig {
  BertConfig {
    vocab_size: 64,
    hidden_size: 1,
    num_hidden_layers: 0,
    num_attention_heads: 1,
    intermediate_size: 1,
    hidden_act: Activation::Gelu,
    max_position_embeddings: 32,
    type_vocab_size: 1,
    layer_norm_eps: 1e-12,
  }
}

/// `BAAI/bge-m3`'s own `pad_token_id` (1) and `position_embedding_type` ("absolute").
fn xlm_config(bert: BertConfig) -> XlmRobertaConfig {
  XlmRobertaConfig {
    bert,
    pad_token_id: 1,
    position_embedding_type: "absolute".to_string(),
  }
}

/// Never run: every `scatter_rows` test builds `hidden` by hand.
fn dummy_encoder() -> XlmRobertaModel {
  let varmap = VarMap::new();
  let vb = VarBuilder::from_varmap(&varmap, DType::F32, &Device::Cpu);
  XlmRobertaModel::load(xlm_config(tiny_config()), vb).unwrap()
}

fn head(weight: Tensor, bias: Tensor, vocab_size: usize) -> M3Sparse {
  M3Sparse {
    encoder: dummy_encoder(),
    weight,
    bias,
    vocab_size,
    dropped: DROPPED,
  }
}

/// Ids `[5, 5]` with weights `[0.25, 0.75]` give one entry `(5, 0.75)`; a sum would give 1.0.
#[test]
fn a_repeated_token_keeps_its_largest_weight() {
  let weight = Tensor::new(&[[1f32]], &Device::Cpu).unwrap();
  let bias = Tensor::new(&[0f32], &Device::Cpu).unwrap();
  let model = head(weight, bias, 64);

  let hidden = Tensor::new(&[[[0.25f32], [0.75]]], &Device::Cpu).unwrap();
  let input_ids = Tensor::from_vec(vec![5u32, 5], (1, 2), &Device::Cpu).unwrap();
  let rows = model.scatter_rows(&hidden, &input_ids, &[2]).unwrap();

  assert_eq!(rows[0].indices, vec![5]);
  assert!(
    (rows[0].values[0] - 0.75).abs() < 1e-6,
    "{:?}",
    rows[0].values
  );
}

/// The reference zeroes `<s>`, `</s>`, `<pad>` and `<unk>`, ids 0, 2, 1 and 3, after the scatter.
#[test]
fn the_four_special_ids_are_dropped() {
  let weight = Tensor::new(&[[1f32]], &Device::Cpu).unwrap();
  let bias = Tensor::new(&[0.1f32], &Device::Cpu).unwrap();
  let model = head(weight, bias, 64);

  let hidden = Tensor::new(
    &[[[0.1f32], [0.2], [0.3], [0.4], [0.5], [0.6]]],
    &Device::Cpu,
  )
  .unwrap();
  let input_ids = Tensor::from_vec(vec![0u32, 2, 1, 3, 9, 42], (1, 6), &Device::Cpu).unwrap();
  let rows = model.scatter_rows(&hidden, &input_ids, &[6]).unwrap();

  assert_eq!(rows[0].indices, vec![9, 42]);
  for (got, want) in rows[0].values.iter().zip([0.6f32, 0.7]) {
    assert!((got - want).abs() < 1e-6, "{got} vs {want}");
  }
}

/// `bge-m3`'s `empty` fixture tokenizes to `<s> </s>`, whose weights are all 0 after `relu`.
#[test]
fn an_all_zero_row_is_an_empty_embedding_not_an_error() {
  let weight = Tensor::new(&[[0f32]], &Device::Cpu).unwrap();
  let bias = Tensor::new(&[0f32], &Device::Cpu).unwrap();
  let model = head(weight, bias, 64);

  let hidden = Tensor::new(&[[[1f32], [2.0]]], &Device::Cpu).unwrap();
  let input_ids = Tensor::from_vec(vec![9u32, 10], (1, 2), &Device::Cpu).unwrap();
  let rows = model.scatter_rows(&hidden, &input_ids, &[2]).unwrap();

  assert!(rows[0].is_empty(), "{:?}", rows[0]);
}

/// The padded positions carry id 999 and a large weight, which must not reach the output.
#[test]
fn padded_positions_never_reach_the_fold() {
  let weight = Tensor::new(&[[1f32]], &Device::Cpu).unwrap();
  let bias = Tensor::new(&[0f32], &Device::Cpu).unwrap();
  let model = head(weight, bias, 64);

  let hidden = Tensor::new(&[[[0.1f32], [0.5], [0.05], [9.0], [9.0]]], &Device::Cpu).unwrap();
  let input_ids = Tensor::from_vec(vec![0u32, 55, 2, 999, 999], (1, 5), &Device::Cpu).unwrap();
  let rows = model.scatter_rows(&hidden, &input_ids, &[3]).unwrap();

  assert_eq!(rows[0].indices, vec![55]);
  assert!(
    (rows[0].values[0] - 0.5).abs() < 1e-6,
    "{:?}",
    rows[0].values
  );
}

/// A `(1, 256, vocab)` tensor fits in memory, so only the output shape shows no tensor takes `vocab_size` as a dimension.
#[test]
fn vocab_size_never_sizes_a_tensor() {
  let weight = Tensor::new(&[[1f32]], &Device::Cpu).unwrap();
  let bias = Tensor::new(&[0f32], &Device::Cpu).unwrap();
  let model = head(weight, bias, 250_002);

  let seq = 256;
  let hidden = Tensor::from_vec(vec![0.5f32; seq], (1, seq, 1), &Device::Cpu).unwrap();
  let ids: Vec<u32> = (0..seq as u32).collect();
  let input_ids = Tensor::from_vec(ids, (1, seq), &Device::Cpu).unwrap();
  let rows = model.scatter_rows(&hidden, &input_ids, &[seq]).unwrap();

  assert_eq!(rows[0].len(), seq - 4);
}

/// FlagEmbedding's own loader hides a missing file behind an info log and a randomly initialized head (`runner.py:90-97`).
#[test]
fn a_missing_sparse_linear_file_is_a_named_load_error() {
  let dir = tempdir().unwrap();
  let missing = dir.path().join("sparse_linear.pt");
  let varmap = VarMap::new();
  let vb = VarBuilder::from_varmap(&varmap, DType::F32, &Device::Cpu);

  let err = M3Sparse::load(xlm_config(tiny_config()), vb, &missing).unwrap_err();
  assert!(
    matches!(err, CanddingError::MissingFile(ref p) if p == &missing),
    "{err}"
  );
  assert!(err.to_string().contains("sparse_linear.pt"), "{err}");
}

/// `linear_head.bin` matches `sparse_linear.pt`'s one-column shape, so it serves this loader as it serves `M3Colbert`'s.
#[test]
fn load_reads_weight_and_bias_from_a_real_archive() {
  let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
    .join("tests")
    .join("fixtures")
    .join("pickle")
    .join("linear_head.bin");
  let config = xlm_config(BertConfig {
    hidden_size: 4,
    ..tiny_config()
  });
  let varmap = VarMap::new();
  let vb = VarBuilder::from_varmap(&varmap, DType::F32, &Device::Cpu);
  let model = M3Sparse::load(config, vb, &fixture).unwrap();

  assert_eq!(
    model
      .weight
      .to_dtype(DType::F32)
      .unwrap()
      .to_vec2::<f32>()
      .unwrap(),
    vec![vec![1.5f32, -2.0, 0.5, 0.25]]
  );
  assert_eq!(
    model
      .bias
      .to_dtype(DType::F32)
      .unwrap()
      .to_vec1::<f32>()
      .unwrap(),
    vec![0.75f32]
  );

  let input_ids = Tensor::from_vec(vec![4u32, 5, 6, 7], (1, 4), &Device::Cpu).unwrap();
  let attention_mask = Tensor::from_vec(vec![1u32; 4], (1, 4), &Device::Cpu).unwrap();
  let token_type_ids = input_ids.zeros_like().unwrap();
  let input = EncoderInput {
    input_ids: &input_ids,
    attention_mask: &attention_mask,
    token_type_ids: &token_type_ids,
    lengths: &[4],
  };
  model.forward(&input).unwrap();
}

/// The weights start at f16, as the pickle reader hands them; skipping the cast would fail an f32 by f16 matmul.
#[test]
fn the_head_is_cast_from_f16_to_the_runs_dtype() {
  let weight = Tensor::new(&[[0.5f32, -0.25, 0.1, 0.2]], &Device::Cpu)
    .unwrap()
    .to_dtype(DType::F16)
    .unwrap();
  let bias = Tensor::new(&[0.1f32], &Device::Cpu)
    .unwrap()
    .to_dtype(DType::F16)
    .unwrap();
  assert_eq!(weight.dtype(), DType::F16);

  let config = xlm_config(BertConfig {
    hidden_size: 4,
    ..tiny_config()
  });
  let varmap = VarMap::new();
  let vb = VarBuilder::from_varmap(&varmap, DType::F32, &Device::Cpu);
  let model = M3Sparse::from_raw_head(config, vb, weight, bias).unwrap();
  assert_eq!(model.weight.dtype(), DType::F32);
  assert_eq!(model.bias.dtype(), DType::F32);

  let input_ids = Tensor::from_vec(vec![4u32, 5, 6, 7], (1, 4), &Device::Cpu).unwrap();
  let attention_mask = Tensor::from_vec(vec![1u32; 4], (1, 4), &Device::Cpu).unwrap();
  let token_type_ids = input_ids.zeros_like().unwrap();
  let input = EncoderInput {
    input_ids: &input_ids,
    attention_mask: &attention_mask,
    token_type_ids: &token_type_ids,
    lengths: &[4],
  };
  model.forward(&input).unwrap();
}