Skip to main content

kopitiam_tokenizer/
merges.rs

1//! The ordered BPE merge rules.
2//!
3//! Training a BPE vocab produces merges in the order they were learned:
4//! the first merge discovered is the most frequent pair in the training
5//! corpus, so it has the highest priority at encode time. `tokenizer.json`
6//! and classic `merges.txt` files preserve this as a plain ordered list.
7//! [`MergeTable`] turns that list into "given a pair of adjacent token ids,
8//! what is its priority (lower = merge first) and what id does merging it
9//! produce?" -- the two questions [`crate::bpe::BpeTokenizer::encode`] asks
10//! on every iteration of the merge loop.
11
12use kopitiam_core::{Error, Result};
13use std::collections::HashMap;
14
15/// A learned merge's priority (rank) and the id it produces.
16///
17/// Rank is a `u32` purely to keep this struct small and `Copy`; ranks come
18/// from a merge list's position (`enumerate()`), so `u32::MAX` merges is
19/// not a real-world constraint (GPT-2/Qwen vocabs have on the order of
20/// 10^5 merges).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct MergeRule {
23    pub rank: u32,
24    pub merged_id: u32,
25}
26
27/// Maps an adjacent `(left_id, right_id)` pair to its [`MergeRule`].
28#[derive(Debug, Clone, Default)]
29pub struct MergeTable {
30    rules: HashMap<(u32, u32), MergeRule>,
31}
32
33impl MergeTable {
34    /// Builds a merge table from an ordered list of `(left_bytes,
35    /// right_bytes)` pairs, resolving each side and the concatenated
36    /// result to vocab ids via `lookup`.
37    ///
38    /// `lookup` is a closure rather than a `&Vocab` borrow so this stays
39    /// decoupled from [`crate::vocab::Vocab`]'s concrete type -- the only
40    /// thing a merge table needs from a vocab is "what id is this byte
41    /// string?".
42    pub fn build(
43        merges: &[(Vec<u8>, Vec<u8>)],
44        mut lookup: impl FnMut(&[u8]) -> Option<u32>,
45    ) -> Result<Self> {
46        let mut rules = HashMap::with_capacity(merges.len());
47        for (rank, (left, right)) in merges.iter().enumerate() {
48            let left_id = lookup(left).ok_or_else(|| Error::MalformedModel {
49                format: "bpe-merges",
50                reason: format!(
51                    "merge rule {rank} references left-hand token {left:?}, which is not in the vocab"
52                ),
53            })?;
54            let right_id = lookup(right).ok_or_else(|| Error::MalformedModel {
55                format: "bpe-merges",
56                reason: format!(
57                    "merge rule {rank} references right-hand token {right:?}, which is not in the vocab"
58                ),
59            })?;
60            let mut merged = left.clone();
61            merged.extend_from_slice(right);
62            let merged_id = lookup(&merged).ok_or_else(|| Error::MalformedModel {
63                format: "bpe-merges",
64                reason: format!(
65                    "merge rule {rank} produces token {merged:?}, which is not in the vocab"
66                ),
67            })?;
68
69            // A pair should only ever be taught once; a duplicate would
70            // silently make one of the two ranks unreachable. Treat it as
71            // a malformed merge list rather than quietly keeping the
72            // first (or last) one.
73            if rules
74                .insert(
75                    (left_id, right_id),
76                    MergeRule {
77                        rank: rank as u32,
78                        merged_id,
79                    },
80                )
81                .is_some()
82            {
83                return Err(Error::MalformedModel {
84                    format: "bpe-merges",
85                    reason: format!(
86                        "pair ({left_id}, {right_id}) is taught by more than one merge rule"
87                    ),
88                });
89            }
90        }
91        Ok(Self { rules })
92    }
93
94    /// The merge rule for an adjacent pair, if the pair has one.
95    pub fn get(&self, left_id: u32, right_id: u32) -> Option<MergeRule> {
96        self.rules.get(&(left_id, right_id)).copied()
97    }
98
99    pub fn len(&self) -> usize {
100        self.rules.len()
101    }
102
103    pub fn is_empty(&self) -> bool {
104        self.rules.is_empty()
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::vocab::Vocab;
112
113    fn test_vocab() -> Vocab {
114        Vocab::from_entries(vec![
115            b"a".to_vec(),
116            b"b".to_vec(),
117            b"c".to_vec(),
118            b"ab".to_vec(),
119            b"abc".to_vec(),
120        ])
121        .unwrap()
122    }
123
124    #[test]
125    fn resolves_ranks_and_merged_ids() {
126        let vocab = test_vocab();
127        let merges = vec![
128            (b"a".to_vec(), b"b".to_vec()),
129            (b"ab".to_vec(), b"c".to_vec()),
130        ];
131        let table = MergeTable::build(&merges, |b| vocab.id_of(b)).unwrap();
132        assert_eq!(table.len(), 2);
133        let ab = table.get(0, 1).unwrap(); // ("a","b")
134        assert_eq!(ab.rank, 0);
135        assert_eq!(ab.merged_id, 3); // "ab"
136        let abc = table.get(3, 2).unwrap(); // ("ab","c")
137        assert_eq!(abc.rank, 1);
138        assert_eq!(abc.merged_id, 4); // "abc"
139        assert!(table.get(0, 2).is_none()); // ("a","c") was never taught
140    }
141
142    #[test]
143    fn rejects_merge_producing_a_token_outside_the_vocab() {
144        let vocab = Vocab::from_entries(vec![b"a".to_vec(), b"b".to_vec()]).unwrap();
145        let merges = vec![(b"a".to_vec(), b"b".to_vec())]; // "ab" is not in vocab
146        let err = MergeTable::build(&merges, |b| vocab.id_of(b)).unwrap_err();
147        assert!(matches!(err, Error::MalformedModel { .. }));
148    }
149
150    #[test]
151    fn rejects_duplicate_pair() {
152        let vocab = test_vocab();
153        let merges = vec![
154            (b"a".to_vec(), b"b".to_vec()),
155            (b"a".to_vec(), b"b".to_vec()),
156        ];
157        let err = MergeTable::build(&merges, |b| vocab.id_of(b)).unwrap_err();
158        assert!(matches!(err, Error::MalformedModel { .. }));
159    }
160}