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]>
162 lines
5.4 KiB
Rust
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]);
|
|
}
|