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}