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}