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::fs;
use std::path::PathBuf;

use tempfile::tempdir;

use super::*;
use crate::error::LengthKind;

mod budgets;
mod markers;
mod pairs;
mod prompt;
mod prompt_truncation;
mod windows;

fn tiny(max_length: usize) -> TextTokenizer {
  let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tiny_tokenizer.json");
  TextTokenizer::from_file(&path, max_length, PadToken::Id(0)).unwrap()
}

#[test]
fn pads_on_the_right_and_reports_lengths() {
  let batch = tiny(64)
    .encode(&["hello world", "the cat sat on the mat"], &Device::Cpu)
    .unwrap();
  assert_eq!(batch.input_ids.dims(), &[2, 8]);
  let ids = batch.input_ids.to_vec2::<u32>().unwrap();
  assert_eq!(ids[0], vec![2, 4, 5, 3, 0, 0, 0, 0]);
  assert_eq!(ids[1], vec![2, 6, 7, 8, 9, 6, 10, 3]);
  let mask = batch.attention_mask.to_vec2::<u32>().unwrap();
  assert_eq!(mask[0], vec![1, 1, 1, 1, 0, 0, 0, 0]);
  assert_eq!(
    batch.token_type_ids.to_vec2::<u32>().unwrap()[1],
    vec![0; 8]
  );
  assert_eq!(batch.lengths, vec![4, 8]);
}

#[test]
fn truncates_to_max_length_including_special_tokens() {
  let tokenizer = tiny(5);
  let batch = tokenizer
    .encode(&["hello world", "the cat sat on the mat"], &Device::Cpu)
    .unwrap();
  assert_eq!(batch.input_ids.dims(), &[2, 5]);
  assert_eq!(
    batch.input_ids.to_vec2::<u32>().unwrap()[1],
    vec![2, 6, 7, 8, 3]
  );
  assert_eq!(batch.lengths, vec![4, 5]);
  assert_eq!(
    tokenizer.count(&["the cat sat on the mat", ""]).unwrap(),
    vec![5, 2]
  );
}

/// Counting must not pad: one long document beside thousands of short texts would materialize them all at its length.
#[test]
fn counting_encodes_without_padding() {
  let tokenizer = tiny(64);
  assert!(
    tokenizer.inner.get_padding().is_none(),
    "the tokenizer that counts must carry no padding"
  );
  let texts = ["hello", "the cat sat on the mat", "hello world"];
  assert_eq!(tokenizer.count(&texts).unwrap(), vec![3, 8, 4]);
  let padded = tokenizer.encode(&texts, &Device::Cpu).unwrap();
  assert_eq!(padded.input_ids.dims(), &[3, 8], "encode still pads");
  assert_eq!(padded.lengths, vec![3, 8, 4]);
}

/// The tokenizers crate subtracts the special tokens from `max_length` unchecked, panicking in debug and wrapping in release.
#[test]
fn a_max_length_that_leaves_no_room_for_text_is_refused() {
  let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tiny_tokenizer.json");
  for max_length in [0, 1, 2] {
    let err = match TextTokenizer::from_file(&path, max_length, PadToken::Id(0)) {
      Ok(_) => panic!("max_length {max_length} must be refused"),
      Err(err) => err,
    };
    assert!(
      matches!(err, CanddingError::LengthTooSmall { requested, minimum: 3, kind: LengthKind::Text { added: 2 } }
        if requested == max_length),
      "{max_length}: {err}"
    );
    assert!(err.to_string().contains('3'), "{err}");
  }
  assert_eq!(tiny(3).count(&["hello world"]).unwrap(), vec![3]);
}

#[test]
fn splits_subwords() {
  let ids = tiny(16)
    .encode(&["cats"], &Device::Cpu)
    .unwrap()
    .input_ids
    .to_vec2::<u32>()
    .unwrap();
  assert_eq!(ids[0], vec![2, 7, 11, 3]);
}

#[test]
fn named_pad_token_is_looked_up_in_the_vocabulary() {
  let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tiny_tokenizer.json");
  let tokenizer =
    TextTokenizer::from_file(&path, 64, PadToken::Named("[SEP]".to_string())).unwrap();
  let ids = tokenizer
    .encode(&["hello", "the cat sat"], &Device::Cpu)
    .unwrap()
    .input_ids
    .to_vec2::<u32>()
    .unwrap();
  assert_eq!(ids[0], vec![2, 4, 3, 3, 3]);
}

#[test]
fn pad_token_prefers_config_then_tokenizer_config_then_unknown() {
  let dir = tempdir().unwrap();
  let plain = dir.path().join("plain.json");
  fs::write(&plain, r#"{"pad_token": "<|endoftext|>"}"#).unwrap();
  let added = dir.path().join("added.json");
  fs::write(
    &added,
    r#"{"pad_token": {"content": "<pad>", "lstrip": false}}"#,
  )
  .unwrap();
  assert!(matches!(
    read_pad_token(r#"{"pad_token_id": 1}"#, Some(plain.as_path())).unwrap(),
    PadToken::Id(1)
  ));
  assert!(matches!(
    read_pad_token(r#"{"pad_token_id": null}"#, Some(plain.as_path())).unwrap(),
    PadToken::Named(name) if name == "<|endoftext|>"
  ));
  assert!(matches!(
    read_pad_token("{}", Some(added.as_path())).unwrap(),
    PadToken::Named(name) if name == "<pad>"
  ));
  assert!(matches!(
    read_pad_token("{}", None).unwrap(),
    PadToken::Unknown
  ));
}

/// `"pad_token_id": -1` means none and is not a `u32`, so the priority order carries on to `tokenizer_config.json`.
#[test]
fn a_negative_pad_token_id_is_absent_rather_than_fatal() {
  let dir = tempdir().unwrap();
  let named = dir.path().join("tokenizer_config.json");
  fs::write(&named, r#"{"pad_token": "<pad>"}"#).unwrap();
  assert!(matches!(
    read_pad_token(r#"{"pad_token_id": -1}"#, Some(named.as_path())).unwrap(),
    PadToken::Named(name) if name == "<pad>"
  ));
  assert!(matches!(
    read_pad_token(r#"{"pad_token_id": -1}"#, None).unwrap(),
    PadToken::Unknown
  ));
  assert!(matches!(
    read_pad_token(r#"{"pad_token_id": 0}"#, Some(named.as_path())).unwrap(),
    PadToken::Id(0),
  ));
}

#[test]
fn read_mask_token_prefers_the_published_name_and_falls_back_to_mask_bracket() {
  let dir = tempdir().unwrap();
  let named = dir.path().join("tokenizer_config.json");
  fs::write(&named, r#"{"mask_token": "<mask>"}"#).unwrap();
  assert_eq!(read_mask_token(Some(named.as_path())).unwrap(), "<mask>");

  let added = dir.path().join("added.json");
  fs::write(
    &added,
    r#"{"mask_token": {"content": "<mask>", "lstrip": true}}"#,
  )
  .unwrap();
  assert_eq!(read_mask_token(Some(added.as_path())).unwrap(), "<mask>");

  let no_mask_key = dir.path().join("no_mask_key.json");
  fs::write(&no_mask_key, r#"{"pad_token": "[PAD]"}"#).unwrap();
  assert_eq!(
    read_mask_token(Some(no_mask_key.as_path())).unwrap(),
    "[MASK]"
  );

  assert_eq!(read_mask_token(None).unwrap(), "[MASK]");
}

/// `Tokenizer::from_file` installs a published `padding` section, which the counting tokenizer must clear.
#[test]
fn a_padding_section_in_the_published_file_is_not_inherited() {
  let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tiny_tokenizer.json");
  let mut json: serde_json::Value =
    serde_json::from_str(&fs::read_to_string(source).unwrap()).unwrap();
  json["padding"] = serde_json::json!({
    "strategy": {"Fixed": 8},
    "direction": "Right",
    "pad_to_multiple_of": null,
    "pad_id": 0,
    "pad_type_id": 0,
    "pad_token": "[PAD]"
  });
  let dir = tempfile::tempdir().unwrap();
  let path = dir.path().join("tokenizer.json");
  fs::write(&path, json.to_string()).unwrap();
  let tokenizer = TextTokenizer::from_file(&path, 64, PadToken::Id(0)).unwrap();
  assert!(
    tokenizer.inner.get_padding().is_none(),
    "the file's padding must be cleared"
  );
  assert_eq!(tokenizer.count(&["", "hello"]).unwrap(), vec![2, 3]);
  let batch = tokenizer.encode(&["hello"], &Device::Cpu).unwrap();
  assert_eq!(
    batch.input_ids.dims(),
    &[1, 3],
    "a batch pads to its longest row, not to the file's fixed length"
  );
}