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 std::collections::HashMap;

use candle_core::DType;
use candle_core::Device;
use candle_core::Tensor;
use candle_nn::VarBuilder;

use super::*;
use crate::error::CanddingError;

mod alibi;
mod flash;

/// Its sigmoid, `0.880797`, is far from the raw `2.0` an `Identity` head returns.
const BIAS: f32 = 2.0;
const SIGMOID_OF_BIAS: f32 = 0.880_797_1;

fn zeros(shape: &[usize]) -> Tensor {
  Tensor::zeros(shape, DType::F32, &Device::Cpu).unwrap()
}

/// A zero-layer encoder's embeddings under `prefix`, every tensor zero except the LayerNorm weight.
fn embeddings(prefix: &str, type_vocab_size: usize) -> HashMap<String, Tensor> {
  let mut tensors = HashMap::new();
  for (name, shape) in [
    ("embeddings.word_embeddings.weight", vec![8, 4]),
    ("embeddings.position_embeddings.weight", vec![16, 4]),
    (
      "embeddings.token_type_embeddings.weight",
      vec![type_vocab_size, 4],
    ),
    ("embeddings.LayerNorm.bias", vec![4]),
  ] {
    tensors.insert(format!("{prefix}.{name}"), zeros(&shape));
  }
  tensors.insert(
    format!("{prefix}.embeddings.LayerNorm.weight"),
    Tensor::ones(4, DType::F32, &Device::Cpu).unwrap(),
  );
  tensors
}

fn bias() -> Tensor {
  Tensor::new(&[BIAS], &Device::Cpu).unwrap()
}

fn score(model: &dyn RerankModel) -> Vec<f32> {
  let input_ids = Tensor::new(&[[1u32, 2, 3, 4], [1u32, 5, 3, 0]], &Device::Cpu).unwrap();
  let attention_mask = Tensor::new(&[[1u32, 1, 1, 1], [1u32, 1, 1, 0]], &Device::Cpu).unwrap();
  let token_type_ids = input_ids.zeros_like().unwrap();
  let lengths = vec![4, 3];
  let input = EncoderInput {
    input_ids: &input_ids,
    attention_mask: &attention_mask,
    token_type_ids: &token_type_ids,
    lengths: &lengths,
  };
  model
    .forward(&input)
    .unwrap()
    .iter()
    .map(RerankScore::value)
    .collect()
}

fn conventions(head: RerankHead, score_activation: ScoreActivation) -> RerankConventions {
  RerankConventions {
    head,
    score_activation,
    alibi_variant: None,
    position_scheme: None,
  }
}

#[test]
fn the_bert_pooler_head_applies_the_activation_load_rerank_hands_it() {
  let mut tensors = embeddings("bert", 2);
  tensors.insert("bert.pooler.dense.weight".to_string(), zeros(&[4, 4]));
  tensors.insert("bert.pooler.dense.bias".to_string(), zeros(&[4]));
  tensors.insert("classifier.weight".to_string(), zeros(&[1, 4]));
  tensors.insert("classifier.bias".to_string(), bias());
  let vb = VarBuilder::from_tensors(tensors, DType::F32, &Device::Cpu);
  let config_json = r#"{"vocab_size":8,"hidden_size":4,"num_hidden_layers":0,
    "num_attention_heads":1,"intermediate_size":1,"max_position_embeddings":16,
    "type_vocab_size":2,"layer_norm_eps":1e-12}"#;
  let model = load_rerank(
    "bert",
    config_json,
    vb,
    conventions(RerankHead::BertPooler, ScoreActivation::Sigmoid),
  )
  .unwrap();
  for score in score(model.as_ref()) {
    assert!((score - SIGMOID_OF_BIAS).abs() < 1e-6, "{score}");
  }
}

#[test]
fn the_roberta_classification_head_applies_the_activation_load_rerank_hands_it() {
  let mut tensors = embeddings("roberta", 1);
  tensors.insert("classifier.dense.weight".to_string(), zeros(&[4, 4]));
  tensors.insert("classifier.dense.bias".to_string(), zeros(&[4]));
  tensors.insert("classifier.out_proj.weight".to_string(), zeros(&[1, 4]));
  tensors.insert("classifier.out_proj.bias".to_string(), bias());
  let vb = VarBuilder::from_tensors(tensors, DType::F32, &Device::Cpu);
  let config_json = r#"{"vocab_size":8,"hidden_size":4,"num_hidden_layers":0,
    "num_attention_heads":1,"intermediate_size":1,"max_position_embeddings":16,
    "type_vocab_size":1,"layer_norm_eps":1e-12,"pad_token_id":1,
    "position_embedding_type":"absolute"}"#;
  let model = load_rerank(
    "xlm-roberta",
    config_json,
    vb,
    conventions(
      RerankHead::RobertaClassificationHead,
      ScoreActivation::Sigmoid,
    ),
  )
  .unwrap();
  for score in score(model.as_ref()) {
    assert!((score - SIGMOID_OF_BIAS).abs() < 1e-6, "{score}");
  }
}

#[test]
fn head_serializes_with_a_kind_tag() {
  assert_eq!(
    serde_json::to_value(RerankHead::RobertaClassificationHead).unwrap(),
    serde_json::json!({"kind": "roberta_classification_head"})
  );
  for (unembedding, name) in [(Unembedding::Tied, "tied"), (Unembedding::Untied, "untied")] {
    let logit_score = RerankHead::LogitScore {
      true_token_id: 9693,
      false_token_id: 2152,
      unembedding,
    };
    let json = serde_json::json!(
      {"kind": "logit_score", "true_token_id": 9693, "false_token_id": 2152, "unembedding": name}
    );
    assert_eq!(serde_json::to_value(logit_score).unwrap(), json);
    assert_eq!(
      serde_json::from_value::<RerankHead>(json).unwrap(),
      logit_score
    );
    assert_eq!(unembedding.name(), name);
  }
  assert_eq!(
    serde_json::from_str::<RerankHead>(r#"{"kind": "bert_pooler"}"#).unwrap(),
    RerankHead::BertPooler
  );
}

/// The "yes" row is every token's embedding and the "no" row zero, so a row scores 4, its normalized embedding's squared norm.
#[test]
fn the_logit_score_head_takes_the_ids_and_the_activation_load_rerank_hands_it() {
  let config_json = r#"{"vocab_size":8,"hidden_size":4,"num_hidden_layers":0,"num_attention_heads":1,
    "num_key_value_heads":1,"head_dim":4,"intermediate_size":4,"max_position_embeddings":16,
    "tie_word_embeddings":true}"#;
  let build = |true_token_id: u32, false_token_id: u32, unembedding, activation| {
    let mut embed = vec![1f32; 8 * 4];
    embed[4..8].fill(0.0);
    let mut tensors = HashMap::new();
    tensors.insert(
      "model.embed_tokens.weight".to_string(),
      Tensor::from_vec(embed, (8, 4), &Device::Cpu).unwrap(),
    );
    tensors.insert(
      "model.norm.weight".to_string(),
      Tensor::ones(4, DType::F32, &Device::Cpu).unwrap(),
    );
    let vb = VarBuilder::from_tensors(tensors, DType::F32, &Device::Cpu);
    let head = RerankHead::LogitScore {
      true_token_id,
      false_token_id,
      unembedding,
    };
    load_rerank("qwen3", config_json, vb, conventions(head, activation))
  };
  let score_with = |true_token_id: u32, false_token_id: u32, activation: ScoreActivation| {
    let model = build(true_token_id, false_token_id, Unembedding::Tied, activation).unwrap();
    score(model.as_ref())
  };
  let untied = build(3, 1, Unembedding::Untied, ScoreActivation::Identity);
  assert!(
    matches!(
      untied,
      Err(CanddingError::UnembeddingDisagreesWithConfig { .. })
    ),
    "{:?}",
    untied.err()
  );
  for value in score_with(3, 1, ScoreActivation::Identity) {
    assert!((value - 4.0).abs() < 1e-4, "{value}");
  }
  for value in score_with(1, 3, ScoreActivation::Identity) {
    assert!((value + 4.0).abs() < 1e-4, "{value}");
  }
  for value in score_with(3, 1, ScoreActivation::Sigmoid) {
    assert!(
      (value - 1.0 / (1.0 + (-4.0f32).exp())).abs() < 1e-5,
      "{value}"
    );
  }
}