#2320·tokenizers

BpeTrainer emits a merge for a pair that occurs zero times (negative pair_counts sign-extended by `as u64`)

Author: Abraham-yCreated Aug 7, 2026Updated Sep 18, 2026
LabelsStale

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.

python
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:

  1. Counts are signed and the delta update can subtract:

    rust
    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.

  2. Both queue.push sites correctly refuse non-positive counts:

    rust
    let count = pair_counts[&pair];
    if count > 0 { queue.push(Merge { pair, count: count as u64, pos }); }
  3. But the staleness re-check has no such guard, and this is the path that lets a negative through:

    rust
    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:

rust
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