open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/tokenizer.rs
Claude 8f4d5b49b1
Some checks failed
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Has been cancelled
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
standards workspaces build + test / which workspaces this change touches (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
jouleclaw-loader-gguf: 161 -> 81 clippy warnings, and what the gate still needs
#373 left this crate as the remaining half of the per-configuration lint
hole: `jouleclaw-omni --features metal` is gated with `-D warnings`, but
loader-gguf, which omni depends on, is not linted at all on its own.

Two steps here, neither of them risky:

- `cargo clippy --fix` applied the machine-applicable suggestions. Only
  seven files; most of this crate's lints are not auto-fixable.
- the same three policy decisions omni's lib.rs already records, for the
  same reasons, since this is quantisation and tokeniser code with the
  same shapes: `needless_range_loop` (kernels walk several arrays at one
  index, often at an offset), `too_many_arguments` (a dequantiser's
  signature is its block format), and the two doc-indentation lints.

161 -> 81 on the default feature set.

It still cannot join the `-D warnings` list, and the measurement says
precisely why. Counting distinct sites, deduplicated, by crate:

                              default   + rayon fabric-meter
    loader-gguf src              75              76
    loader-gguf tests/examples   10              14
    jouleclaw-backend-apple       -              43

Three things that measurement settles, all of which I had wrong at some
point while taking it:

- `--no-deps` does NOT exclude workspace path-dependencies. Running
  `clippy -p jouleclaw-loader-gguf` reports jouleclaw-backend-apple's
  warnings too, so gating this crate means gating that one as well.
  That is also why 27 `unused_unsafe` and 15 `unused_imports` appear
  under fabric-meter and not on default: they are backend-apple's, in
  mps_matmul.rs, not this crate's.
- the remainder is in `src`, not tests. I guessed "largely tests and
  examples" from the first number and it is 75 against 10.
- what is left is mechanical rather than policy: 16
  `chunks_exact_to_as_chunks`, 9 `unnecessary_cast`, 9 `redundant_guards`,
  6 `collapsible_if`, 6 `manual_repeat_n`, 5 `manual_is_multiple_of`,
  5 `unnecessary_sort_by`, and a tail of ones and twos.

So closing it is ~80 edits here plus ~43 in a second crate, each needing
care because `chunks_exact` -> `as_chunks` changes remainder handling in
quantisation code. That is a decision about appetite, not a blocker, and
it is left as one.

Verified: 343 loader-gguf tests, the fabric-meter `process_calibration`
build that caught a deleted label yesterday, and omni's own
`-D warnings` gate and 77 tests all still pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-21 19:08:08 -04:00

264 lines
8.9 KiB
Rust

//! Tokenizer tests: SPM (SentencePiece BPE) algorithm.
//!
//! Each test builds a small vocabulary by hand, embeds it in a synthetic
//! GGUF, parses the GGUF, and verifies the tokenizer produces the expected
//! token IDs.
use jouleclaw_loader_gguf::synthetic::{synthesize_llama_gguf, SyntheticConfig};
use jouleclaw_loader_gguf::tokenizer::Vocab;
use jouleclaw_loader_gguf::read_gguf;
use std::io::Cursor;
/// Build a Vocab from a list of (token, score) pairs by going through the
/// synthetic GGUF round-trip. This exercises the same code path real models use.
fn vocab_from_tokens(
pairs: Vec<(&str, f32)>,
bos: Option<u32>,
eos: Option<u32>,
unk: Option<u32>,
) -> Vocab {
let owned: Vec<(String, f32)> = pairs.into_iter()
.map(|(t, s)| (t.to_string(), s)).collect();
let cfg = SyntheticConfig {
vocab_size: owned.len(),
embedding_length: 8,
block_count: 1,
feed_forward_length: 16,
head_count: 1,
head_count_kv: 1,
rms_eps: 1e-6,
seed: 1,
vocab: Some(owned),
merges: None,
bos_id: bos,
eos_id: eos,
unk_id: unk, chat_template: None,
};
let bytes = synthesize_llama_gguf(&cfg);
let model = read_gguf(Cursor::new(bytes)).expect("parse synthetic gguf with vocab");
Vocab::from_gguf(&model).expect("load vocab from gguf")
}
/// Vocabulary loads correctly from GGUF metadata.
#[test]
fn vocab_loads_from_gguf() {
let v = vocab_from_tokens(
vec![("<unk>", 0.0), ("<s>", 0.0), ("</s>", 0.0), ("a", -1.0), ("b", -2.0)],
Some(1), Some(2), Some(0),
);
assert_eq!(v.len(), 5);
assert_eq!(v.id_to_token(3), Some("a"));
assert_eq!(v.id_to_token(4), Some("b"));
assert_eq!(v.bos_id, Some(1));
assert_eq!(v.eos_id, Some(2));
assert_eq!(v.unk_id, Some(0));
assert_eq!(v.scores[3], -1.0);
}
/// Encode with no merges available: each prepared char becomes its own token.
/// Vocabulary contains exactly `▁`, `a`, `b`, `c`, `<unk>`.
/// Input "abc" → prepared "▁abc" → tokens ["▁", "a", "b", "c"].
#[test]
fn encode_with_no_merges_emits_per_char_tokens() {
let v = vocab_from_tokens(
vec![
("<unk>", 0.0), // 0
("\u{2581}", 0.0), // 1: prefix space
("a", -1.0), // 2
("b", -2.0), // 3
("c", -3.0), // 4
],
None, None, Some(0),
);
let ids = v.encode_spm("abc", false);
assert_eq!(ids, vec![1, 2, 3, 4]);
}
/// Encode where a merged token is preferred: vocabulary has both "a" and "ab",
/// with "ab" having higher score. The tokenizer should emit "ab" instead of
/// two separate tokens.
#[test]
fn encode_prefers_higher_scoring_merge() {
let v = vocab_from_tokens(
vec![
("<unk>", 0.0), // 0
("\u{2581}", 0.0), // 1
("a", -10.0), // 2: low priority
("b", -10.0), // 3
("ab", -1.0), // 4: high priority (preferred merge)
],
None, None, Some(0),
);
let ids = v.encode_spm("ab", false);
// After merge: ▁, ab → tokens 1, 4.
assert_eq!(ids, vec![1, 4],
"expected merge of a+b -> 'ab' since it has higher score");
}
/// Multi-step merging: vocabulary contains "ab", "bc", "abc". Score order:
/// "abc" > "ab" > "bc". Input "abc" should become a single token because
/// the algorithm finds the merged "ab"+"c" path through "abc".
///
/// The SPM algorithm only merges adjacent symbols; it can't directly
/// produce "abc" from individual chars. It would first merge "ab" or "bc"
/// (whichever has higher score), then merge the result with the remaining
/// char if a vocabulary entry exists for that combined string.
#[test]
fn encode_iterative_merge_to_three_chars() {
let v = vocab_from_tokens(
vec![
("<unk>", 0.0), // 0
("\u{2581}", 0.0), // 1
("a", -10.0), // 2
("b", -10.0), // 3
("c", -10.0), // 4
("ab", -2.0), // 5
("bc", -3.0), // 6: lower than "ab"
("abc", -1.0), // 7: highest
],
None, None, Some(0),
);
let ids = v.encode_spm("abc", false);
// Step 1: bigrams (a,b)=score(-2), (b,c)=score(-3). Pick (a,b) → "ab".
// Step 2: bigram (ab,c) → "abc" with score(-1). Pick it → single "abc" token.
// Final emit: ▁, abc → tokens 1, 7.
assert_eq!(ids, vec![1, 7],
"should iteratively merge a+b then ab+c into single 'abc' token");
}
/// Spaces in input are converted to U+2581. "a b" → "▁a▁b".
#[test]
fn encode_converts_spaces() {
let v = vocab_from_tokens(
vec![
("<unk>", 0.0), // 0
("\u{2581}", 0.0), // 1
("a", -1.0), // 2
("b", -2.0), // 3
("\u{2581}a", -0.5), // 4: ▁a as a merged token
("\u{2581}b", -0.5), // 5: ▁b as a merged token
],
None, None, Some(0),
);
let ids = v.encode_spm("a b", false);
// Prepared: ▁a▁b. Bigrams: (▁,a)=-0.5, (a,▁)=none, (▁,b)=-0.5.
// Merge (▁,a) → ▁a (token 4). Then (▁a,▁) = none, (▁,b) = -0.5.
// Merge (▁,b) → ▁b (token 5). Done.
// Final tokens: ▁a, ▁b → 4, 5.
assert_eq!(ids, vec![4, 5]);
}
/// BOS prepending works.
#[test]
fn encode_prepends_bos_when_requested() {
let v = vocab_from_tokens(
vec![
("<unk>", 0.0),
("<s>", 0.0),
("\u{2581}", 0.0),
("a", -1.0),
],
Some(1), None, Some(0),
);
let with_bos = v.encode_spm("a", true);
let without_bos = v.encode_spm("a", false);
assert_eq!(with_bos[0], 1, "first token should be BOS");
assert_eq!(&with_bos[1..], &without_bos[..]);
}
/// Byte fallback: a character not in the vocabulary becomes its UTF-8 bytes
/// emitted as `<0xNN>` tokens.
#[test]
fn encode_falls_back_to_bytes_for_unknown_chars() {
// Build a vocab with only ASCII letters + byte tokens for non-ASCII.
// Test char "©" = U+00A9 = UTF-8 bytes 0xC2 0xA9.
let mut tokens = vec![
("<unk>".to_string(), 0.0),
("\u{2581}".to_string(), 0.0),
("a".to_string(), -1.0),
];
// Add all 256 byte tokens so any UTF-8 byte can be emitted.
for b in 0..256u32 {
tokens.push((format!("<0x{:02X}>", b), -100.0));
}
let owned: Vec<(String, f32)> = tokens.to_vec();
let pairs: Vec<(&str, f32)> = owned.iter()
.map(|(s, sc)| (s.as_str(), *sc)).collect();
let v = vocab_from_tokens(pairs, None, None, Some(0));
let ids = v.encode_spm("a©", false);
// Expected: ▁ (1), a (2), <0xC2> (?), <0xA9> (?).
// Find the IDs of <0xC2> and <0xA9>.
let c2_id = v.token_to_id.get("<0xC2>").copied().unwrap();
let a9_id = v.token_to_id.get("<0xA9>").copied().unwrap();
assert_eq!(ids, vec![1, 2, c2_id, a9_id],
"non-ASCII char without dedicated token should fall back to UTF-8 byte tokens");
}
/// Decoding reverses the U+2581 substitution.
#[test]
fn decode_replaces_spm_marker_with_space() {
let v = vocab_from_tokens(
vec![
("<unk>", 0.0), // 0
("\u{2581}hello", -1.0), // 1
("\u{2581}world", -1.0), // 2
],
None, None, Some(0),
);
let text = v.decode_spm(&[1, 2]);
assert_eq!(text, "hello world",
"SPM ▁ markers should decode to spaces, with leading space trimmed");
}
/// Encoding is deterministic: same input always produces same token IDs.
#[test]
fn encode_is_deterministic() {
let v = vocab_from_tokens(
vec![
("<unk>", 0.0),
("\u{2581}", 0.0),
("a", -1.0), ("b", -1.0), ("c", -1.0),
("ab", -0.5), ("bc", -0.5),
],
None, None, Some(0),
);
let ids1 = v.encode_spm("abc", false);
let ids2 = v.encode_spm("abc", false);
let ids3 = v.encode_spm("abc", false);
assert_eq!(ids1, ids2);
assert_eq!(ids2, ids3);
}
/// Tie-break in merge selection: when two bigrams have equal score, the
/// leftmost one is chosen. This matches SPM's reference behavior.
#[test]
fn encode_tiebreak_prefers_leftmost() {
let v = vocab_from_tokens(
vec![
("<unk>", 0.0), // 0
("\u{2581}", 0.0), // 1
("a", -10.0), // 2
("b", -10.0), // 3
("aa", -1.0), // 4: same score as bb
("bb", -1.0), // 5
],
None, None, Some(0),
);
// Input "aabb": prepared "▁aabb". Bigrams: (▁,a)=none, (a,a)=-1, (a,b)=none, (b,b)=-1.
// Both (a,a) and (b,b) have score -1. Leftmost wins: (a,a) merges first → "aa".
// After merge: ▁,aa,b,b. New bigrams: (▁,aa)=none, (aa,b)=none, (b,b)=-1.
// Then (b,b) merges → "bb". Final: ▁, aa, bb → 1, 4, 5.
let ids = v.encode_spm("aabb", false);
assert_eq!(ids, vec![1, 4, 5]);
}