Skip to main content

ferritin_plms/t5/
tokenizer.rs

1//! The T5 family's tokenizer, as a built-in table.
2//!
3//! Shared by ProtT5, Ankh and ProstT5. The first two arrive in different
4//! containers — ProtT5 in
5//! a SentencePiece `spiece.model`, Ankh in a `tokenizer.json` Unigram model —
6//! but the alphabet underneath is the *same letters at the same ids*, so one
7//! table serves both. Ankh's pieces carry no word-boundary marker, which is
8//! invisible here because this maps characters directly.
9//!
10//! ProstT5 extends the same vocabulary rather than replacing it: Foldseek's
11//! 3Di structural alphabet occupies ids 128..=147 as *lowercase* letters, each
12//! exactly [`THREE_DI_OFFSET`] above its uppercase residue. Case is therefore
13//! **meaning, not formatting** — `M` is methionine and `m` is a structural
14//! state — which is why [`vocab_id`] never folds it.
15//!
16//! That equivalence is verified rather than assumed: both parity fixtures
17//! carry HuggingFace's own token ids (see `scripts/generate_t5_fixtures.py`),
18//! and the Rust tests compare against them before comparing embeddings, so a
19//! divergence fails on the ids rather than showing up as mysteriously wrong
20//! numbers.
21//!
22//! # Why this is hand-written rather than loaded
23//!
24//! `Rostlab/prot_t5_xl_half_uniref50-enc` ships no `tokenizer.json`. It ships
25//! `spiece.model`, a SentencePiece protobuf — which would mean either a
26//! SentencePiece dependency or a conversion step, for a vocabulary that turns
27//! out to contain **28 pieces**:
28//!
29//! ```text
30//!  0 <pad>   1 </s>    2 <unk>
31//!  3 ▁A   4 ▁L   5 ▁G   6 ▁V   7 ▁S   8 ▁R   9 ▁E  10 ▁D  11 ▁T  12 ▁I
32//! 13 ▁P  14 ▁K  15 ▁F  16 ▁Q  17 ▁N  18 ▁Y  19 ▁M  20 ▁H  21 ▁W  22 ▁C
33//! 23 ▁X  24 ▁B  25 ▁O  26 ▁U  27 ▁Z
34//! ```
35//!
36//! (Read directly out of the published `spiece.model`, not inferred.) Every
37//! amino-acid piece carries SentencePiece's `▁` word-boundary marker, and
38//! nothing else in the file is reachable from a protein sequence, so the whole
39//! tokenizer is a 28-entry character lookup.
40//!
41//! `config.json` says `vocab_size: 128` and `shared.weight` really is
42//! `[128, 1024]`: ids 28..=127 are T5's 100 `<extra_id_*>` sentinels, which a
43//! protein sequence never produces. The embedding table is larger than the
44//! reachable alphabet, which is normal for a T5 checkpoint and not a sign that
45//! this table is missing something.
46//!
47//! # Whitespace
48//!
49//! Because every piece is `▁<letter>`, the reference pipeline has to space the
50//! residues out — HuggingFace's `T5Tokenizer` would read `"MKT"` as one word
51//! and emit `<unk>`. Rostlab's own model card does exactly that:
52//!
53//! ```python
54//! sequence = " ".join(list(re.sub(r"[UZOB]", "X", sequence)))
55//! ```
56//!
57//! Mapping characters directly makes the spacing irrelevant, so this tokenizer
58//! **skips whitespace** and `"MKT"` and `"M K T"` produce identical ids. That
59//! removes the failure the bead was most worried about: getting the convention
60//! wrong shifts every embedding by one residue and produces plausible,
61//! meaningless numbers rather than an error.
62//!
63//! The `[UZOB] -> X` substitution in that snippet is *not* applied here. Those
64//! four residues have their own ids (24..=27) and the model has embeddings for
65//! them; collapsing them is a convention of the model card, not of the
66//! tokenizer, and doing it silently would change a caller's input behind their
67//! back. Callers reproducing published ProtT5 embeddings should apply it
68//! themselves — see [`replace_rare_residues`].
69
70/// `<pad>` — id 0. Also T5's `decoder_start_token_id`.
71pub const PAD_ID: u32 = 0;
72/// `</s>` — id 1. Appended after the last residue; ProtT5 has no BOS.
73pub const EOS_ID: u32 = 1;
74/// `<unk>` — id 2. Any character outside the table below.
75pub const UNK_ID: u32 = 2;
76
77/// The reachable alphabet, in SentencePiece id order (id = index + 3).
78///
79/// Ordered by frequency in UniRef, which is how SentencePiece emitted it — so
80/// this is *not* alphabetical and must not be "tidied".
81const RESIDUES: [char; 25] = [
82    'A', 'L', 'G', 'V', 'S', 'R', 'E', 'D', 'T', 'I', 'P', 'K', 'F', 'Q', 'N', 'Y', 'M', 'H', 'W',
83    'C', 'X', 'B', 'O', 'U', 'Z',
84];
85
86/// Id of the first residue piece; everything below it is a special token.
87const FIRST_RESIDUE_ID: u32 = 3;
88
89/// Full vocabulary size declared by `config.json` and matched by
90/// `shared.weight`. Larger than the reachable alphabet; see the module docs.
91pub const VOCAB_SIZE: usize = 128;
92
93/// The token id for one residue character, or [`UNK_ID`] if it has none.
94///
95/// Case-sensitive: `tokenizer_config.json` sets `do_lower_case: false`, and
96/// lowercase input in the reference pipeline would reach `<unk>` too.
97pub fn residue_id(residue: char) -> u32 {
98    match RESIDUES.iter().position(|&c| c == residue) {
99        Some(i) => FIRST_RESIDUE_ID + i as u32,
100        None => UNK_ID,
101    }
102}
103
104/// How many residues `sequence` encodes, ignoring whitespace.
105///
106/// [`PlmRunner::residue_count`][crate::plm_runner::PlmRunner::residue_count]
107/// delegates here so `embed_residues` returns one row per residue whether the
108/// caller spaced the sequence out or not.
109pub fn residue_count(sequence: &str) -> usize {
110    sequence.chars().filter(|c| !c.is_whitespace()).count()
111}
112
113/// Token ids for `sequence`, with the trailing `</s>` and no BOS.
114///
115/// Whitespace is skipped, so `"MKT"` and `"M K T"` are the same input.
116pub fn encode(sequence: &str) -> Vec<u32> {
117    let mut ids: Vec<u32> = sequence
118        .chars()
119        .filter(|c| !c.is_whitespace())
120        .map(residue_id)
121        .collect();
122    ids.push(EOS_ID);
123    ids
124}
125
126// ── ProstT5: the 3Di structural alphabet (ferritin-goh.6) ────────────────────
127
128/// How far a 3Di token sits above its amino-acid counterpart.
129///
130/// ProstT5 extends this vocabulary with Foldseek's 3Di alphabet as *lowercase*
131/// letters at ids 128..=147, and every one of them is exactly its uppercase
132/// residue's id plus this offset — `a` = 128 = `A`(3) + 125, through
133/// `c` = 147 = `C`(22) + 125. So there is no second table: 3Di is this table
134/// shifted.
135///
136/// `test_three_di_is_the_residue_table_shifted` checks that against the ids
137/// published in ProstT5's `added_tokens.json`, so the shortcut is verified
138/// rather than assumed.
139pub const THREE_DI_OFFSET: u32 = 125;
140
141/// The 20 residues that have a 3Di counterpart — the standard amino acids.
142///
143/// `X`, `B`, `O`, `U` and `Z` are in the residue table but have no 3Di state:
144/// Foldseek's alphabet is exactly 20 letters.
145const THREE_DI_RESIDUES: usize = 20;
146
147/// `<AA2fold>` — prefixed to an amino-acid sequence to translate it *to* 3Di.
148pub const AA_TO_FOLD_ID: u32 = 149;
149/// `<fold2AA>` — prefixed to a 3Di sequence to translate it *to* amino acids.
150pub const FOLD_TO_AA_ID: u32 = 148;
151
152/// Which way a ProstT5 translation runs.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum Direction {
155    /// Amino acids in, 3Di structural states out.
156    AaToFold,
157    /// 3Di structural states in, amino acids out.
158    FoldToAa,
159}
160
161impl Direction {
162    /// The token that prefixes the input for this direction.
163    pub const fn prefix_id(&self) -> u32 {
164        match self {
165            Self::AaToFold => AA_TO_FOLD_ID,
166            Self::FoldToAa => FOLD_TO_AA_ID,
167        }
168    }
169}
170
171/// The token id for one 3Di state character (lowercase), or [`UNK_ID`].
172pub fn three_di_id(state: char) -> u32 {
173    if !state.is_ascii_lowercase() {
174        return UNK_ID;
175    }
176    match RESIDUES[..THREE_DI_RESIDUES]
177        .iter()
178        .position(|&c| c == state.to_ascii_uppercase())
179    {
180        Some(i) => FIRST_RESIDUE_ID + i as u32 + THREE_DI_OFFSET,
181        None => UNK_ID,
182    }
183}
184
185/// The 3Di state character for a token id, or `None` if the id is not one.
186///
187/// Used to decode a generated sequence; ids outside 128..=147 (specials, and
188/// the amino-acid range) return `None` so a caller can tell "not a 3Di state"
189/// from "some state".
190pub fn three_di_char(id: u32) -> Option<char> {
191    let base = id.checked_sub(THREE_DI_OFFSET)?;
192    let index = base.checked_sub(FIRST_RESIDUE_ID)? as usize;
193    RESIDUES
194        .get(..THREE_DI_RESIDUES)?
195        .get(index)
196        .map(|c| c.to_ascii_lowercase())
197}
198
199/// The amino-acid character for a token id, or `None`.
200///
201/// The reverse direction's decoder: ids 3..=27 map back to residues.
202pub fn residue_char(id: u32) -> Option<char> {
203    let index = id.checked_sub(FIRST_RESIDUE_ID)? as usize;
204    RESIDUES.get(index).copied()
205}
206
207/// The token id for any character in the shared vocabulary.
208///
209/// **Case is meaning here, not formatting.** ProstT5 puts amino acids and 3Di
210/// states in one vocabulary and distinguishes them by case: `M` is methionine
211/// (19) and `m` is a structural state (144). So this maps uppercase through
212/// [`residue_id`] and lowercase through [`three_di_id`], exactly as
213/// HuggingFace's tokenizer does — and deliberately does *not* case-fold.
214///
215/// Folding case would be an unforced deviation from the reference that changes
216/// the ids: lowercasing an amino-acid sequence would silently reinterpret it as
217/// a structural one, and the model would translate it fluently and wrongly.
218pub fn vocab_id(c: char) -> u32 {
219    if c.is_ascii_lowercase() {
220        three_di_id(c)
221    } else {
222        residue_id(c)
223    }
224}
225
226/// Token ids for a ProstT5 translation input: the direction token, the
227/// sequence, then `</s>`.
228///
229/// The direction prefix is not decoration — ProstT5 is trained with it, and
230/// omitting it or using the wrong one produces a fluent, wrong translation
231/// rather than an error. The prefix is the *only* thing `direction` controls:
232/// the characters themselves are mapped by [`vocab_id`], which reads case as
233/// meaning. Whitespace is skipped, as everywhere else here.
234pub fn encode_for_translation(sequence: &str, direction: Direction) -> Vec<u32> {
235    let mut ids = Vec::with_capacity(residue_count(sequence) + 2);
236    ids.push(direction.prefix_id());
237    ids.extend(
238        sequence
239            .chars()
240            .filter(|c| !c.is_whitespace())
241            .map(vocab_id),
242    );
243    ids.push(EOS_ID);
244    ids
245}
246
247/// Rostlab's `[UZOB] -> X` convention, for callers reproducing published
248/// ProtT5 embeddings.
249///
250/// Offered rather than applied: see the module docs. Selenocysteine (U) and
251/// pyrrolysine (O) are real residues with their own learned embeddings, so
252/// collapsing them loses information that the model actually has.
253pub fn replace_rare_residues(sequence: &str) -> String {
254    sequence
255        .chars()
256        .map(|c| match c {
257            'U' | 'Z' | 'O' | 'B' => 'X',
258            other => other,
259        })
260        .collect()
261}
262
263// ── Tests ─────────────────────────────────────────────────────────────────────
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    /// The ids that matter, checked against the published `spiece.model`.
270    ///
271    /// Frequency-ordered, not alphabetical: `A` is 3 and `C` is 22. Sorting
272    /// this table would load every residue's embedding from the wrong row.
273    #[test]
274    fn test_ids_match_the_sentencepiece_vocabulary() {
275        for (residue, expected) in [
276            ('A', 3),
277            ('L', 4),
278            ('G', 5),
279            ('V', 6),
280            ('S', 7),
281            ('R', 8),
282            ('E', 9),
283            ('D', 10),
284            ('T', 11),
285            ('I', 12),
286            ('P', 13),
287            ('K', 14),
288            ('F', 15),
289            ('Q', 16),
290            ('N', 17),
291            ('Y', 18),
292            ('M', 19),
293            ('H', 20),
294            ('W', 21),
295            ('C', 22),
296            ('X', 23),
297            ('B', 24),
298            ('O', 25),
299            ('U', 26),
300            ('Z', 27),
301        ] {
302            assert_eq!(residue_id(residue), expected, "id for {residue}");
303        }
304    }
305
306    /// The table covers every id from 3 to 27 exactly once.
307    #[test]
308    fn test_residue_table_is_dense_and_unique() {
309        let mut ids: Vec<u32> = RESIDUES.iter().map(|&c| residue_id(c)).collect();
310        ids.sort_unstable();
311        ids.dedup();
312        assert_eq!(ids.len(), RESIDUES.len(), "duplicate residue in the table");
313        assert_eq!(ids.first().copied(), Some(FIRST_RESIDUE_ID));
314        assert_eq!(ids.last().copied(), Some(27));
315    }
316
317    /// The whole point of skipping whitespace: the model card's spaced form and
318    /// a bare sequence must tokenize identically.
319    #[test]
320    fn test_spaced_and_bare_sequences_agree() {
321        assert_eq!(encode("MKT"), encode("M K T"));
322        assert_eq!(encode("MKT"), vec![19, 14, 11, EOS_ID]);
323        assert_eq!(residue_count("M K T"), 3);
324        assert_eq!(residue_count("MKT"), 3);
325    }
326
327    /// EOS is appended and nothing is prepended — ProtT5 has no BOS.
328    #[test]
329    fn test_encode_appends_eos_and_no_bos() {
330        let ids = encode("AC");
331        assert_eq!(ids.len(), 3, "2 residues + </s>");
332        assert_eq!(ids[0], residue_id('A'));
333        assert_eq!(*ids.last().unwrap(), EOS_ID);
334        assert_ne!(ids[0], EOS_ID, "nothing is prepended");
335    }
336
337    /// Unknown characters become `<unk>` rather than silently shifting the
338    /// sequence or panicking.
339    #[test]
340    fn test_unknown_characters_become_unk() {
341        assert_eq!(residue_id('J'), UNK_ID);
342        assert_eq!(residue_id('m'), UNK_ID, "the tokenizer is case-sensitive");
343        assert_eq!(encode("AJC").len(), 4);
344        assert_eq!(encode("AJC")[1], UNK_ID);
345    }
346
347    // ── ProstT5 3Di ───────────────────────────────────────────────────────
348
349    /// The 3Di ids, checked against ProstT5's published `added_tokens.json`.
350    ///
351    /// This is the assertion the `THREE_DI_OFFSET` shortcut rests on: if the
352    /// two alphabets were ordered differently, deriving one from the other
353    /// would map every structural state to the wrong embedding row while
354    /// looking perfectly reasonable.
355    #[test]
356    fn test_three_di_is_the_residue_table_shifted() {
357        // Verbatim from https://huggingface.co/Rostlab/ProstT5 added_tokens.json.
358        for (state, expected) in [
359            ('a', 128),
360            ('l', 129),
361            ('g', 130),
362            ('v', 131),
363            ('s', 132),
364            ('r', 133),
365            ('e', 134),
366            ('d', 135),
367            ('t', 136),
368            ('i', 137),
369            ('p', 138),
370            ('k', 139),
371            ('f', 140),
372            ('q', 141),
373            ('n', 142),
374            ('y', 143),
375            ('m', 144),
376            ('h', 145),
377            ('w', 146),
378            ('c', 147),
379        ] {
380            assert_eq!(three_di_id(state), expected, "3Di id for {state}");
381            assert_eq!(
382                three_di_id(state),
383                residue_id(state.to_ascii_uppercase()) + THREE_DI_OFFSET,
384                "{state} should be its uppercase id + {THREE_DI_OFFSET}"
385            );
386        }
387    }
388
389    /// Foldseek's alphabet is exactly 20 letters. ProtT5's table also carries
390    /// X/B/O/U/Z, which have no structural counterpart and must not be
391    /// silently given one — `x` would otherwise land on id 148, which is
392    /// `<fold2AA>`.
393    #[test]
394    fn test_rare_residues_have_no_3di_state() {
395        for state in ['x', 'b', 'o', 'u', 'z'] {
396            assert_eq!(three_di_id(state), UNK_ID, "{state} is not a 3Di state");
397        }
398        assert_eq!(three_di_id('j'), UNK_ID);
399        assert_eq!(three_di_id('A'), UNK_ID, "3Di states are lowercase");
400    }
401
402    /// Decoding is the inverse of encoding across the whole alphabet, and the
403    /// two ranges do not overlap.
404    #[test]
405    fn test_three_di_round_trips_and_stays_disjoint_from_residues() {
406        for state in "algvsredtipkfqnymhwc".chars() {
407            let id = three_di_id(state);
408            assert_eq!(three_di_char(id), Some(state), "round trip for {state}");
409            assert_eq!(
410                residue_char(id),
411                None,
412                "{state}'s id {id} must not also read as an amino acid"
413            );
414        }
415        for residue in RESIDUES {
416            let id = residue_id(residue);
417            assert_eq!(residue_char(id), Some(residue));
418            assert_eq!(three_di_char(id), None, "{residue} is not a 3Di state");
419        }
420        for special in [PAD_ID, EOS_ID, UNK_ID, AA_TO_FOLD_ID, FOLD_TO_AA_ID] {
421            assert_eq!(three_di_char(special), None, "special {special}");
422        }
423    }
424
425    /// The direction token leads, and the two directions read opposite cases.
426    ///
427    /// Getting the prefix wrong does not error — ProstT5 produces a fluent
428    /// translation in the wrong direction — so this is the check that the
429    /// prefix is present and correct at all.
430    #[test]
431    fn test_translation_input_carries_the_direction_prefix() {
432        let aa = encode_for_translation("MKT", Direction::AaToFold);
433        assert_eq!(aa[0], AA_TO_FOLD_ID, "AA->3Di must lead with <AA2fold>");
434        assert_eq!(&aa[1..4], &[19, 14, 11], "the residues themselves");
435        assert_eq!(*aa.last().unwrap(), EOS_ID);
436
437        let fold = encode_for_translation("dvq", Direction::FoldToAa);
438        assert_eq!(fold[0], FOLD_TO_AA_ID, "3Di->AA must lead with <fold2AA>");
439        assert_eq!(
440            &fold[1..4],
441            &[three_di_id('d'), three_di_id('v'), three_di_id('q')]
442        );
443        assert_eq!(*fold.last().unwrap(), EOS_ID);
444        assert_eq!(aa.len(), 5, "3 residues + prefix + EOS");
445    }
446
447    /// Case is meaning, not formatting: `M` and `m` are different tokens, and
448    /// the encoder must not fold them together.
449    ///
450    /// This is the deviation that is easy to introduce by being "helpful".
451    /// Lowercasing an amino-acid sequence does not normalise it — it
452    /// reinterprets it as a structural one, and every id changes.
453    #[test]
454    fn test_case_selects_the_alphabet_and_is_never_folded() {
455        assert_eq!(vocab_id('M'), 19, "uppercase M is methionine");
456        assert_eq!(vocab_id('m'), 144, "lowercase m is a 3Di state");
457        assert_ne!(vocab_id('M'), vocab_id('m'));
458
459        // The direction token is the only thing `direction` changes.
460        let a = encode_for_translation("MKT", Direction::AaToFold);
461        let b = encode_for_translation("MKT", Direction::FoldToAa);
462        assert_eq!(a[1..], b[1..], "only the prefix should differ");
463        assert_ne!(a[0], b[0]);
464
465        // A character with no counterpart in its own case is <unk>, not a
466        // silent reinterpretation: `x` has no 3Di state.
467        assert_eq!(vocab_id('x'), UNK_ID);
468        assert_eq!(vocab_id('X'), 23, "uppercase X is a real residue");
469    }
470
471    /// The rare-residue convention is available but not automatic.
472    #[test]
473    fn test_replace_rare_residues_is_opt_in() {
474        assert_eq!(replace_rare_residues("MUZOB"), "MXXXX");
475        assert_ne!(
476            encode("MU"),
477            encode(&replace_rare_residues("MU")),
478            "encode must not apply the substitution on its own"
479        );
480        assert_eq!(residue_id('U'), 26, "U keeps its own embedding row");
481    }
482}