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
//! `naver/splade-code-06B`'s tokenizer adds no special tokens, so the empty string has zero positions.

use super::*;

fn empty_hidden(batch: usize, hidden_size: usize) -> Tensor {
  Tensor::from_vec(Vec::<f32>::new(), (batch, 0, hidden_size), &Device::Cpu).unwrap()
}

fn empty_mask(batch: usize) -> Tensor {
  Tensor::from_vec(Vec::<u32>::new(), (batch, 0), &Device::Cpu).unwrap()
}

#[test]
fn pool_logits_returns_empty_rows_for_a_zero_length_sequence() {
  let model = tiny_model();
  let rows = model
    .pool_logits(&empty_hidden(1, 8), &empty_mask(1))
    .unwrap();
  assert_eq!(rows.len(), 1);
  assert!(rows[0].is_empty(), "{:?}", rows[0]);
}

#[test]
fn pool_logits_handles_every_row_of_a_multi_row_zero_length_batch() {
  let model = tiny_model();
  let rows = model
    .pool_logits(&empty_hidden(3, 8), &empty_mask(3))
    .unwrap();
  assert_eq!(rows.len(), 3);
  assert!(rows.iter().all(SparseEmbedding::is_empty));
}

#[test]
fn forward_returns_an_empty_row_for_a_zero_token_batch_of_one() {
  let model = tiny_model();
  let input_ids = Tensor::from_vec(Vec::<u32>::new(), (1, 0), &Device::Cpu).unwrap();
  let attention_mask = Tensor::from_vec(Vec::<u32>::new(), (1, 0), &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: &[0],
  };
  let rows = model.forward(&input).unwrap();
  assert_eq!(rows.len(), 1);
  assert!(rows[0].is_empty());
}

#[test]
fn a_zero_length_row_among_real_rows_matches_the_row_encoded_alone() {
  let model = tiny_model();
  let device = Device::Cpu;

  let alone_ids = Tensor::from_vec(vec![1u32, 2], (1, 2), &device).unwrap();
  let alone_mask = Tensor::from_vec(vec![1u32, 1], (1, 2), &device).unwrap();
  let alone_types = alone_ids.zeros_like().unwrap();
  let alone = model
    .forward(&EncoderInput {
      input_ids: &alone_ids,
      attention_mask: &alone_mask,
      token_type_ids: &alone_types,
      lengths: &[2],
    })
    .unwrap();

  let input_ids = Tensor::from_vec(vec![1u32, 2, 3, 0], (2, 2), &device).unwrap();
  let attention_mask = Tensor::from_vec(vec![1u32, 1, 0, 0], (2, 2), &device).unwrap();
  let token_type_ids = input_ids.zeros_like().unwrap();
  let rows = model
    .forward(&EncoderInput {
      input_ids: &input_ids,
      attention_mask: &attention_mask,
      token_type_ids: &token_type_ids,
      lengths: &[2, 0],
    })
    .unwrap();

  assert_eq!(rows.len(), 2);
  assert_eq!(
    rows[0].indices, alone[0].indices,
    "{:?} vs {:?}",
    rows[0], alone[0]
  );
  for (a, b) in rows[0].values.iter().zip(&alone[0].values) {
    assert!((a - b).abs() < 1e-5, "{:?} vs {:?}", rows[0], alone[0]);
  }
  assert!(rows[1].is_empty(), "{:?}", rows[1]);
}

#[test]
fn real_rows_on_either_side_of_an_empty_one_each_match_their_own_alone_encoding() {
  let model = tiny_model();
  let device = Device::Cpu;

  let alone_row = |ids: &[u32]| {
    let input_ids = Tensor::from_vec(ids.to_vec(), (1, ids.len()), &device).unwrap();
    let attention_mask = Tensor::from_vec(vec![1u32; ids.len()], (1, ids.len()), &device).unwrap();
    let token_type_ids = input_ids.zeros_like().unwrap();
    model
      .forward(&EncoderInput {
        input_ids: &input_ids,
        attention_mask: &attention_mask,
        token_type_ids: &token_type_ids,
        lengths: &[ids.len()],
      })
      .unwrap()
      .remove(0)
  };
  let alone_first = alone_row(&[1, 2]);
  let alone_third = alone_row(&[4, 5, 3]);

  // Row 1 (the empty one) is padded to the batch's longest real row, 3.
  let input_ids = Tensor::from_vec(vec![1u32, 2, 0, 0, 0, 0, 4, 5, 3], (3, 3), &device).unwrap();
  let attention_mask =
    Tensor::from_vec(vec![1u32, 1, 0, 0, 0, 0, 1, 1, 1], (3, 3), &device).unwrap();
  let token_type_ids = input_ids.zeros_like().unwrap();
  let rows = model
    .forward(&EncoderInput {
      input_ids: &input_ids,
      attention_mask: &attention_mask,
      token_type_ids: &token_type_ids,
      lengths: &[2, 0, 3],
    })
    .unwrap();

  assert_eq!(rows.len(), 3);
  assert_eq!(rows[0].indices, alone_first.indices, "{:?}", rows[0]);
  for (a, b) in rows[0].values.iter().zip(&alone_first.values) {
    assert!(
      (a - b).abs() < 1e-5,
      "row 0: {:?} vs {:?}",
      rows[0],
      alone_first
    );
  }
  assert!(rows[1].is_empty(), "{:?}", rows[1]);
  assert_eq!(rows[2].indices, alone_third.indices, "{:?}", rows[2]);
  for (a, b) in rows[2].values.iter().zip(&alone_third.values) {
    assert!(
      (a - b).abs() < 1e-5,
      "row 2: {:?} vs {:?}",
      rows[2],
      alone_third
    );
  }
}

/// candle's Metal SDPA divides a zero softmax sum for a row with no visible key, which a zero-length row is.
#[cfg(feature = "metal")]
#[test]
fn a_zero_length_row_among_real_rows_does_not_corrupt_the_real_row_on_metal() {
  let device = candle_core::Device::new_metal(0).expect("a metal device");
  let varmap = VarMap::new();
  let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
  let model = SpladeTiedSparse::load(tiny_qwen3_config(), vb).unwrap();

  let alone_ids = Tensor::from_vec(vec![1u32, 2], (1, 2), &device).unwrap();
  let alone_mask = Tensor::from_vec(vec![1u32, 1], (1, 2), &device).unwrap();
  let alone_types = alone_ids.zeros_like().unwrap();
  let alone = model
    .forward(&EncoderInput {
      input_ids: &alone_ids,
      attention_mask: &alone_mask,
      token_type_ids: &alone_types,
      lengths: &[2],
    })
    .unwrap();

  let input_ids = Tensor::from_vec(vec![1u32, 2, 3, 0], (2, 2), &device).unwrap();
  let attention_mask = Tensor::from_vec(vec![1u32, 1, 0, 0], (2, 2), &device).unwrap();
  let token_type_ids = input_ids.zeros_like().unwrap();
  let rows = model
    .forward(&EncoderInput {
      input_ids: &input_ids,
      attention_mask: &attention_mask,
      token_type_ids: &token_type_ids,
      lengths: &[2, 0],
    })
    .unwrap();

  assert_eq!(rows.len(), 2);
  assert!(
    rows[0].values.iter().all(|v| v.is_finite()),
    "real row must stay finite on Metal: {:?}",
    rows[0]
  );
  assert_eq!(
    rows[0].indices, alone[0].indices,
    "{:?} vs {:?}",
    rows[0], alone[0]
  );
  for (a, b) in rows[0].values.iter().zip(&alone[0].values) {
    assert!((a - b).abs() < 1e-4, "{:?} vs {:?}", rows[0], alone[0]);
  }
  assert!(rows[1].is_empty(), "{:?}", rows[1]);
}