Skip to main content

ferritin_plms/esm2/
saprot_tokenizer.rs

1//! SaProt's structure-aware tokenizer (ferritin-goh.3).
2//!
3//! SaProt is the ESM-2 architecture with a different alphabet. Each residue is
4//! an (amino acid, 3Di structural state) pair over a 20×20 product alphabet —
5//! `"Ma"`, `"Ld"`, `"Kp"` — so a sequence is read two characters at a time and
6//! `"MdAaLp"` is **three** residues, not six.
7//!
8//! # Why not `tokenizers::Tokenizer`
9//!
10//! SaProt's repo ships no `tokenizer.json`. It has a bare `vocab.txt`: 446
11//! lines, one token per line, the line number being the id. Reconstructing an
12//! HF tokenizer from that would mean assembling a `WordLevel` model plus a
13//! two-character `Split` pre-tokenizer to reproduce behaviour that is, written
14//! directly, a lookup table and `chunks(2)`.
15//!
16//! # Vocabulary layout
17//!
18//! ```text
19//! 0  <cls>
20//! 1  <pad>
21//! 2  <eos>
22//! 3  <unk>
23//! 4  <mask>
24//! 5+ Ap, Ay, An, ...   (amino acid, 3Di state) pairs
25//! ```
26//!
27//! Those ids match `config.json`'s `pad_token_id: 1` and `mask_token_id: 4`,
28//! which is the cross-check that the file is being read as intended.
29
30use anyhow::{Result, bail};
31use std::collections::HashMap;
32
33/// Characters per residue in SaProt's product alphabet.
34pub const CHARS_PER_RESIDUE: usize = 2;
35
36// ── Bridging from ProstT5 (ferritin-goh.13) ──────────────────────────────────
37
38/// SaProt's placeholder for an unknown amino acid or unknown structural state.
39///
40/// The alphabet is a full 21x21 product — the 20 standard residues plus this,
41/// crossed with the 20 3Di states plus this — so `#` is available on either
42/// axis. `M#` is a known residue with unknown structure (the documented
43/// sequence-only form) and `#a` is the converse.
44pub const UNKNOWN_STATE: char = '#';
45
46/// The residues SaProt has pairs for: the 20 standard amino acids.
47///
48/// Deliberately narrower than ProtT5's table, which also carries `X`, `B`,
49/// `O`, `U` and `Z`. SaProt has no pair for those — `Xa` is simply not in the
50/// vocabulary — so they have to become [`UNKNOWN_STATE`] rather than being
51/// passed through. See [`interleave`].
52const SAPROT_RESIDUES: &str = "ACDEFGHIKLMNPQRSTVWY";
53
54/// The 20 Foldseek 3Di states, lowercase.
55const SAPROT_STRUCTURE_STATES: &str = "acdefghiklmnpqrstvwy";
56
57/// Weave an amino-acid sequence together with a 3Di structural string into
58/// SaProt's product alphabet (ferritin-goh.13).
59///
60/// This is the join between
61/// [`ProstT5Translator`][crate::t5::translator::ProstT5Translator], which emits
62/// 3Di, and SaProt, which consumes `(residue, state)` pairs — the pair that
63/// removes the Foldseek dependency from the structure-aware path.
64///
65/// # Non-standard residues become `#`
66///
67/// ProstT5's alphabet is wider than SaProt's: it has embeddings for `X`, `B`,
68/// `O`, `U` and `Z`, and SaProt has no pair for any of them. Passing one
69/// straight through would produce a token like `Xa`, which is **not in the
70/// 441-entry vocabulary** and encodes as `<unk>` — losing the structural state
71/// as well as the residue, silently.
72///
73/// Substituting [`UNKNOWN_STATE`] keeps the state: `#a` *is* in the vocabulary.
74/// It is lossy about the residue either way, but it loses strictly less, and it
75/// uses SaProt's own convention for exactly this case.
76///
77/// # Errors
78///
79/// On a length mismatch — ProstT5's translation is length-preserving, so a
80/// disagreement means something upstream went wrong and silently truncating to
81/// the shorter of the two would misalign every residue after the first gap —
82/// and on any character that is neither a residue nor a state, which indicates
83/// the two arguments were swapped or the structure string is not 3Di at all.
84///
85/// Whitespace is skipped in both arguments, matching the rest of the family.
86pub fn interleave(residues: &str, structure: &str) -> Result<String> {
87    let aa: Vec<char> = residues.chars().filter(|c| !c.is_whitespace()).collect();
88    let states: Vec<char> = structure.chars().filter(|c| !c.is_whitespace()).collect();
89
90    if aa.len() != states.len() {
91        bail!(
92            "SaProt bridge: {} residues against {} structural states. ProstT5's \
93             translation is length-preserving, so these should always match.",
94            aa.len(),
95            states.len()
96        );
97    }
98    if aa.is_empty() {
99        bail!("SaProt bridge: nothing to interleave");
100    }
101
102    let mut out = String::with_capacity(aa.len() * CHARS_PER_RESIDUE);
103    for (i, (&residue, &state)) in aa.iter().zip(states.iter()).enumerate() {
104        let residue = if SAPROT_RESIDUES.contains(residue) || residue == UNKNOWN_STATE {
105            residue
106        } else if residue.is_ascii_uppercase() {
107            // A non-standard residue ProstT5 knows and SaProt does not.
108            UNKNOWN_STATE
109        } else {
110            bail!(
111                "SaProt bridge: {residue:?} at position {i} is not an amino acid. \
112                 Residues are uppercase and structural states lowercase — are the \
113                 two arguments the right way round?"
114            );
115        };
116        if !(SAPROT_STRUCTURE_STATES.contains(state) || state == UNKNOWN_STATE) {
117            bail!(
118                "SaProt bridge: {state:?} at position {i} is not a 3Di state. \
119                 Structural states are lowercase; ProstT5 emits them directly."
120            );
121        }
122        out.push(residue);
123        out.push(state);
124    }
125    Ok(out)
126}
127
128/// A tokenizer built from SaProt's `vocab.txt`.
129#[derive(Debug, Clone)]
130pub struct SaProtTokenizer {
131    ids: HashMap<String, u32>,
132    vocab: Vec<String>,
133}
134
135impl SaProtTokenizer {
136    /// Parse a `vocab.txt`: one token per line, line number is the id.
137    ///
138    /// Rejects a file whose special tokens are absent or misplaced, since the
139    /// ids are load-bearing — `<cls>` and `<eos>` wrap every sequence, and a
140    /// silently-wrong `<unk>` would turn unknown residues into a real amino
141    /// acid rather than an unknown.
142    pub fn from_vocab_txt(contents: &str) -> Result<Self> {
143        let vocab: Vec<String> = contents
144            .lines()
145            .map(|l| l.trim_end_matches('\r').to_string())
146            .filter(|l| !l.is_empty())
147            .collect();
148
149        if vocab.is_empty() {
150            bail!("SaProt vocab.txt is empty");
151        }
152
153        let ids: HashMap<String, u32> = vocab
154            .iter()
155            .enumerate()
156            .map(|(i, t)| (t.clone(), i as u32))
157            .collect();
158
159        for (expected_id, token) in [(0u32, "<cls>"), (1, "<pad>"), (2, "<eos>"), (3, "<unk>")] {
160            match ids.get(token) {
161                Some(&id) if id == expected_id => {}
162                Some(&id) => bail!(
163                    "SaProt vocab.txt has {token} at id {id}, expected {expected_id}; \
164                     the special-token ids are load-bearing"
165                ),
166                None => bail!("SaProt vocab.txt is missing {token}"),
167            }
168        }
169
170        Ok(Self { ids, vocab })
171    }
172
173    /// Number of tokens in the vocabulary.
174    pub fn len(&self) -> usize {
175        self.vocab.len()
176    }
177
178    /// Whether the vocabulary is empty. Never true for a valid vocab.
179    pub fn is_empty(&self) -> bool {
180        self.vocab.is_empty()
181    }
182
183    /// Look up a token's id.
184    pub fn token_to_id(&self, token: &str) -> Option<u32> {
185        self.ids.get(token).copied()
186    }
187
188    /// How many residues `sequence` encodes.
189    ///
190    /// A trailing odd character is its own (malformed) residue rather than
191    /// being dropped, so [`encode`][Self::encode] can report it as `<unk>`
192    /// instead of silently shortening the sequence.
193    pub fn residue_count(&self, sequence: &str) -> usize {
194        sequence.len().div_ceil(CHARS_PER_RESIDUE)
195    }
196
197    /// Encode a SaProt sequence to token ids, **without** special tokens.
198    ///
199    /// Unknown pairs map to `<unk>` rather than erroring: a residue whose 3Di
200    /// state could not be determined is written with a `#` placeholder in
201    /// practice, and refusing the whole sequence for one such residue would be
202    /// unhelpful.
203    pub fn encode(&self, sequence: &str) -> Vec<u32> {
204        let unk = self.ids["<unk>"];
205        sequence
206            .as_bytes()
207            .chunks(CHARS_PER_RESIDUE)
208            .map(|pair| {
209                std::str::from_utf8(pair)
210                    .ok()
211                    .and_then(|t| self.ids.get(t).copied())
212                    .unwrap_or(unk)
213            })
214            .collect()
215    }
216
217    /// Decode ids back to a SaProt sequence, skipping special tokens.
218    pub fn decode(&self, ids: &[u32]) -> String {
219        ids.iter()
220            .filter_map(|&id| self.vocab.get(id as usize))
221            .filter(|t| !(t.starts_with('<') && t.ends_with('>')))
222            .map(String::as_str)
223            .collect()
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    /// The first lines of the real SaProt vocab.txt, plus a few residue pairs.
232    fn vocab() -> &'static str {
233        "<cls>\n<pad>\n<eos>\n<unk>\n<mask>\nAp\nAy\nAn\nMd\nLp\n"
234    }
235
236    #[test]
237    fn test_special_token_ids_match_the_config() {
238        let t = SaProtTokenizer::from_vocab_txt(vocab()).unwrap();
239        assert_eq!(t.token_to_id("<cls>"), Some(0));
240        assert_eq!(t.token_to_id("<pad>"), Some(1));
241        assert_eq!(t.token_to_id("<eos>"), Some(2));
242        assert_eq!(t.token_to_id("<unk>"), Some(3));
243        // config.json declares mask_token_id 4 and pad_token_id 1.
244        assert_eq!(t.token_to_id("<mask>"), Some(4));
245    }
246
247    /// Two characters per residue — the property the whole module exists for.
248    #[test]
249    fn test_encodes_two_characters_per_residue() {
250        let t = SaProtTokenizer::from_vocab_txt(vocab()).unwrap();
251        let ids = t.encode("ApAyAn");
252        assert_eq!(ids.len(), 3, "six characters are three residues");
253        assert_eq!(ids, vec![5, 6, 7]);
254        assert_eq!(t.residue_count("ApAyAn"), 3);
255    }
256
257    #[test]
258    fn test_unknown_pairs_become_unk_not_an_error() {
259        let t = SaProtTokenizer::from_vocab_txt(vocab()).unwrap();
260        // "Zz" is not in the vocabulary.
261        assert_eq!(t.encode("ApZz"), vec![5, 3]);
262    }
263
264    /// A trailing odd character is reported as unknown rather than dropped,
265    /// so a malformed sequence does not silently lose its last residue.
266    #[test]
267    fn test_odd_length_input_is_not_silently_truncated() {
268        let t = SaProtTokenizer::from_vocab_txt(vocab()).unwrap();
269        assert_eq!(t.residue_count("ApA"), 2);
270        assert_eq!(t.encode("ApA"), vec![5, 3]);
271    }
272
273    #[test]
274    fn test_decode_round_trips_and_drops_specials() {
275        let t = SaProtTokenizer::from_vocab_txt(vocab()).unwrap();
276        let ids = t.encode("ApMdLp");
277        assert_eq!(t.decode(&ids), "ApMdLp");
278
279        let wrapped = [&[0u32][..], &ids, &[2u32][..]].concat();
280        assert_eq!(t.decode(&wrapped), "ApMdLp", "specials should be dropped");
281    }
282
283    /// A vocab whose specials are misplaced is rejected, because those ids are
284    /// used directly rather than looked up everywhere.
285    #[test]
286    fn test_rejects_misplaced_special_tokens() {
287        let bad = "<pad>\n<cls>\n<eos>\n<unk>\n<mask>\nAp\n";
288        let err = SaProtTokenizer::from_vocab_txt(bad)
289            .map(|_| ())
290            .expect_err("swapped cls/pad must be rejected");
291        assert!(err.to_string().contains("load-bearing"), "got: {err}");
292    }
293
294    #[test]
295    fn test_rejects_a_vocab_missing_specials() {
296        let err = SaProtTokenizer::from_vocab_txt("Ap\nAy\n")
297            .map(|_| ())
298            .expect_err("a vocab without <cls> is not a SaProt vocab");
299        assert!(err.to_string().contains("missing"), "got: {err}");
300    }
301
302    // ── The ProstT5 bridge (ferritin-goh.13) ──────────────────────────────
303
304    /// The join itself: residue and state alternate, uppercase then lowercase.
305    #[test]
306    fn test_interleave_weaves_residues_and_states() {
307        assert_eq!(interleave("MKT", "dvq").unwrap(), "MdKvTq");
308        assert_eq!(interleave("M K T", "d v q").unwrap(), "MdKvTq");
309    }
310
311    /// The alphabet this bridge is allowed to emit is exactly 21x21.
312    ///
313    /// That every one of those pairs is really in SaProt's vocabulary is
314    /// checked against the published `vocab.txt` in
315    /// `tests/test_saprot_bridge.rs`; here we only pin the shape.
316    #[test]
317    fn test_bridge_alphabet_is_twenty_one_squared() {
318        assert_eq!(SAPROT_RESIDUES.chars().count(), 20);
319        assert_eq!(SAPROT_STRUCTURE_STATES.chars().count(), 20);
320        assert!(!SAPROT_RESIDUES.contains(UNKNOWN_STATE));
321        assert!(
322            SAPROT_STRUCTURE_STATES
323                .chars()
324                .all(|c| c.is_ascii_lowercase())
325        );
326        assert!(SAPROT_RESIDUES.chars().all(|c| c.is_ascii_uppercase()));
327    }
328
329    /// ProstT5 knows X/B/O/U/Z; SaProt does not. They become `#`, which keeps
330    /// the structural state — `#a` is in the vocabulary, `Xa` is not.
331    #[test]
332    fn test_non_standard_residues_become_the_unknown_state() {
333        assert_eq!(interleave("MXBOUZ", "dvqavd").unwrap(), "Md#v#q#a#v#d");
334        // That this substitution is not cosmetic — `Xa` is absent from the
335        // vocabulary while `#a` is present — is checked against the real
336        // vocab.txt in tests/test_saprot_bridge.rs.
337    }
338
339    /// A length mismatch is an error, not a silent truncation — every residue
340    /// after the gap would otherwise be paired with the wrong state.
341    #[test]
342    fn test_interleave_rejects_a_length_mismatch() {
343        let err = interleave("MKT", "dv").unwrap_err().to_string();
344        assert!(err.contains("3 residues against 2"), "got: {err}");
345        assert!(interleave("", "").is_err(), "nothing to interleave");
346    }
347
348    /// Swapping the arguments is the easy mistake, and it is caught rather
349    /// than producing a fluent-looking wrong answer.
350    #[test]
351    fn test_interleave_rejects_swapped_arguments() {
352        let err = interleave("dvq", "MKT").unwrap_err().to_string();
353        assert!(
354            err.contains("not an amino acid") || err.contains("right way round"),
355            "got: {err}"
356        );
357    }
358
359    /// `#` is legitimate on either side: a residue whose structure could not be
360    /// determined is exactly what the sequence-only form uses.
361    #[test]
362    fn test_unknown_state_passes_through_on_either_axis() {
363        assert_eq!(interleave("MKT", "d#q").unwrap(), "MdK#Tq");
364        assert_eq!(interleave("M#T", "dvq").unwrap(), "Md#vTq");
365    }
366}