Skip to main content

morpheme/models/unigram/
model.rs

1//! The [`Unigram`] model.
2
3use std::collections::HashMap;
4use std::sync::RwLock;
5
6use rustc_hash::FxHashMap;
7use serde::de::Error as _;
8use serde::ser::{Error as _, SerializeStruct};
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11use super::lattice::Lattice;
12use super::prng::Prng;
13use super::trie::Trie;
14use crate::Token;
15use crate::error::{Error, Result};
16use crate::traits::Model;
17
18/// Score penalty (below the lowest piece score) for unknown chars.
19pub(crate) const UNK_PENALTY: f64 = 10.0;
20
21/// Sentences at least this long (bytes) are not cached.
22const CACHE_MAX_LENGTH: usize = 256;
23/// Maximum number of cached sentences.
24const CACHE_CAPACITY: usize = 10_000;
25/// Number of cache shards (same as the BPE word cache).
26const CACHE_SHARDS: usize = 64;
27
28/// Memoizes encoded sentences. Cloning a model gives it a fresh cache.
29///
30/// Sharded so that parallel batch encoding doesn't serialize on a single
31/// lock: lookups and inserts are non-blocking (`try_read` / `try_write`)
32/// and simply miss when the shard is busy.
33#[derive(Debug)]
34struct SentenceCache {
35    shards: Vec<RwLock<FxHashMap<String, Vec<String>>>>,
36}
37
38impl SentenceCache {
39    fn new() -> Self {
40        Self {
41            shards: (0..CACHE_SHARDS)
42                .map(|_| RwLock::new(FxHashMap::default()))
43                .collect(),
44        }
45    }
46
47    fn shard(&self, key: &str) -> &RwLock<FxHashMap<String, Vec<String>>> {
48        use std::hash::{BuildHasher, BuildHasherDefault};
49        let h = BuildHasherDefault::<rustc_hash::FxHasher>::default().hash_one(key);
50        &self.shards[(h as usize) % self.shards.len()]
51    }
52
53    fn get(&self, key: &str) -> Option<Vec<String>> {
54        self.shard(key).try_read().ok()?.get(key).cloned()
55    }
56
57    fn insert(&self, key: &str, pieces: &[String]) {
58        if key.len() >= CACHE_MAX_LENGTH {
59            return;
60        }
61        let per_shard = CACHE_CAPACITY.div_ceil(self.shards.len());
62        if let Ok(mut map) = self.shard(key).try_write() {
63            if map.len() < per_shard {
64                map.insert(key.to_owned(), pieces.to_vec());
65            }
66        }
67    }
68
69    fn clear(&self) {
70        for shard in &self.shards {
71            if let Ok(mut map) = shard.write() {
72                map.clear();
73            }
74        }
75    }
76}
77
78/// A Unigram language model (SentencePiece): every piece has a score
79/// (log probability) and a sentence is split into the pieces maximizing
80/// the total score (Viterbi).
81///
82/// # Example
83///
84/// ```
85/// use morpheme::models::Unigram;
86/// use morpheme::Model;
87///
88/// let pieces = vec![
89///     ("<unk>".to_string(), 0.0),
90///     ("▁he".to_string(), -2.0),
91///     ("llo".to_string(), -2.0),
92///     ("▁".to_string(), -4.0),
93///     ("h".to_string(), -5.0),
94///     ("e".to_string(), -5.0),
95///     ("l".to_string(), -5.0),
96///     ("o".to_string(), -5.0),
97/// ];
98/// let unigram = Unigram::new(pieces, Some(0), false)?;
99///
100/// // Viterbi picks the most likely segmentation.
101/// let tokens: Vec<String> = unigram.tokenize("▁hello")?.into_iter().map(|t| t.value).collect();
102/// assert_eq!(tokens, ["▁he", "llo"]);
103/// // Unknown chars get the unk id instead of failing.
104/// assert_eq!(unigram.tokenize("▁z")?.last().unwrap().id, 0);
105/// # Ok::<(), morpheme::Error>(())
106/// ```
107pub struct Unigram {
108    vocab: Vec<(String, f64)>,
109    token_to_ids: FxHashMap<String, u32>,
110    trie: Trie,
111    /// Lowest score in the vocabulary (NaN scores ignored).
112    pub(crate) min_score: f64,
113    unk_id: Option<usize>,
114    pub(crate) bos_id: usize,
115    pub(crate) eos_id: usize,
116    fuse_unk: bool,
117    is_optimized: bool,
118    byte_fallback: bool,
119    /// `(alpha, nbest_size)` when subword-regularization sampling is on.
120    sampling: Option<(f64, i32)>,
121    /// Seed for reproducible sampling; `None` draws from entropy.
122    seed: Option<u64>,
123    cache: SentenceCache,
124}
125
126impl Clone for Unigram {
127    /// The clone starts with an empty cache.
128    fn clone(&self) -> Self {
129        Self {
130            vocab: self.vocab.clone(),
131            token_to_ids: self.token_to_ids.clone(),
132            trie: self.trie.clone(),
133            min_score: self.min_score,
134            unk_id: self.unk_id,
135            bos_id: self.bos_id,
136            eos_id: self.eos_id,
137            fuse_unk: self.fuse_unk,
138            is_optimized: self.is_optimized,
139            byte_fallback: self.byte_fallback,
140            sampling: self.sampling,
141            seed: self.seed,
142            cache: SentenceCache::new(),
143        }
144    }
145}
146
147impl PartialEq for Unigram {
148    fn eq(&self, other: &Self) -> bool {
149        self.unk_id == other.unk_id
150            && self.byte_fallback == other.byte_fallback
151            && self.vocab.len() == other.vocab.len()
152            && self
153                .vocab
154                .iter()
155                .zip(&other.vocab)
156                .all(|((a, sa), (b, sb))| a == b && (sa == sb || (sa.is_nan() && sb.is_nan())))
157    }
158}
159
160impl std::fmt::Debug for Unigram {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        f.debug_struct("Unigram")
163            .field("vocab", &self.vocab.len())
164            .field("unk_id", &self.unk_id)
165            .field("byte_fallback", &self.byte_fallback)
166            .finish()
167    }
168}
169
170impl Default for Unigram {
171    fn default() -> Self {
172        Self::new(vec![("<unk>".to_string(), 0.0)], Some(0), false)
173            .expect("the default vocabulary is valid")
174    }
175}
176
177impl Unigram {
178    /// Build a model from `(piece, score)` pairs. The id of a piece is its
179    /// index. `unk_id` must point inside the vocabulary; with
180    /// `byte_fallback`, unknown text is emitted as `<0xNN>` byte pieces
181    /// when those exist in the vocabulary.
182    ///
183    /// Every score must be finite: a `NaN` or infinite score is an
184    /// error naming the piece, because `tokenizer.json` cannot represent
185    /// it (JSON has no `NaN` or infinity, and `serde_json` would write
186    /// `null`, which no loader accepts).
187    pub fn new(
188        vocab: Vec<(String, f64)>,
189        unk_id: Option<usize>,
190        byte_fallback: bool,
191    ) -> Result<Self> {
192        if let Some((id, (piece, score))) =
193            vocab.iter().enumerate().find(|(_, (_, s))| !s.is_finite())
194        {
195            return Err(Error::Config(non_finite_score(piece, id, *score)));
196        }
197        Self::with_any_scores(vocab, unk_id, byte_fallback)
198    }
199
200    /// [`new`](Self::new) without the finite-score check, for the
201    /// trainer's intermediate models (whose training-time unknown piece
202    /// scores `NaN`, as in HF tokenizers). Such a model must never be
203    /// returned to users: serializing it fails.
204    pub(crate) fn with_any_scores(
205        vocab: Vec<(String, f64)>,
206        unk_id: Option<usize>,
207        byte_fallback: bool,
208    ) -> Result<Self> {
209        if let Some(unk_id) = unk_id {
210            if vocab.is_empty() {
211                return Err(Error::Config(
212                    "Unigram: the vocabulary is empty but at least <unk> is needed".into(),
213                ));
214            }
215            if unk_id >= vocab.len() {
216                return Err(Error::Config(format!(
217                    "Unigram: unk_id {unk_id} is not in the vocabulary (size {})",
218                    vocab.len()
219                )));
220            }
221        }
222        let n = vocab.len();
223        let mut token_to_ids = FxHashMap::default();
224        token_to_ids.reserve(n);
225        let mut trie = Trie::new();
226        let mut min_score = f64::INFINITY;
227        for (id, (token, score)) in vocab.iter().enumerate() {
228            let id = u32::try_from(id)
229                .map_err(|_| Error::Config("Unigram: vocabulary is too large".into()))?;
230            token_to_ids.insert(token.clone(), id);
231            trie.insert(token.as_bytes(), id);
232            if *score < min_score {
233                min_score = *score;
234            }
235        }
236        Ok(Self {
237            vocab,
238            token_to_ids,
239            trie,
240            min_score,
241            unk_id,
242            bos_id: n + 1,
243            eos_id: n + 2,
244            fuse_unk: true,
245            is_optimized: true,
246            byte_fallback,
247            sampling: None,
248            seed: None,
249            cache: SentenceCache::new(),
250        })
251    }
252
253    /// Turn subword-regularization sampling on (or off) for `encode` /
254    /// `tokenize`, as SentencePiece's `SampleEncode` and HF's
255    /// `Unigram(alpha, nbest_size)`:
256    ///
257    /// - `nbest_size > 1`: sample one of the `nbest_size` best
258    ///   segmentations with probability proportional to
259    ///   `exp(alpha * score)`;
260    /// - `nbest_size < 0`: sample from the whole lattice with the piece
261    ///   scores scaled by `alpha` (forward-filtering backward-sampling);
262    /// - `nbest_size` of `0` or `1`, or `alpha == 0`: plain Viterbi,
263    ///   identical to not sampling.
264    ///
265    /// `alpha` must be finite and non-negative. Sampled encodings bypass
266    /// the sentence cache. The setting is runtime-only and is not
267    /// written to `tokenizer.json`.
268    pub fn set_sampling(&mut self, alpha: f64, nbest_size: i32) -> Result<()> {
269        if !alpha.is_finite() || alpha < 0.0 {
270            return Err(Error::Config(format!(
271                "Unigram: sampling alpha must be finite and >= 0, got {alpha}"
272            )));
273        }
274        self.sampling = if alpha == 0.0 || (0..=1).contains(&nbest_size) {
275            None
276        } else {
277            Some((alpha, nbest_size))
278        };
279        Ok(())
280    }
281
282    /// Builder form of [`set_sampling`](Self::set_sampling).
283    pub fn with_sampling(mut self, alpha: f64, nbest_size: i32) -> Result<Self> {
284        self.set_sampling(alpha, nbest_size)?;
285        Ok(self)
286    }
287
288    /// The active `(alpha, nbest_size)`, or `None` when encoding is
289    /// deterministic Viterbi.
290    pub fn sampling(&self) -> Option<(f64, i32)> {
291        self.sampling
292    }
293
294    /// Seed the sampler so that every call on a given sentence draws the
295    /// same segmentation (independent of thread or batch order). `None`
296    /// restores entropy-seeded sampling.
297    pub fn set_seed(&mut self, seed: Option<u64>) {
298        self.seed = seed;
299    }
300
301    /// Builder form of [`set_seed`](Self::set_seed).
302    pub fn with_seed(mut self, seed: u64) -> Self {
303        self.seed = Some(seed);
304        self
305    }
306
307    /// The sampling seed, if any.
308    pub fn seed(&self) -> Option<u64> {
309        self.seed
310    }
311
312    /// Whether unknown text falls back to `<0xNN>` byte pieces.
313    pub fn byte_fallback(&self) -> bool {
314        self.byte_fallback
315    }
316
317    /// Id of the unknown piece, if any.
318    pub fn unk_id(&self) -> Option<usize> {
319        self.unk_id
320    }
321
322    /// The `(piece, score)` pairs, in id order.
323    pub fn pieces(&self) -> &[(String, f64)] {
324        &self.vocab
325    }
326
327    /// Number of pieces.
328    pub(crate) fn len(&self) -> usize {
329        self.vocab.len()
330    }
331
332    /// Drop all cached encodings.
333    pub fn clear_cache(&self) {
334        self.cache.clear();
335    }
336
337    #[cfg(test)]
338    pub(crate) fn set_fuse_unk(&mut self, fuse_unk: bool) {
339        self.fuse_unk = fuse_unk;
340        self.clear_cache();
341    }
342
343    #[cfg(test)]
344    pub(crate) fn set_optimized(&mut self, is_optimized: bool) {
345        self.is_optimized = is_optimized;
346        self.clear_cache();
347    }
348
349    /// Fill `lattice` with every vocabulary piece matching at every char
350    /// position, adding an unknown node where no single-char piece
351    /// exists.
352    pub(crate) fn populate_nodes(&self, lattice: &mut Lattice<'_>) {
353        let unk_score = self.min_score - UNK_PENALTY;
354        let sentence = lattice.sentence();
355        let bytes = sentence.as_bytes();
356        let len = sentence.len();
357        let mut pos = 0;
358        let mut found: Vec<(usize, u32)> = Vec::new();
359        while pos < len {
360            let char_len = utf8_len(bytes[pos]);
361            found.clear();
362            self.trie
363                .common_prefix_search(&bytes[pos..], |n, id| found.push((n, id)));
364            let mut has_single = false;
365            for &(n, id) in &found {
366                lattice.insert(pos, n, self.vocab[id as usize].1, id as usize);
367                has_single |= n == char_len;
368            }
369            if !has_single {
370                if let Some(unk) = self.unk_id {
371                    lattice.insert(pos, char_len, unk_score, unk);
372                }
373            }
374            pos += char_len;
375        }
376    }
377
378    /// Split `sentence` into its best pieces. Consecutive unknown chars
379    /// are fused into one piece.
380    pub fn encode(&self, sentence: &str) -> Result<Vec<String>> {
381        if sentence.is_empty() {
382            return Ok(vec![]);
383        }
384        if let Some((alpha, nbest_size)) = self.sampling {
385            // Sampled encodings are not memoized.
386            return self.encode_sampled(sentence, alpha, nbest_size);
387        }
388        if let Some(hit) = self.cache.get(sentence) {
389            return Ok(hit);
390        }
391        let result = if self.is_optimized {
392            self.encode_optimized(sentence)?
393        } else {
394            self.encode_unoptimized(sentence)?
395        };
396        self.cache.insert(sentence, &result);
397        Ok(result)
398    }
399
400    /// Viterbi without building a lattice (SentencePiece's optimized
401    /// encoder).
402    fn encode_optimized(&self, sentence: &str) -> Result<Vec<String>> {
403        #[derive(Clone, Copy)]
404        struct Best {
405            id: usize,
406            score: f64,
407            starts_at: Option<usize>,
408        }
409        let bytes = sentence.as_bytes();
410        let size = bytes.len();
411        let unk_score = self.min_score - UNK_PENALTY;
412        let mut best = vec![
413            Best {
414                id: 0,
415                score: 0.0,
416                starts_at: None,
417            };
418            size + 1
419        ];
420        let mut start = 0;
421        while start < size {
422            let base = best[start].score;
423            let char_len = utf8_len(bytes[start]);
424            let mut has_single = false;
425            let vocab = &self.vocab;
426            self.trie.common_prefix_search(&bytes[start..], |n, id| {
427                let target = &mut best[start + n];
428                let candidate = vocab[id as usize].1 + base;
429                if target.starts_at.is_none() || candidate > target.score {
430                    *target = Best {
431                        id: id as usize,
432                        score: candidate,
433                        starts_at: Some(start),
434                    };
435                }
436                has_single |= n == char_len;
437            });
438            if !has_single {
439                let target = &mut best[start + char_len];
440                let candidate = unk_score + base;
441                if target.starts_at.is_none() || candidate > target.score {
442                    *target = Best {
443                        id: self.unk_id.ok_or_else(missing_unk)?,
444                        score: candidate,
445                        starts_at: Some(start),
446                    };
447                }
448            }
449            start += char_len;
450        }
451
452        let mut results: Vec<String> = Vec::new();
453        let mut unk_run: Vec<&str> = Vec::new();
454        let mut end = size;
455        while end > 0 {
456            let node = best[end];
457            let start = node
458                .starts_at
459                .ok_or_else(|| Error::Model("Unigram: no segmentation found".into()))?;
460            let piece = &sentence[start..end];
461            if self.fuse_unk && Some(node.id) == self.unk_id {
462                unk_run.push(piece);
463            } else {
464                if !unk_run.is_empty() {
465                    unk_run.reverse();
466                    results.push(unk_run.concat());
467                    unk_run.clear();
468                }
469                results.push(piece.to_owned());
470            }
471            end = start;
472        }
473        if !unk_run.is_empty() {
474            unk_run.reverse();
475            results.push(unk_run.concat());
476        }
477        results.reverse();
478        Ok(results)
479    }
480
481    /// Viterbi over an explicit lattice.
482    fn encode_unoptimized(&self, sentence: &str) -> Result<Vec<String>> {
483        let mut lattice = Lattice::new(sentence, self.bos_id, self.eos_id);
484        self.populate_nodes(&mut lattice);
485        let path = lattice.viterbi();
486        self.pieces_for_path(&lattice, &path)
487    }
488
489    /// One sampled segmentation (see [`set_sampling`](Self::set_sampling)).
490    fn encode_sampled(&self, sentence: &str, alpha: f64, nbest_size: i32) -> Result<Vec<String>> {
491        let mut rng = match self.seed {
492            Some(seed) => {
493                let mut h = rustc_hash::FxHasher::default();
494                std::hash::Hasher::write(&mut h, sentence.as_bytes());
495                Prng::seeded(seed, std::hash::Hasher::finish(&h))
496            }
497            None => Prng::from_entropy(),
498        };
499        let mut lattice = Lattice::new(sentence, self.bos_id, self.eos_id);
500        self.populate_nodes(&mut lattice);
501        let path = if nbest_size < 0 {
502            let mut uniform = || rng.next_f64();
503            let sampled = lattice.sample(alpha, &mut uniform);
504            if sampled.is_empty() {
505                // Either the lattice is incomplete (Viterbi is empty too
506                // and reports the missing unk) or `alpha` is so large
507                // that the forward mass underflowed: the distribution
508                // has collapsed onto the best path, so take it.
509                lattice.viterbi()
510            } else {
511                sampled
512            }
513        } else {
514            let mut paths = lattice.nbest(nbest_size as usize);
515            if paths.is_empty() {
516                return Err(missing_unk());
517            }
518            let scores: Vec<f64> = paths
519                .iter()
520                .map(|p| p.iter().map(|&i| lattice.node(i).score).sum::<f64>())
521                .collect();
522            let weights = nbest_weights(&scores, alpha);
523            paths.swap_remove(rng.choose_weighted(&weights))
524        };
525        self.pieces_for_path(&lattice, &path)
526    }
527
528    /// The pieces along `path`, fusing consecutive unknown chars.
529    fn pieces_for_path(&self, lattice: &Lattice<'_>, path: &[usize]) -> Result<Vec<String>> {
530        if path.is_empty() {
531            return Err(missing_unk());
532        }
533        let mut results = Vec::new();
534        let mut unk_run = String::new();
535        for &i in path {
536            let node = lattice.node(i);
537            let piece = lattice.piece(node);
538            if self.fuse_unk && Some(node.id) == self.unk_id {
539                unk_run.push_str(piece);
540            } else {
541                if !unk_run.is_empty() {
542                    results.push(std::mem::take(&mut unk_run));
543                }
544                results.push(piece.to_owned());
545            }
546        }
547        if !unk_run.is_empty() {
548            results.push(unk_run);
549        }
550        Ok(results)
551    }
552}
553
554/// Softmax weights `exp(alpha * (score - max))` for n-best path
555/// `scores`, computed so that no finite `alpha >= 0` can produce `NaN`:
556/// the best path always gets weight 1, the gap is scaled after the max
557/// is subtracted, and the exponent is clamped to a representable range.
558fn nbest_weights(scores: &[f64], alpha: f64) -> Vec<f64> {
559    // Exponents below this underflow `exp` to 0 anyway.
560    const MIN_EXPONENT: f64 = -750.0;
561    let max = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max);
562    scores
563        .iter()
564        .map(|&s| {
565            let gap = s - max;
566            if gap >= 0.0 || !gap.is_finite() {
567                // The best path (or a tie), or a `-inf` score: an
568                // argmax entry gets 1, an unreachable one 0.
569                return if gap >= 0.0 { 1.0 } else { 0.0 };
570            }
571            let exponent = alpha * gap;
572            if exponent.is_nan() {
573                return 0.0;
574            }
575            exponent.max(MIN_EXPONENT).exp()
576        })
577        .collect()
578}
579
580fn non_finite_score(piece: &str, id: usize, score: f64) -> String {
581    format!(
582        "Unigram: piece {piece:?} (id {id}) has a non-finite score ({score}); \
583         scores must be finite to be saved in tokenizer.json"
584    )
585}
586
587fn missing_unk() -> Error {
588    Error::Model("Unigram: encountered an unknown token but `unk_id` is missing".into())
589}
590
591/// Byte length of the UTF-8 char starting with `first`.
592#[inline]
593fn utf8_len(first: u8) -> usize {
594    match first {
595        0x00..=0x7F => 1,
596        0xC0..=0xDF => 2,
597        0xE0..=0xEF => 3,
598        _ => 4,
599    }
600}
601
602impl Model for Unigram {
603    fn tokenize(&self, sequence: &str) -> Result<Vec<Token>> {
604        let pieces = self.encode(sequence)?;
605        let mut offset = 0;
606        let mut tokens = Vec::with_capacity(pieces.len());
607        for piece in pieces {
608            let len = piece.len();
609            let offsets = (offset, offset + len);
610            offset += len;
611            if let Some(&id) = self.token_to_ids.get(&piece) {
612                tokens.push(Token::new(id, piece, offsets));
613                continue;
614            }
615            if self.byte_fallback {
616                let bytes: Option<Vec<Token>> = piece
617                    .bytes()
618                    .map(|b| {
619                        let s = format!("<0x{b:02X}>");
620                        self.token_to_ids
621                            .get(&s)
622                            .map(|&id| Token::new(id, s, offsets))
623                    })
624                    .collect();
625                if let Some(bytes) = bytes {
626                    tokens.extend(bytes);
627                    continue;
628                }
629            }
630            let unk = self.unk_id.ok_or_else(missing_unk)? as u32;
631            tokens.push(Token::new(unk, piece, offsets));
632        }
633        Ok(tokens)
634    }
635
636    fn token_to_id(&self, token: &str) -> Option<u32> {
637        self.token_to_ids.get(token).copied()
638    }
639
640    fn id_to_token(&self, id: u32) -> Option<String> {
641        self.vocab.get(id as usize).map(|(t, _)| t.clone())
642    }
643
644    fn vocab(&self) -> HashMap<String, u32> {
645        self.token_to_ids
646            .iter()
647            .map(|(k, v)| (k.clone(), *v))
648            .collect()
649    }
650
651    fn vocab_size(&self) -> usize {
652        self.vocab.len()
653    }
654}
655
656impl Serialize for Unigram {
657    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
658        // JSON has no `NaN` or infinity: `serde_json` would write `null`
659        // and produce a file that cannot be loaded back, so refuse.
660        if let Some((id, (piece, score))) = self
661            .vocab
662            .iter()
663            .enumerate()
664            .find(|(_, (_, s))| !s.is_finite())
665        {
666            return Err(S::Error::custom(non_finite_score(piece, id, *score)));
667        }
668        let mut s = serializer.serialize_struct("Unigram", 4)?;
669        s.serialize_field("type", "Unigram")?;
670        s.serialize_field("unk_id", &self.unk_id)?;
671        s.serialize_field("vocab", &self.vocab)?;
672        s.serialize_field("byte_fallback", &self.byte_fallback)?;
673        s.end()
674    }
675}
676
677impl<'de> Deserialize<'de> for Unigram {
678    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
679        #[derive(Deserialize)]
680        struct Repr {
681            #[serde(rename = "type", default)]
682            ty: Option<String>,
683            vocab: Vec<(String, f64)>,
684            #[serde(default)]
685            unk_id: Option<usize>,
686            #[serde(default)]
687            byte_fallback: bool,
688        }
689        let r = Repr::deserialize(deserializer)?;
690        if let Some(ty) = &r.ty {
691            if ty != "Unigram" {
692                return Err(D::Error::custom(format!(
693                    "expected model type \"Unigram\", got {ty:?}"
694                )));
695            }
696        }
697        Unigram::new(r.vocab, r.unk_id, r.byte_fallback).map_err(D::Error::custom)
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    fn pieces(v: &[(&str, f64)]) -> Vec<(String, f64)> {
706        v.iter().map(|(s, f)| (s.to_string(), *f)).collect()
707    }
708
709    #[test]
710    fn populate_nodes_unk() {
711        let model = Unigram::new(pieces(&[("<unk>", 0.0)]), Some(0), false).unwrap();
712        let mut lattice = Lattice::new("abc", model.bos_id, model.eos_id);
713        model.populate_nodes(&mut lattice);
714        for pos in 0..3 {
715            assert_eq!(lattice.begin_nodes[pos].len(), 1);
716            let node_id = lattice.begin_nodes[pos][0];
717            assert_eq!(lattice.node(node_id).id, 0);
718            assert_eq!(node_id, pos + 2);
719        }
720    }
721
722    #[test]
723    fn populate_nodes_pieces() {
724        let model = Unigram::new(
725            pieces(&[
726                ("<unk>", 0.0),
727                ("a", 0.1),
728                ("b", 0.2),
729                ("ab", 0.3),
730                ("bc", 0.4),
731            ]),
732            Some(0),
733            false,
734        )
735        .unwrap();
736        let mut lattice = Lattice::new("abc", model.bos_id, model.eos_id);
737        model.populate_nodes(&mut lattice);
738        let ids = |pos: usize| -> Vec<usize> {
739            lattice.begin_nodes[pos]
740                .iter()
741                .map(|&i| lattice.node(i).id)
742                .collect()
743        };
744        assert_eq!(ids(0), vec![1, 3]);
745        assert_eq!(ids(1), vec![2, 4]);
746        assert_eq!(ids(2), vec![0]);
747    }
748
749    #[test]
750    fn nbest_weights_are_finite_for_extreme_alpha() {
751        let scores = [-1000.0, -1000.5, -1200.0];
752        // Overflow regime: `alpha * score` alone would be `-inf`.
753        let w = nbest_weights(&scores, 1e308);
754        assert_eq!(w, [1.0, 0.0, 0.0]);
755        let w = nbest_weights(&scores, f64::MAX);
756        assert_eq!(w, [1.0, 0.0, 0.0]);
757        // Ordinary regime: softmax over the scaled gaps.
758        let w = nbest_weights(&scores, 2.0);
759        assert_eq!(w[0], 1.0);
760        assert!((w[1] - (-1.0f64).exp()).abs() < 1e-12);
761        assert!((w[2] - (-400.0f64).exp()).abs() < 1e-200);
762        // Ties and alpha == 0 give a uniform distribution.
763        assert_eq!(nbest_weights(&[-5.0, -5.0], 1e308), [1.0, 1.0]);
764        assert_eq!(nbest_weights(&scores, 0.0), [1.0, 1.0, 1.0]);
765        // A `-inf` path score never wins and never produces `NaN`.
766        assert_eq!(nbest_weights(&[-1.0, f64::NEG_INFINITY], 1e308), [1.0, 0.0]);
767        assert!(nbest_weights(&scores, 1e308).iter().all(|w| w.is_finite()));
768    }
769
770    /// A model with no `unk_id` and only multi-char pieces leaves char
771    /// positions with no single-char piece. Like SentencePiece and HF,
772    /// the optimized encoder rejects that (it needs an unk node at every
773    /// such position), and the unoptimized and sampling encoders must
774    /// agree with it rather than accept or reject different inputs.
775    #[test]
776    fn sampling_agrees_with_viterbi_on_models_without_single_char_pieces() {
777        let vocab = pieces(&[("ab", -1.0), ("abab", -1.5), ("c", -2.0)]);
778        let optimized = Unigram::new(vocab.clone(), None, false).unwrap();
779        let mut unoptimized = optimized.clone();
780        unoptimized.set_optimized(false);
781        for input in ["ab", "abab", "cabc", "c"] {
782            let expected = optimized.encode(input).map_err(|e| e.to_string());
783            assert_eq!(
784                unoptimized.encode(input).map_err(|e| e.to_string()),
785                expected,
786                "{input}"
787            );
788            for nbest in [-1, 4] {
789                let sampler = Unigram::new(vocab.clone(), None, false)
790                    .unwrap()
791                    .with_sampling(0.5, nbest)
792                    .unwrap();
793                assert_eq!(
794                    sampler.encode(input).map_err(|e| e.to_string()),
795                    expected,
796                    "nbest_size={nbest} on {input}"
797                );
798            }
799        }
800        // Once single-char pieces exist, every encoder accepts the
801        // inputs and sharp sampling reproduces Viterbi.
802        let vocab = pieces(&[("ab", -1.0), ("abab", -1.5), ("a", -3.0), ("b", -3.0)]);
803        let viterbi = Unigram::new(vocab.clone(), None, false).unwrap();
804        for input in ["ab", "abab", "ababa", "ba"] {
805            let expected = viterbi.encode(input).unwrap();
806            for nbest in [-1, 4] {
807                let sampler = Unigram::new(vocab.clone(), None, false)
808                    .unwrap()
809                    .with_sampling(1e6, nbest)
810                    .unwrap();
811                for _ in 0..10 {
812                    assert_eq!(
813                        sampler.encode(input).unwrap(),
814                        expected,
815                        "nbest_size={nbest} on {input}"
816                    );
817                }
818            }
819        }
820    }
821
822    #[test]
823    fn sampling_incomplete_lattice_reports_missing_unk() {
824        let vocab = pieces(&[("ab", -1.0)]);
825        for nbest in [-1, 4] {
826            let sampler = Unigram::new(vocab.clone(), None, false)
827                .unwrap()
828                .with_sampling(0.5, nbest)
829                .unwrap();
830            let err = sampler.encode("abc").unwrap_err();
831            assert!(err.to_string().contains("unk_id"), "{err}");
832            let err = sampler.encode("ba").unwrap_err();
833            assert!(err.to_string().contains("unk_id"), "{err}");
834        }
835    }
836
837    /// Extreme `alpha` with large-magnitude scores overflows
838    /// `alpha * score`; sampling must still return the Viterbi path.
839    #[test]
840    fn sampling_extreme_alpha_returns_viterbi() {
841        let vocab = pieces(&[
842            ("<unk>", -1500.0),
843            ("a", -1000.0),
844            ("b", -1000.0),
845            ("ab", -1000.5),
846            ("abc", -2100.0),
847            ("c", -1000.0),
848        ]);
849        let viterbi = Unigram::new(vocab.clone(), Some(0), false).unwrap();
850        let expected = viterbi.encode("abc").unwrap();
851        assert_eq!(expected, ["ab", "c"]);
852        for nbest in [-1, 8] {
853            let sampler = Unigram::new(vocab.clone(), Some(0), false)
854                .unwrap()
855                .with_sampling(1e308, nbest)
856                .unwrap();
857            for _ in 0..20 {
858                assert_eq!(sampler.encode("abc").unwrap(), expected, "nbest={nbest}");
859            }
860        }
861    }
862
863    #[test]
864    fn encode_simple() {
865        let model = Unigram::new(
866            pieces(&[
867                ("<unk>", 0.0),
868                ("a", 0.0),
869                ("b", 0.0),
870                ("c", 0.0),
871                ("d", 0.0),
872                ("cd", 1.0),
873                ("ab", 2.0),
874                ("abc", 5.0),
875                ("abcd", 10.0),
876            ]),
877            Some(0),
878            false,
879        )
880        .unwrap();
881        assert_eq!(model.encode("abcd").unwrap(), vec!["abcd"]);
882        assert_eq!(
883            model.encode("abcdacdxx").unwrap(),
884            vec!["abcd", "a", "cd", "xx"]
885        );
886    }
887
888    #[test]
889    fn encode_optimized_and_unoptimized_agree() {
890        let mut model = Unigram::new(
891            pieces(&[
892                ("<unk>", 0.0),
893                ("ab", 0.0),
894                ("cd", -0.1),
895                ("abc", -0.2),
896                ("a", -0.3),
897                ("b", -0.4),
898                ("c", -0.5),
899                ("ABC", -0.5),
900                ("abcdabcd", 20.0),
901                ("q", 20.5),
902                ("r", 20.5),
903                ("qr", -0.5),
904            ]),
905            Some(0),
906            false,
907        )
908        .unwrap();
909        for optimized in [true, false] {
910            model.set_optimized(optimized);
911            assert_eq!(model.encode("abc").unwrap(), vec!["abc"]);
912            assert_eq!(model.encode("AB").unwrap(), vec!["AB"]);
913            model.set_fuse_unk(false);
914            assert_eq!(model.encode("AB").unwrap(), vec!["A", "B"]);
915            model.set_fuse_unk(true);
916            assert_eq!(model.encode("AB").unwrap(), vec!["AB"]);
917            assert_eq!(model.encode("abcd").unwrap(), vec!["ab", "cd"]);
918            assert_eq!(model.encode("abcc").unwrap(), vec!["abc", "c"]);
919            assert_eq!(
920                model.encode("xabcabaabcdd").unwrap(),
921                vec!["x", "abc", "ab", "a", "ab", "cd", "d"]
922            );
923            model.set_fuse_unk(false);
924            assert_eq!(
925                model.encode("xyz東京").unwrap(),
926                vec!["x", "y", "z", "東", "京"]
927            );
928            model.set_fuse_unk(true);
929            assert_eq!(model.encode("xyz東京").unwrap(), vec!["xyz東京"]);
930            assert_eq!(model.encode("ABC").unwrap(), vec!["ABC"]);
931            assert_eq!(model.encode("abABCcd").unwrap(), vec!["ab", "ABC", "cd"]);
932            assert_eq!(
933                model.encode("ababcdabcdcd").unwrap(),
934                vec!["ab", "abcdabcd", "cd"]
935            );
936            assert_eq!(model.encode("abqrcd").unwrap(), vec!["ab", "q", "r", "cd"]);
937        }
938    }
939
940    #[test]
941    fn byte_fallback() {
942        let model = Unigram::new(
943            pieces(&[("<unk>", 0.0), ("<0xC3>", -0.01), ("<0xA9>", -0.03)]),
944            Some(0),
945            true,
946        )
947        .unwrap();
948        let tokens = model.tokenize("é").unwrap();
949        assert_eq!(
950            tokens,
951            vec![
952                Token::new(1, "<0xC3>".into(), (0, 2)),
953                Token::new(2, "<0xA9>".into(), (0, 2)),
954            ]
955        );
956        // '?' has no byte piece: the whole fused unknown run maps to unk.
957        let tokens = model.tokenize("?é").unwrap();
958        assert_eq!(tokens[0].id, 0);
959    }
960
961    #[test]
962    fn no_unk_id_errors_only_when_needed() {
963        let model = Unigram::new(pieces(&[("a", -0.5)]), None, false).unwrap();
964        assert_eq!(model.encode("aa").unwrap(), vec!["a", "a"]);
965        assert!(model.encode("ab").is_err());
966    }
967
968    #[test]
969    fn validation() {
970        assert!(Unigram::new(vec![], Some(0), false).is_err());
971        assert!(Unigram::new(pieces(&[("a", 0.0)]), Some(1), false).is_err());
972        assert!(Unigram::new(vec![], None, false).is_ok());
973    }
974
975    #[test]
976    fn non_finite_scores_are_rejected() {
977        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
978            let err = Unigram::new(pieces(&[("<unk>", 0.0), ("y", bad)]), Some(0), false)
979                .unwrap_err()
980                .to_string();
981            assert!(err.contains("\"y\" (id 1)"), "{err}");
982        }
983    }
984
985    /// A model built without the finite-score check (only the trainer
986    /// does that, internally) refuses to serialize instead of writing
987    /// `null`.
988    #[test]
989    fn serialization_refuses_non_finite_scores() {
990        let model = Unigram::with_any_scores(
991            pieces(&[("<unk>", 0.0), ("x", f64::INFINITY)]),
992            Some(0),
993            false,
994        )
995        .unwrap();
996        let err = serde_json::to_string(&model).unwrap_err().to_string();
997        assert!(err.contains("\"x\" (id 1)"), "{err}");
998
999        let tok = crate::Tokenizer::new(model);
1000        assert!(tok.to_json(false).is_err());
1001        assert!(tok.to_json(true).is_err());
1002    }
1003
1004    /// `save` fails before the atomic replace, so an existing
1005    /// destination is left byte-identical.
1006    #[cfg(not(target_arch = "wasm32"))]
1007    #[test]
1008    fn save_of_non_finite_model_leaves_existing_file_intact() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let path = dir.path().join("tokenizer.json");
1011        let good = crate::Tokenizer::new(Unigram::default());
1012        good.save(&path, true).unwrap();
1013        let before = std::fs::read(&path).unwrap();
1014
1015        let bad =
1016            Unigram::with_any_scores(pieces(&[("<unk>", 0.0), ("y", f64::NAN)]), Some(0), false)
1017                .unwrap();
1018        let bad = crate::Tokenizer::new(bad);
1019        assert!(bad.save(&path, true).is_err());
1020        assert!(bad.save(&path, false).is_err());
1021        assert_eq!(std::fs::read(&path).unwrap(), before);
1022        // No temporary file was left behind either.
1023        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1024    }
1025
1026    #[test]
1027    fn serialization_round_trip() {
1028        for (v, unk) in [
1029            (pieces(&[("<unk>", 0.0), ("a", -0.5)]), Some(0)),
1030            (pieces(&[("a", -0.5), ("<unk>", 0.0)]), Some(1)),
1031            (pieces(&[("a", -0.5)]), None),
1032        ] {
1033            let model = Unigram::new(v, unk, false).unwrap();
1034            let json = serde_json::to_string(&model).unwrap();
1035            let back: Unigram = serde_json::from_str(&json).unwrap();
1036            assert_eq!(model, back);
1037        }
1038        let model = Unigram::new(pieces(&[("<unk>", 0.0), ("a", -0.5)]), Some(0), false).unwrap();
1039        assert_eq!(
1040            serde_json::to_string(&model).unwrap(),
1041            r#"{"type":"Unigram","unk_id":0,"vocab":[["<unk>",0.0],["a",-0.5]],"byte_fallback":false}"#
1042        );
1043    }
1044
1045    #[test]
1046    fn deserialize_legacy_and_reject_wrong_type() {
1047        let m: Unigram = serde_json::from_str(r#"{"unk_id":0,"vocab":[["<unk>",0.0]]}"#).unwrap();
1048        assert_eq!(m.vocab_size(), 1);
1049        assert!(serde_json::from_str::<Unigram>(r#"{"type":"BPE","vocab":[["a",0.0]]}"#).is_err());
1050        assert!(serde_json::from_str::<Unigram>(r#"{"unk_id":3,"vocab":[["a",0.0]]}"#).is_err());
1051    }
1052
1053    /// Parity with real Hugging Face tokenizer files (downloaded by
1054    /// `scripts/fetch-hf-fixtures.sh`; skipped when absent).
1055    mod fixtures {
1056        use super::*;
1057
1058        type Expected<'a> = &'a [(&'a str, &'a [(u32, &'a str, (usize, usize))])];
1059
1060        fn load(name: &str) -> Option<(Unigram, serde_json::Value)> {
1061            let path = format!("{}/tests/data/hf/{name}.json", env!("CARGO_MANIFEST_DIR"));
1062            let Ok(raw) = std::fs::read_to_string(&path) else {
1063                eprintln!("skipping: {path} not found (run scripts/fetch-hf-fixtures.sh)");
1064                return None;
1065            };
1066            let file: serde_json::Value = serde_json::from_str(&raw).unwrap();
1067            let model_json = file["model"].clone();
1068            let model: Unigram = serde_json::from_value(model_json.clone()).unwrap();
1069            Some((model, model_json))
1070        }
1071
1072        fn check(name: &str, expected: Expected<'_>) {
1073            let Some((model, mut json)) = load(name) else {
1074                return;
1075            };
1076            // Re-serialization: older files omit "type"/"byte_fallback".
1077            let obj = json.as_object_mut().unwrap();
1078            obj.entry("type").or_insert("Unigram".into());
1079            obj.entry("byte_fallback").or_insert(false.into());
1080            assert_eq!(
1081                serde_json::to_value(&model).unwrap(),
1082                json,
1083                "{name}: re-serialization"
1084            );
1085
1086            for (input, tokens) in expected {
1087                let got: Vec<(u32, String, (usize, usize))> = model
1088                    .tokenize(input)
1089                    .unwrap()
1090                    .into_iter()
1091                    .map(|t| (t.id, t.value, t.offsets))
1092                    .collect();
1093                let want: Vec<(u32, String, (usize, usize))> = tokens
1094                    .iter()
1095                    .map(|(id, v, o)| (*id, v.to_string(), *o))
1096                    .collect();
1097                assert_eq!(got, want, "{name}: {input:?}");
1098            }
1099        }
1100
1101        // Expectations from Python `tokenizers` 0.23.2: `tok.model.tokenize(s)`.
1102        #[test]
1103        fn t5_small() {
1104            check(
1105                "t5-small",
1106                &[
1107                    ("▁Hello", &[(8774, "▁Hello", (0, 8))]),
1108                    (
1109                        "▁world▁test",
1110                        &[(296, "▁world", (0, 8)), (794, "▁test", (8, 15))],
1111                    ),
1112                    (
1113                        "▁𝔘nicode",
1114                        &[
1115                            (3, "▁", (0, 3)),
1116                            (2, "𝔘", (3, 7)),
1117                            (29, "n", (7, 8)),
1118                            (23, "i", (8, 9)),
1119                            (4978, "code", (9, 13)),
1120                        ],
1121                    ),
1122                    (
1123                        "▁日本語のテキスト",
1124                        &[(3, "▁", (0, 3)), (2, "日本語のテキスト", (3, 27))],
1125                    ),
1126                    (
1127                        "▁Ünïcödé▁ťéxt",
1128                        &[
1129                            (3, "▁", (0, 3)),
1130                            (14858, "Ü", (3, 5)),
1131                            (29, "n", (5, 6)),
1132                            (2, "ï", (6, 8)),
1133                            (75, "c", (8, 9)),
1134                            (1872, "ö", (9, 11)),
1135                            (3764, "dé", (11, 14)),
1136                            (3, "▁", (14, 17)),
1137                            (2, "ť", (17, 19)),
1138                            (154, "é", (19, 21)),
1139                            (226, "x", (21, 22)),
1140                            (17, "t", (22, 23)),
1141                        ],
1142                    ),
1143                    (
1144                        "▁supercalifragilistic",
1145                        &[
1146                            (1355, "▁super", (0, 8)),
1147                            (15534, "cali", (8, 12)),
1148                            (20791, "frag", (12, 16)),
1149                            (173, "il", (16, 18)),
1150                            (3040, "istic", (18, 23)),
1151                        ],
1152                    ),
1153                    ("▁a", &[(3, "▁", (0, 3)), (9, "a", (3, 4))]),
1154                    ("▁😀👍", &[(3, "▁", (0, 3)), (2, "😀👍", (3, 11))]),
1155                ],
1156            );
1157        }
1158
1159        #[test]
1160        fn xlm_roberta_base() {
1161            check(
1162                "xlm-roberta-base",
1163                &[
1164                    ("▁Hello", &[(35378, "▁Hello", (0, 8))]),
1165                    (
1166                        "▁world▁test",
1167                        &[(8999, "▁world", (0, 8)), (3034, "▁test", (8, 15))],
1168                    ),
1169                    (
1170                        "▁𝔘nicode",
1171                        &[
1172                            (6, "▁", (0, 3)),
1173                            (3, "𝔘", (3, 7)),
1174                            (12150, "nico", (7, 11)),
1175                            (112, "de", (11, 13)),
1176                        ],
1177                    ),
1178                    (
1179                        "▁日本語のテキスト",
1180                        &[
1181                            (6, "▁", (0, 3)),
1182                            (98449, "日本語", (3, 12)),
1183                            (154, "の", (12, 15)),
1184                            (193508, "テキスト", (15, 27)),
1185                        ],
1186                    ),
1187                    (
1188                        "▁Ünïcödé▁ťéxt",
1189                        &[
1190                            (8578, "▁Ü", (0, 5)),
1191                            (19, "n", (5, 6)),
1192                            (9392, "ï", (6, 8)),
1193                            (238, "c", (8, 9)),
1194                            (21166, "öd", (9, 12)),
1195                            (446, "é", (12, 14)),
1196                            (6, "▁", (14, 17)),
1197                            (1981, "ť", (17, 19)),
1198                            (446, "é", (19, 21)),
1199                            (29062, "xt", (21, 23)),
1200                        ],
1201                    ),
1202                    (
1203                        "▁supercalifragilistic",
1204                        &[
1205                            (1601, "▁super", (0, 8)),
1206                            (52777, "cali", (8, 12)),
1207                            (6000, "fra", (12, 15)),
1208                            (8726, "gil", (15, 18)),
1209                            (48242, "istic", (18, 23)),
1210                        ],
1211                    ),
1212                    ("▁a", &[(10, "▁a", (0, 4))]),
1213                    ("▁😀👍", &[(21119, "▁😀", (0, 7)), (118280, "👍", (7, 11))]),
1214                ],
1215            );
1216        }
1217
1218        #[test]
1219        fn albert_base_v2() {
1220            check(
1221                "albert-base-v2",
1222                &[
1223                    (
1224                        "▁Hello",
1225                        &[(13, "▁", (0, 3)), (1, "H", (3, 4)), (7523, "ello", (4, 8))],
1226                    ),
1227                    (
1228                        "▁world▁test",
1229                        &[(126, "▁world", (0, 8)), (1289, "▁test", (8, 15))],
1230                    ),
1231                    (
1232                        "▁𝔘nicode",
1233                        &[
1234                            (13, "▁", (0, 3)),
1235                            (1, "𝔘", (3, 7)),
1236                            (889, "ni", (7, 9)),
1237                            (9375, "code", (9, 13)),
1238                        ],
1239                    ),
1240                    (
1241                        "▁日本語のテキスト",
1242                        &[(13, "▁", (0, 3)), (1, "日本語のテキスト", (3, 27))],
1243                    ),
1244                    (
1245                        "▁Ünïcödé▁ťéxt",
1246                        &[
1247                            (13, "▁", (0, 3)),
1248                            (1, "Ü", (3, 5)),
1249                            (103, "n", (5, 6)),
1250                            (1, "ï", (6, 8)),
1251                            (150, "c", (8, 9)),
1252                            (1, "ö", (9, 11)),
1253                            (43, "d", (11, 12)),
1254                            (1, "é", (12, 14)),
1255                            (13, "▁", (14, 17)),
1256                            (1, "ťé", (17, 21)),
1257                            (396, "x", (21, 22)),
1258                            (38, "t", (22, 23)),
1259                        ],
1260                    ),
1261                    (
1262                        "▁supercalifragilistic",
1263                        &[
1264                            (1026, "▁super", (0, 8)),
1265                            (3430, "cal", (8, 11)),
1266                            (49, "i", (11, 12)),
1267                            (22133, "frag", (12, 16)),
1268                            (947, "il", (16, 18)),
1269                            (3771, "istic", (18, 23)),
1270                        ],
1271                    ),
1272                    ("▁a", &[(21, "▁a", (0, 4))]),
1273                    ("▁😀👍", &[(13, "▁", (0, 3)), (1, "😀👍", (3, 11))]),
1274                ],
1275            );
1276        }
1277
1278        #[test]
1279        fn large_vocab_encoding_is_fast() {
1280            let Some((model, _)) = load("xlm-roberta-base") else {
1281                return;
1282            };
1283            let words: Vec<String> = (0..20_000).map(|i| format!("▁word{i}xyz")).collect();
1284            let start = std::time::Instant::now();
1285            for w in &words {
1286                model.tokenize(w).unwrap();
1287            }
1288            let elapsed = start.elapsed();
1289            // ~250k-piece vocab: the trie is built once, so this is
1290            // milliseconds; rebuilding it per call would take minutes.
1291            assert!(elapsed.as_secs() < 5, "encoding took {elapsed:?}");
1292        }
1293    }
1294}