Skip to content

fix: implement add_tokens instead of silently ignoring it - #63

Merged
AlonKejzman merged 2 commits into
crusoecloud:mainfrom
murphymatt:fix/add-tokens-vocabulary-extension
Aug 10, 2026
Merged

fix: implement add_tokens instead of silently ignoring it#63
AlonKejzman merged 2 commits into
crusoecloud:mainfrom
murphymatt:fix/add-tokens-vocabulary-extension

Conversation

@murphymatt

Copy link
Copy Markdown
Contributor

Summary

_TokenizerShim.add_tokens and add_special_tokens returned 0 and threw their
argument away, so the patched transformers backend could not grow its
vocabulary. That makes fastokens unusable on any checkpoint whose embedding
matrix is padded above the tokenizer's token count — a common shape. Loaders
reconcile the two by appending placeholder tokens until len(tokenizer) reaches
the embedding count and then assert that it did:

tokenizer.add_tokens([f"<|padding_token_{i}|>" for i in range(num_embeddings - len(tokenizer))])
if len(tokenizer) != num_embeddings:
    raise ...   # always, under fastokens

The failure is fail-closed at load, so nothing is ever mis-tokenized; the
backend is simply unavailable on those models. Models whose tokenizer and
embedding counts already agree never enter the padding branch, which is why this
went unnoticed.

Tracing it surfaced a second stored-but-unused value: encode_special_tokens,
which transformers sets for every split_special_tokens=True encode. The shim
kept the flag and encoded as though it were unset, so control-token strings in
untrusted text still produced control-token ids. Callers pair that flag with
add_special_tokens to promote ordinary added tokens to special first, so both
no-ops had to go for that path to work at all.

What changed

Vocabulary extension is now native, and ids are assigned the way tokenizers
assigns them so the two backends stay interchangeable under the same loader:

  • a content already in the added vocabulary keeps its id and only its flags can
    change — this is what promotes a token to special;
  • a content the model already carries becomes an added token at that same id,
    leaving the vocabulary size untouched;
  • anything else is appended contiguously above the vocabulary, which lands
    padding placeholders exactly on the padded embedding rows.

Tokenizer::add_tokens compiles the new set before committing it, so a rejected
batch cannot leave a half-extended tokenizer, and it drops the opt-in prefix
cache, whose entries predate the new tokens. AddedTokenPolicy threads through
encode, adding the SkipSpecial mode that leaves special tokens as ordinary
text while the rest of the added vocabulary still matches — the exact rule
upstream applies under encode_special_tokens.

On the Python side the shim reads added tokens back from the backend instead of
the constructor JSON, so extensions survive to_str, pickling, deepcopy, and
added_tokens_decoder. get_vocab unions the id scan with the added entries,
which also keeps len(get_vocab()) == get_vocab_size() when declared ids leave a
gap.

One divergence is deliberately left alone: get_vocab_size answers the full
vocabulary for both values of with_added_tokens, where upstream returns the
base vocabulary for False. The backend does not track the split, and
over-reporting is the safer direction for callers that size embedding tables or
logit masks from it, so it is documented in place rather than changed here.

Test plan

  • cargo test — 264 pass, 16 new. Includes a parity test that runs the same
    batches through tokenizers and this crate and compares ids and
    vocabulary sizes across all four cases (new contents, a repeat, a string
    the model already has, a promotion to special).
  • New python/tests/test_add_tokens.py covers the shim directly: padding up
    to an embedding count, idempotence, promotion, serialization round-trips,
    and special-token splitting. Wired into CI alongside the existing
    transformers job.
  • python/tests/test_patch_transformers.py asserts end-to-end equality with
    the unpatched backend for both padding and split_special_tokens.
  • Green on transformers 4.57.1 and 5.3.0 (both patch paths).
  • cargo fmt --check, cargo clippy -- -D warnings.

Made with Cursor

murphymatt and others added 2 commits August 9, 2026 07:17
`_TokenizerShim.add_tokens` and `add_special_tokens` returned 0 and discarded
their argument, so the patched `transformers` backend could not grow its
vocabulary. That makes fastokens unusable on any checkpoint whose embedding
matrix is padded above the tokenizer's token count: loaders reconcile the two
by appending placeholder tokens until `len(tokenizer)` reaches the embedding
count, then assert it did, and the assertion fails with the vocabulary
unchanged. The failure is fail-closed at load, so nothing is mis-tokenized —
the backend is simply unavailable on those models.

Tracing it surfaced a second stored-but-unused value: `encode_special_tokens`,
which `transformers` sets for every `split_special_tokens=True` encode. The
shim kept the flag and encoded as if it were unset, so control-token strings in
untrusted text still produced control-token ids. Callers pair that flag with
`add_special_tokens` to promote ordinary added tokens first, so both no-ops had
to go for that path to work.

Vocabulary extension is now native. `Tokenizer::add_tokens` rebuilds the
added-token matcher and assigns ids the way `tokenizers` does — a content
already in the vocabulary keeps its id and only its flags change (this is what
promotes a token to special), a content the model already carries becomes an
added token at that same id, and anything else is appended contiguously above
the vocabulary, which lands padding placeholders exactly on the padded rows. The
set is compiled before it is committed so a rejected batch cannot leave a
half-extended tokenizer, and the opt-in prefix cache is dropped since its
entries predate the new tokens. `AddedTokenPolicy` threads through `encode`,
adding the `SkipSpecial` mode that leaves special tokens as ordinary text while
the rest of the added vocabulary still matches.

The shim now reads added tokens back from the backend rather than the
constructor JSON, so extensions survive `to_str`, pickling, deepcopy, and
`added_tokens_decoder`. `get_vocab` unions the id scan with the added entries,
which also keeps `len(get_vocab()) == get_vocab_size()` when declared ids leave
a gap. `get_vocab_size` still answers the full vocabulary for both values of
`with_added_tokens`; that divergence is separate and over-reports rather than
under-reports, so it is documented in place instead of changed here.

Verified against `tokenizers` directly: after padding, both backends report the
same size and assign the same ids, and `split_special_tokens` produces identical
output. The end-to-end tests assert that equality through `AutoTokenizer` on
both supported transformers lines.

Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses five confirmed findings from the review of the add-tokens work:

- encode_batch_flat ignored split_special_tokens: the bulk flat path
  hardcoded AddedTokenPolicy::All, so it could not suppress control-token
  IDs for untrusted input the way encode/encode_batch now can. Thread the
  parameter through and route via encode_with_policy.

- add_special_tokens did not force special=True on AddedToken-like objects,
  diverging from HuggingFace. An AddedToken(content, special=False) passed to
  add_special_tokens was registered non-special, so encode_special_tokens
  would not skip it. Coerce object args to special=True (copying, not
  mutating, the caller's object).

- get_vocab ignored with_added_tokens=False and always returned the full
  vocab, so transformers.get_added_vocab() (full minus base) saw an empty
  diff and dropped added tokens on save/reload. Exclude the added set when
  with_added_tokens=False.

- get_added_tokens_decoder and to_str drifted the added-token `normalized`
  flag: reading it from the backend (serde default false) instead of the
  source JSON flipped absent-normalized from HF's True default to False, and
  to_str wrote that back, mutating tokenizer.json on a load/save round-trip.
  Preserve source entries verbatim, overlay only the live `special` flag
  (promotions), and append tokens added after construction.

Adds Python regression tests for each and updates the .pyi stub. Rust
type-checks (cargo check); Python tests require a maturin build to run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AlonKejzman

Copy link
Copy Markdown
Collaborator

Thank you for your contribution!

@AlonKejzman
AlonKejzman merged commit d3eb552 into crusoecloud:main Aug 10, 2026
19 checks passed
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.

2 participants