Summary
BpeTrainer can emit a merge for a pair that occurs zero times in the corpus, when continuing_subword_prefix (or end_of_word_suffix) is combined with max_token_length.
Under that combination a pair count in pair_counts: AHashMap<Pair, i32> can go negative. The value is then read through as u64, which sign-extends -1 to 18446744073709551615, and that entry wins the max-heap immediately — so the trainer merges a pair that nothing in the corpus could have produced.
Reachable with 8 words / 38 bytes. Confirmed on tokenizers==0.23.1.
This is a sibling of #2058 (positive i32 overflow); same field, opposite sign, and it needs far less data to hit.
Reproducer
Self-contained — no dependency beyond tokenizers. It trains, then replays the emitted merge list against the corpus and reports the true occurrence count of each pair at the moment it is merged. A count of 0 means the merge was unreachable.
import collections, json
from tokenizers import Tokenizer, models, pre_tokenizers, trainers
CORPUS = "##c ##cac# #ab c# #### accc#a#a#b a cb"
PREFIX, VOCAB_SIZE, MAX_TOKEN_LENGTH, SPECIALS = "##", 244, 4, ["a", "<unk>"]
def train(path):
tok = Tokenizer(models.BPE())
tok.pre_tokenizer = pre_tokenizers.WhitespaceSplit()
tok.train([path], trainers.BpeTrainer(
vocab_size=VOCAB_SIZE, show_progress=False,
continuing_subword_prefix=PREFIX, max_token_length=MAX_TOKEN_LENGTH,
special_tokens=SPECIALS))
m = json.loads(tok.to_str())["model"]["merges"]
return [tuple(x) if isinstance(x, list) else tuple(x.split(" ", 1)) for x in m]
def replay(merges):
counts = collections.Counter(CORPUS.split())
words = [[[c if i == 0 else PREFIX + c for i, c in enumerate(w)], n]
for w, n in counts.items()]
out = []
for rank, (a, b) in enumerate(merges):
n = sum(c for syms, c in words
for i in range(len(syms) - 1)
if syms[i] == a and syms[i + 1] == b)
out.append((rank, a, b, n))
new = a + (b[len(PREFIX):] if b.startswith(PREFIX) else b)
for e in words:
syms, res, i = e[0], [], 0
while i < len(syms):
if i + 1 < len(syms) and syms[i] == a and syms[i + 1] == b:
res.append(new); i += 2
else:
res.append(syms[i]); i += 1
e[0] = res
return out
path = "/tmp/phantom_corpus.txt"
open(path, "w").write(CORPUS + "\n")
for run in range(40):
rep = replay(train(path))
if any(r[3] == 0 for r in rep):
print(f"run {run}: unreachable merge found")
for rank, a, b, n in rep:
print(f" {rank:>3} ({a!r}, {b!r})".ljust(40)
+ f"count={n}" + (" <-- UNREACHABLE" if n == 0 else ""))
break
Output (tokenizers==0.23.1)
rank pair true count when merged
0 ('#', '###') 3
1 ('##a', '###') 2
2 ('##c', '###') 2
3 ('##', '##c') 2
4 ('a', '##c') 1
5 ('#', '##a') 1
6 ('c', '##b') 1
7 ('c', '###') 1
8 ('##a', '##c#') 1
9 ('###', '###') 1
10 ('##c', '##a') 0 <-- UNREACHABLE
11 ('##c', '##c#') 1
12 ('##a#', '##b') 1
13 ('#a', '##b') 1
Training with continuing_subword_prefix is itself nondeterministic (#1794, PR #2066), so this appears in a subset of runs: 4/40 runs over 4 distinct merge lists.
max_token_length is required. Sweeping it on this corpus, 30 runs each:
max_token_length |
2 |
3 |
4 |
5 |
6 |
8 |
100 |
unset |
| runs with an unreachable merge |
0/30 |
0/30 |
6/30 |
0/30 |
0/30 |
0/30 |
0/30 |
0/30 |
Mechanism
In tokenizers/src/models/bpe/trainer.rs:
-
Counts are signed and the delta update can subtract:
let (mut pair_counts, mut where_to_update): (AHashMap<Pair, i32>, _) = ...
let count = change * counts[iw] as i32;
*pair_counts.entry(pair).or_default() += count;
With max_token_length set, a merge that reuses an existing token id can apply the -1 neighbour delta while the length guard rejects the matching +1, so a pair already at 0 reaches -1.
-
Both queue.push sites correctly refuse non-positive counts:
let count = pair_counts[&pair];
if count > 0 { queue.push(Merge { pair, count: count as u64, pos }); }
-
But the staleness re-check has no such guard, and this is the path that lets a negative through:
if top.count != pair_counts[&top.pair] as u64 {
top.count = pair_counts[&top.pair] as u64; // -1 -> u64::MAX
queue.push(top);
continue;
}
if top.count < 1 || self.min_frequency > top.count {
break;
}
An entry already on the heap whose count later goes negative is re-pushed with the sign-extended value. On the next pop the staleness check passes (both sides are the same huge number), top.count < 1 is false, and it merges — ahead of every legitimate pair, since this is a max-heap.
Suggested fix
Guard the staleness re-check the same way the two push sites are guarded, e.g. drop the entry when the live count is not positive:
let live = pair_counts[&top.pair];
if live <= 0 {
continue; // spent; do not re-push
}
if top.count != live as u64 {
top.count = live as u64;
queue.push(top);
continue;
}
Comparing as i32 before the cast would also work. Widening the counter to i64 (as #2058 proposes for the positive direction) does not fix this on its own — any negative value sign-extends the same way.
Impact
The emitted merge is unreachable, so it silently consumes a vocabulary slot and shifts every subsequent merge, changing the tokenizer. It affects WordPieceTrainer, which sets continuing_subword_prefix("##") in its default builder, whenever max_token_length is also set.
Environment
tokenizers 0.23.1 (also reproduces on 0.22.2)
- Python 3.12, macOS arm64
Summary
BpeTrainercan emit a merge for a pair that occurs zero times in the corpus, whencontinuing_subword_prefix(orend_of_word_suffix) is combined withmax_token_length.Under that combination a pair count in
pair_counts: AHashMap<Pair, i32>can go negative. The value is then read throughas u64, which sign-extends-1to18446744073709551615, and that entry wins the max-heap immediately — so the trainer merges a pair that nothing in the corpus could have produced.Reachable with 8 words / 38 bytes. Confirmed on
tokenizers==0.23.1.This is a sibling of #2058 (positive
i32overflow); same field, opposite sign, and it needs far less data to hit.Reproducer
Self-contained — no dependency beyond
tokenizers. It trains, then replays the emitted merge list against the corpus and reports the true occurrence count of each pair at the moment it is merged. A count of0means the merge was unreachable.Output (
tokenizers==0.23.1)Training with
continuing_subword_prefixis itself nondeterministic (#1794, PR #2066), so this appears in a subset of runs: 4/40 runs over 4 distinct merge lists.max_token_lengthis required. Sweeping it on this corpus, 30 runs each:max_token_lengthMechanism
In
tokenizers/src/models/bpe/trainer.rs:Counts are signed and the delta update can subtract:
With
max_token_lengthset, a merge that reuses an existing token id can apply the-1neighbour delta while the length guard rejects the matching+1, so a pair already at0reaches-1.Both
queue.pushsites correctly refuse non-positive counts:But the staleness re-check has no such guard, and this is the path that lets a negative through:
An entry already on the heap whose count later goes negative is re-pushed with the sign-extended value. On the next pop the staleness check passes (both sides are the same huge number),
top.count < 1is false, and it merges — ahead of every legitimate pair, since this is a max-heap.Suggested fix
Guard the staleness re-check the same way the two push sites are guarded, e.g. drop the entry when the live count is not positive:
Comparing as
i32before the cast would also work. Widening the counter toi64(as #2058 proposes for the positive direction) does not fix this on its own — any negative value sign-extends the same way.Impact
The emitted merge is unreachable, so it silently consumes a vocabulary slot and shifts every subsequent merge, changing the tokenizer. It affects
WordPieceTrainer, which setscontinuing_subword_prefix("##")in its default builder, whenevermax_token_lengthis also set.Environment
tokenizers0.23.1 (also reproduces on 0.22.2)