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
//! `tiny_tokenizer_qwen3_reranker.json`, written and checked by `scripts/prompt_fixture.py`, encodes every text below to the published ids.

use std::fs;
use std::path::Path;
use std::path::PathBuf;

use candle_core::Device;
use tempfile::TempDir;
use tempfile::tempdir;

use crate::error::CanddingError;
use crate::error::LengthKind;
use crate::tokenizer::PadToken;
use crate::tokenizer::PromptTemplate;
use crate::tokenizer::PromptTokenizer;

pub(super) const PAD: u32 = 151_643;
pub(super) const DEFAULT: &str =
  "Given a web search query, retrieve relevant passages that answer the query";
const CUSTOM: &str =
  "Given a question about geography, judge whether the passage names the correct city";
const QUERY: &str = "What is the capital of China?";
const DOCUMENT: &str = "The capital of China is Beijing.";

/// Identical on sentence-transformers' `CrossEncoder` and on the model card's own code.
const P0_IDS: [u32; 85] = [
  151644, 8948, 198, 60256, 3425, 279, 11789, 20027, 279, 8502, 3118, 389, 279, 11361, 323, 279,
  758, 1235, 3897, 13, 7036, 429, 279, 4226, 646, 1172, 387, 330, 9693, 1, 476, 330, 2152, 3263,
  151645, 198, 151644, 872, 198, 27, 641, 1235, 26818, 16246, 264, 3482, 2711, 3239, 11, 17179,
  9760, 46769, 429, 4226, 279, 3239, 198, 27, 2859, 26818, 3555, 374, 279, 6722, 315, 5616, 5267,
  75692, 26818, 576, 6722, 315, 5616, 374, 26549, 13, 151645, 198, 151644, 77091, 198, 151667, 271,
  151668, 271,
];

pub(super) fn subset_path() -> PathBuf {
  PathBuf::from(env!("CARGO_MANIFEST_DIR"))
    .join("tests/fixtures/tiny_tokenizer_qwen3_reranker.json")
}

pub(super) fn qwen3(path: &Path, max_length: usize) -> PromptTokenizer {
  PromptTokenizer::from_file(
    path,
    max_length,
    PadToken::Id(PAD),
    PromptTemplate::Qwen3Reranker,
  )
  .unwrap()
}

fn edited(edit: impl FnOnce(&mut serde_json::Value)) -> (TempDir, PathBuf) {
  let mut json: serde_json::Value =
    serde_json::from_str(&fs::read_to_string(subset_path()).unwrap()).unwrap();
  edit(&mut json);
  let dir = tempdir().unwrap();
  let path = dir.path().join("tokenizer.json");
  fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
  (dir, path)
}

#[test]
fn the_template_renders_its_prefix_content_and_suffix_verbatim() {
  let template = PromptTemplate::Qwen3Reranker;
  assert_eq!(
    template.render("I", "Q", "D"),
    format!(
      "{}<Instruct>: I\n<Query>: Q\n<Document>: D{}",
      template.prefix(),
      template.suffix()
    )
  );
  assert!(template.suffix().ends_with("</think>\n\n"));
  assert!(!template.suffix().ends_with("\n\n\n"));
}

/// The known triple encodes to the references' 85 ids: 39 of prefix, the content and 9 of suffix.
#[test]
fn a_known_triple_encodes_to_the_references_ids() {
  let tokenizer = qwen3(&subset_path(), 8192);
  assert_eq!(tokenizer.prefix_len(), 39);
  assert_eq!(tokenizer.suffix_ids(), &P0_IDS[76..]);
  assert_eq!(
    tokenizer.prompt_ids(DEFAULT, QUERY, DOCUMENT).unwrap(),
    P0_IDS
  );
}

#[test]
fn the_instruction_reaches_its_slot() {
  let tokenizer = qwen3(&subset_path(), 8192);
  let slot = |instruction: &str| {
    let ids = tokenizer.prompt_ids(instruction, QUERY, DOCUMENT).unwrap();
    let head = &P0_IDS[..43];
    let tail = &P0_IDS[56..];
    assert!(ids.starts_with(head), "{instruction:?}");
    assert!(ids.ends_with(tail), "{instruction:?}");
    ids[head.len()..ids.len() - tail.len()].to_vec()
  };
  assert_eq!(slot(DEFAULT), &P0_IDS[43..56]);
  let custom = slot(CUSTOM);
  assert_ne!(custom, slot(DEFAULT));
  assert_eq!(custom.len(), 14);
}

/// The space before the newline encodes with it: one id where the default instruction took 13.
#[test]
fn an_empty_instruction_renders_empty() {
  let template = PromptTemplate::Qwen3Reranker;
  assert!(
    template
      .content("", QUERY, DOCUMENT)
      .starts_with("<Instruct>: \n<Query>: ")
  );
  let tokenizer = qwen3(&subset_path(), 8192);
  let empty = tokenizer.prompt_ids("", QUERY, DOCUMENT).unwrap();
  assert_eq!(empty.len(), P0_IDS.len() - 13);
  assert_eq!(&empty[..43], &P0_IDS[..43]);
  assert_eq!(&empty[44..], &P0_IDS[57..]);
}

#[test]
fn a_max_length_below_the_templates_minimum_is_refused_by_name() {
  for max_length in [0, 1, 9, 39, 48] {
    let refused = PromptTokenizer::from_file(
      &subset_path(),
      max_length,
      PadToken::Id(PAD),
      PromptTemplate::Qwen3Reranker,
    );
    match refused {
      Err(
        err @ CanddingError::LengthTooSmall {
          requested,
          minimum: 49,
          kind:
            LengthKind::Prompt {
              template: PromptTemplate::Qwen3Reranker,
              prefix: 39,
              suffix: 9,
            },
        },
      ) => {
        assert_eq!(requested, max_length);
        let message = err.to_string();
        assert!(message.contains("minimum 49"), "{message}");
        assert!(message.contains("qwen3_reranker"), "{message}");
      }
      Err(other) => panic!("{max_length}: unexpected error: {other}"),
      Ok(_) => panic!("{max_length} leaves no content token"),
    }
  }
  assert_eq!(qwen3(&subset_path(), 49).max_length(), 49);
}

/// A prompt rendered with the template file's `\n\n\n` suffix ends in that one id instead of the suffix's `\n\n`.
#[test]
fn the_suffix_check_refuses_a_rendering_that_disagrees_with_the_template() {
  let tokenizer = qwen3(&subset_path(), 8192);
  let template = PromptTemplate::Qwen3Reranker;
  let rendered = format!("{}\n", template.render(DEFAULT, QUERY, DOCUMENT));
  match tokenizer.rendered_ids(&rendered) {
    Err(CanddingError::PromptSuffixMismatch {
      expected, found, ..
    }) => {
      assert_eq!(expected, &P0_IDS[76..]);
      assert_ne!(found, expected);
    }
    other => panic!("expected a suffix mismatch, got {other:?}"),
  }
}

/// Without the special tokens the merges join the document's final `.` with `<`, and as text they push the minimum above 49.
#[test]
fn the_suffix_check_refuses_a_tokenizer_that_merges_the_suffix_into_the_document() {
  let (_dir, path) = edited(|json| json["added_tokens"] = serde_json::json!([]));
  let tokenizer = qwen3(&path, 8192);
  let minimum = tokenizer.prefix_len() + tokenizer.suffix_ids().len() + 1;
  assert!(minimum > 49, "{minimum}");
  let refused =
    PromptTokenizer::from_file(&path, 49, PadToken::Id(PAD), PromptTemplate::Qwen3Reranker);
  assert!(
    matches!(refused, Err(CanddingError::LengthTooSmall { minimum: m, kind: LengthKind::Prompt { .. }, .. }) if m == minimum),
    "{:?}",
    refused.err()
  );
  match tokenizer.prompt_ids(DEFAULT, QUERY, DOCUMENT) {
    Err(CanddingError::PromptSuffixMismatch {
      expected, found, ..
    }) => assert_ne!(found, expected),
    other => panic!("expected a suffix mismatch, got {other:?}"),
  }
}

/// The published file's no-op post-processor is not what keeps added tokens out of a prompt.
#[test]
fn a_prompt_gets_no_special_tokens_from_the_post_processor() {
  let (_dir, path) = edited(|json| {
    json["post_processor"] = serde_json::json!({
      "type": "TemplateProcessing",
      "single": [{"SpecialToken": {"id": "<|endoftext|>", "type_id": 0}}, {"Sequence": {"id": "A", "type_id": 0}}],
      "pair": [{"Sequence": {"id": "A", "type_id": 0}}, {"Sequence": {"id": "B", "type_id": 1}}],
      "special_tokens": {"<|endoftext|>": {"id": "<|endoftext|>", "ids": [PAD], "tokens": ["<|endoftext|>"]}}
    })
  });
  let tokenizer = qwen3(&path, 8192);
  assert_eq!(
    tokenizer.prompt_ids(DEFAULT, QUERY, DOCUMENT).unwrap(),
    P0_IDS
  );
}

#[test]
fn a_batch_is_right_padded_with_real_lengths_and_zero_token_types() {
  let tokenizer = qwen3(&subset_path(), 8192);
  let pairs = [(QUERY, DOCUMENT), ("", "")];
  let batch = tokenizer.encode(DEFAULT, &pairs, &Device::Cpu).unwrap();
  let short = tokenizer.prompt_ids(DEFAULT, "", "").unwrap();
  assert_eq!(batch.lengths, vec![85, short.len()]);
  assert_eq!(tokenizer.count(DEFAULT, &pairs).unwrap(), batch.lengths);
  let ids = batch.input_ids.to_vec2::<u32>().unwrap();
  assert_eq!(ids[0], P0_IDS);
  assert_eq!(&ids[1][..short.len()], short.as_slice());
  assert!(ids[1][short.len()..].iter().all(|&id| id == PAD));
  let mask = batch.attention_mask.to_vec2::<u32>().unwrap();
  assert_eq!(mask[1].iter().filter(|&&m| m == 1).count(), short.len());
  assert!(mask[0].iter().all(|&m| m == 1));
  let types = batch.token_type_ids.to_vec2::<u32>().unwrap();
  assert!(types.iter().flatten().all(|&t| t == 0));
}