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
#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]>
264 lines
8.9 KiB
Rust
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]);
|
|
}
|