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` encodes the prompts below to the published file's ids: 85 for P0 under the default instruction.

use std::borrow::Cow;

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

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.";

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

fn prompt_descriptor(default_instruction: &'static str) -> RerankDescriptor {
  let mut descriptor = stub_descriptor();
  descriptor.input = RerankInput::Prompt {
    template: PromptTemplate::Qwen3Reranker,
    default_instruction: Cow::Borrowed(default_instruction),
  };
  descriptor
}

fn prompt_model(default_instruction: &'static str, max_length: usize) -> TextCrossEncoder {
  cross_encoder(
    Box::new(StubRerankModel),
    prompt_descriptor(default_instruction),
    &subset_path(),
    max_length,
    8,
  )
}

fn scored_lengths(model: &TextCrossEncoder, documents: &[&str]) -> Vec<usize> {
  model
    .rerank(QUERY, documents, None)
    .unwrap()
    .iter()
    .map(|score| score.value() as usize)
    .collect()
}

#[test]
fn a_prompt_entry_hands_the_model_each_prompts_real_length() {
  let model = prompt_model(DEFAULT, 8192);
  let documents = [DOCUMENT, "", "Beijing."];
  let scored = scored_lengths(&model, &documents);
  assert_eq!(scored[0], 85);
  assert_eq!(model.pair_token_count(QUERY, &documents).unwrap(), scored);
  assert!(scored[1] < scored[2] && scored[2] < scored[0], "{scored:?}");
}

#[test]
fn the_descriptors_default_instruction_reaches_the_prompt() {
  let default = prompt_model(DEFAULT, 8192);
  let custom = prompt_model(CUSTOM, 8192);
  assert_eq!(default.pair_token_count(QUERY, &[DOCUMENT]).unwrap(), [85]);
  assert_eq!(custom.pair_token_count(QUERY, &[DOCUMENT]).unwrap(), [86]);
  assert_eq!(scored_lengths(&custom, &[DOCUMENT]), [86]);
}

#[test]
fn the_descriptors_input_kind_decides_what_the_model_reads() {
  let pair = cross_encoder(
    Box::new(StubRerankModel),
    stub_descriptor(),
    &subset_path(),
    8192,
    8,
  );
  let prompt = prompt_model(DEFAULT, 8192);
  let pair_count = pair.pair_token_count(QUERY, &[DOCUMENT]).unwrap()[0];
  assert!(pair_count < 85 - 39 - 9, "{pair_count}");
  assert_eq!(scored_lengths(&pair, &[DOCUMENT]), [pair_count]);
  assert_eq!(scored_lengths(&prompt, &[DOCUMENT]), [85]);
}

#[test]
fn a_prompt_entry_cuts_to_its_max_length_and_refuses_one_below_the_minimum() {
  let model = prompt_model(DEFAULT, 49);
  assert_eq!(model.max_length(), 49);
  assert_eq!(scored_lengths(&model, &[DOCUMENT]), [49]);
  let refused = InputTokenizer::from_file(
    &prompt_descriptor(DEFAULT).input,
    &subset_path(),
    48,
    PadToken::Id(0),
  );
  assert!(matches!(
    refused,
    Err(CanddingError::LengthTooSmall {
      requested: 48,
      minimum: 49,
      kind: LengthKind::Prompt { .. },
    })
  ));
}

#[test]
fn an_instruction_replaces_the_default_in_the_scored_prompt() {
  let model = prompt_model(DEFAULT, 8192);
  let with = |instruction: &str| {
    let scored: Vec<usize> = model
      .rerank_with_instruction(QUERY, &[DOCUMENT], instruction, None)
      .unwrap()
      .iter()
      .map(|score| score.value() as usize)
      .collect();
    let counted = model
      .pair_token_count_with_instruction(QUERY, &[DOCUMENT], instruction)
      .unwrap();
    assert_eq!(scored, counted, "{instruction:?}");
    scored
  };
  assert_eq!(with(CUSTOM), [86]);
  assert_eq!(with(DEFAULT), scored_lengths(&model, &[DOCUMENT]));
}

/// Unlike sentence-transformers' `prompt=""`, which falls back to the default; 13 ids fewer than the default's slot.
#[test]
fn an_empty_instruction_is_rendered_empty_rather_than_as_the_default() {
  let model = prompt_model(DEFAULT, 8192);
  let counted = model
    .pair_token_count_with_instruction(QUERY, &[DOCUMENT], "")
    .unwrap();
  assert_eq!(counted, [85 - 13]);
  let scored = model
    .rerank_with_instruction(QUERY, &[DOCUMENT], "", None)
    .unwrap();
  assert_eq!(scored[0].value() as usize, 85 - 13);
}