Skip to content

normalizers: implement the Lowercase normalizer - #72

Open
GuyStone wants to merge 1 commit into
crusoecloud:mainfrom
GuyStone:guys/lowercase-normalizer
Open

GuyStone wants to merge 1 commit into
crusoecloud:mainfrom
GuyStone:guys/lowercase-normalizer

Conversation

@GuyStone

Copy link
Copy Markdown

Summary

Adds the Lowercase normalizer. Tokenizers declaring {"type": "Lowercase"}
previously failed to load with unsupported normalizer type: Lowercase — the
config was parsed but had no runtime implementation.

Notes

Parity. Lowercasing is per character via char::to_lowercase, mirroring HF's
NormalizedString::lowercase. Deliberately not str::to_lowercase, which also
applies the Greek final-sigma rule — ὈΔΥΣΣΕΎΣὀδυσσεύς where HF gives
ὀδυσσεύσ. U+03A3 is the only character the two disagree on.

Testing

9 unit tests covering the Cow::Borrowed guarantee, titlecase letters, the 1→2
expansion of İ U+0130, final sigma, and a capital at every one of 28 byte
offsets to pin the SWAR lane indexing. Plus
local_tests::lowercase_normalizer_matches_huggingface, which diffs directly
against the tokenizers crate. Full suite passes; fmt and clippy -D warnings
clean.

Also re-exports Prepend at the crate root, missed when it landed in edccd31.

`NormalizerConfig::Lowercase` is already parsed but has no runtime
implementation, so tokenizers declaring `{"type": "Lowercase"}` fail to load
with "unsupported normalizer type: Lowercase". Add the Lowercase normalizer
and wire it into `Normalizer`. This unlocks standalone `Lowercase` and the
CLIP / sentence-transformers shape `Sequence[NFC, Replace, Lowercase]`;
uncased BERT still needs `NFD` + `StripAccents`.

Lowercasing is done per character with `char::to_lowercase`, mirroring HF's
`NormalizedString::lowercase`. This is deliberately not `str::to_lowercase`:
the std method additionally applies the Greek final-sigma rule, rendering
`ὈΔΥΣΣΕΎΣ` as `ὀδυσσεύς` where HF produces `ὀδυσσεύσ`. U+03A3 is the only
character the two disagree on. The "would this character change?" predicate
compares against the mapping itself rather than `char::is_uppercase()`, which
is false for titlecase letters such as `Dž` U+01C5 and would produce wrong
output.

Normalization runs single-threaded over the whole document before rayon
parallelism starts at BPE, so both the scan and the transform take a SWAR fast
path over ASCII, mirroring `ascii_lower_run_end` in `pre_tokenizers::scan`. The
transform is branch-free: `0x80 >> 2 == 0x20` is exactly the ASCII case bit.
Measured against a straightforward `chars().flat_map(char::to_lowercase)` on
~100 KiB inputs: 68x faster on already-lowercase ASCII, 45x on mixed case and
on ALL CAPS, and 0.95x on CJK (both dominated by std's LOWERCASE_TABLE binary
search, which HF pays too).

Tests cover the Cow::Borrowed guarantee, a capital at every one of 28 byte
offsets to pin the SWAR lane indexing and window boundary, titlecase letters,
the 1->2 expansion of `İ` U+0130, and final sigma — the last asserting
inequality with `str::to_lowercase` so a refactor towards it fails loudly.
`local_tests::lowercase_normalizer_matches_huggingface` diffs against the
`tokenizers` crate directly.

Also re-exports `Prepend` at the crate root, which was missed when it landed
in edccd31 (`Nfc` and `Replace` are both there).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant