open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/bpe_regex.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

162 lines
5.4 KiB
Rust

//! Tests for `encode_bpe_regex` — the regex pre-tokenizer version of BPE.
//!
//! These tests focus on cases where the regex pre-tokenizer produces
//! different (more correct) results than the simple whitespace
//! pre-tokenizer used by `encode_bpe`.
use jouleclaw_loader_gguf::synthetic::{synthesize_llama_gguf, SyntheticConfig};
use jouleclaw_loader_gguf::tokenizer::{pre_tokenize_gpt2, Vocab};
use jouleclaw_loader_gguf::read_gguf;
use std::io::Cursor;
fn vocab_with_merges(
tokens: Vec<(&str, f32)>,
merges: Vec<(&str, &str)>,
bos: Option<u32>,
eos: Option<u32>,
unk: Option<u32>,
) -> Vocab {
let owned_vocab: Vec<(String, f32)> = tokens.into_iter()
.map(|(t, s)| (t.to_string(), s)).collect();
let owned_merges: Vec<(String, String)> = merges.into_iter()
.map(|(l, r)| (l.to_string(), r.to_string())).collect();
let cfg = SyntheticConfig {
vocab_size: owned_vocab.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_vocab),
merges: Some(owned_merges),
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 gguf");
Vocab::from_gguf(&model).expect("load vocab")
}
/// Contractions split correctly. "don't" should produce chunks "don" and "'t",
/// each of which is then BPE-encoded.
#[test]
fn regex_bpe_splits_contractions() {
// The pre-tokenizer chunks "don't" into ["don", "'t"], so we need
// vocabulary entries for "don" and "'t".
let v = vocab_with_merges(
vec![
("d", 0.0), ("o", 0.0), ("n", 0.0), ("t", 0.0),
("'", 0.0),
("don", 0.0),
("'t", 0.0),
],
vec![("d", "o"), ("do", "n"), ("'", "t")],
None, None, None,
);
let chunks = pre_tokenize_gpt2("don't");
assert_eq!(chunks, vec!["don", "'t"]);
let ids = v.encode_bpe_regex("don't", false);
// After merging in each chunk:
// chunk "don": d+o=do, do+n=don → "don" (id 5)
// chunk "'t": '+t='t → "'t" (id 6)
assert_eq!(ids, vec![5, 6]);
}
/// Punctuation gets its own chunks distinct from adjacent words.
#[test]
fn regex_bpe_separates_punctuation() {
let _v = vocab_with_merges(
vec![("a", 0.0), (",", 0.0), (" ", 0.0), (" b", 0.0), (".", 0.0)],
vec![],
None, None, None,
);
let chunks = pre_tokenize_gpt2("a, b.");
// Expected: "a", ",", " b", "."
assert_eq!(chunks, vec!["a", ",", " b", "."]);
}
/// Whitespace-based and regex-based encoders agree on simple inputs
/// (single word, no punctuation, no contractions).
#[test]
fn regex_bpe_matches_whitespace_bpe_on_simple_input() {
let v = vocab_with_merges(
vec![("a", 0.0), ("b", 0.0), ("c", 0.0), ("ab", 0.0), ("abc", 0.0)],
vec![("a", "b"), ("ab", "c")],
None, None, None,
);
let regex_ids = v.encode_bpe_regex("abc", false);
let whitespace_ids = v.encode_bpe("abc", false, false);
// Both should produce a single "abc" token via the two merges.
assert_eq!(regex_ids, vec![4]);
assert_eq!(whitespace_ids, vec![4]);
}
/// BOS prepending works with the regex encoder.
#[test]
fn regex_bpe_prepends_bos() {
let v = vocab_with_merges(
vec![("<s>", 0.0), ("a", 0.0)],
vec![],
Some(0), None, None,
);
let with_bos = v.encode_bpe_regex("a", true);
let without_bos = v.encode_bpe_regex("a", false);
assert_eq!(with_bos.first(), Some(&0));
assert_eq!(&with_bos[1..], &without_bos[..]);
}
/// Round-trip: encode then decode should preserve the original ASCII text.
#[test]
fn regex_bpe_round_trips() {
// Vocabulary covers each letter individually plus the space marker
// and a few merged forms.
// Space byte 0x20 maps to char codepoint 288 = 'Ġ' under the GPT-2
// byte-to-char mapping.
let space_mark = char::from_u32(288).unwrap().to_string();
let mut tokens: Vec<(String, f32)> = "abcdefghijklmnopqrstuvwxyz"
.chars().map(|c| (c.to_string(), 0.0)).collect();
tokens.push((space_mark.clone(), 0.0));
// Add " w", " h" forms for "hello world" round-trip
tokens.push((format!("{}w", space_mark), 0.0));
let owned = tokens.to_vec();
let pairs: Vec<(&str, f32)> = owned.iter()
.map(|(s, sc)| (s.as_str(), *sc)).collect();
let v = vocab_with_merges(pairs, vec![(&space_mark, "w")], None, None, None);
let ids = v.encode_bpe_regex("hello world", false);
let decoded = v.decode_bpe(&ids);
assert_eq!(decoded, "hello world",
"regex BPE should round-trip ASCII text exactly");
}
/// Determinism.
#[test]
fn regex_bpe_is_deterministic() {
let v = vocab_with_merges(
vec![("a", 0.0), ("b", 0.0), ("c", 0.0)],
vec![],
None, None, None,
);
let ids1 = v.encode_bpe_regex("abc", false);
let ids2 = v.encode_bpe_regex("abc", false);
let ids3 = v.encode_bpe_regex("abc", false);
assert_eq!(ids1, ids2);
assert_eq!(ids2, ids3);
}
/// Empty input handled gracefully (with or without BOS).
#[test]
fn regex_bpe_empty_input() {
let v = vocab_with_merges(
vec![("<s>", 0.0)],
vec![],
Some(0), None, None,
);
let no_bos = v.encode_bpe_regex("", false);
assert!(no_bos.is_empty());
let with_bos = v.encode_bpe_regex("", true);
assert_eq!(with_bos, vec![0]);
}