ferritin_plms/t5/translator.rs
1//! ProstT5 — translation between the amino-acid and 3Di structural alphabets
2//! (ferritin-goh.6).
3//!
4//! ProstT5 is the only encoder-**decoder** in the crate, and the only model
5//! whose useful output is generated tokens rather than an embedding. It
6//! therefore sits outside [`PlmRunner`][crate::plm_runner::PlmRunner], which is
7//! about embeddings, rather than being contorted to fit it.
8//!
9//! What it buys: the 3Di string it produces is what SaProt consumes, so this
10//! pair removes the Foldseek dependency from the structure-aware path.
11
12use crate::loader::{LoadOptions, WeightSource};
13use crate::registry::{self, ModelCard};
14use crate::t5::runner::parse_t5_config;
15use crate::t5::tokenizer::{self, Direction};
16use anyhow::{Context, Result, bail};
17use candle_core::{DType, Device, Tensor};
18use candle_transformers::models::t5::{Config as T5Config, T5ForConditionalGeneration};
19use std::sync::Mutex;
20
21/// Available ProstT5 variants.
22pub enum ProstT5Models {
23 /// `Rostlab/ProstT5_fp16` — the half-precision build. The F32
24 /// `Rostlab/ProstT5` is the same weights at 11.3 GB instead of 5.6 GB.
25 XlFp16,
26}
27
28impl ProstT5Models {
29 /// This variant's registry id.
30 pub const fn registry_id(&self) -> &'static str {
31 match self {
32 Self::XlFp16 => "prostt5-fp16",
33 }
34 }
35
36 /// This variant's [`ModelCard`].
37 pub fn card(&self) -> &'static ModelCard {
38 registry::lookup(self.registry_id())
39 .expect("every ProstT5Models variant must have a registry entry")
40 }
41
42 /// The precision this checkpoint is published at.
43 pub const fn published_dtype(&self) -> DType {
44 DType::F16
45 }
46
47 /// The published `config.json`, transcribed, for when the hub is
48 /// unreachable.
49 ///
50 /// Note `tie_word_embeddings: true`. The checkpoint has **no `lm_head`
51 /// tensor**, so the output projection is the shared embedding transposed
52 /// and the hidden state is scaled by `sqrt(d_model)` first.
53 ///
54 /// Setting it to `false` would fail loudly — candle would go looking for
55 /// an `lm_head` that is not there. The quiet failure is upstream of that:
56 /// the hub's `config.json` writes `"tie_word_embeddings": null`, and if
57 /// that config were dropped wholesale (see `parse_t5_config`) this fallback
58 /// is what would be used instead. So it has to be right.
59 fn fallback_config(&self) -> T5Config {
60 T5Config {
61 vocab_size: 150,
62 d_model: 1024,
63 d_kv: 128,
64 d_ff: 16384,
65 num_layers: 24,
66 num_decoder_layers: Some(24),
67 num_heads: 32,
68 relative_attention_num_buckets: 32,
69 relative_attention_max_distance: 128,
70 dropout_rate: 0.1,
71 layer_norm_epsilon: 1e-6,
72 initializer_factor: 1.0,
73 feed_forward_proj: Default::default(),
74 tie_word_embeddings: true,
75 is_decoder: false,
76 is_encoder_decoder: true,
77 // The decoder genuinely caches; see `ProstT5Translator`.
78 use_cache: true,
79 pad_token_id: tokenizer::PAD_ID as usize,
80 eos_token_id: tokenizer::EOS_ID as usize,
81 decoder_start_token_id: Some(tokenizer::PAD_ID as usize),
82 }
83 }
84}
85
86/// ProstT5, wrapped for greedy translation.
87///
88/// # Why the model sits behind a `Mutex`, and why it matters more here
89///
90/// [`T5Runner`][crate::t5::runner::T5Runner] also holds its model in a `Mutex`,
91/// but there it guards nothing: `T5Attention::load` sets
92/// `use_cache: cfg.use_cache && decoder`, and an encoder stack loads with
93/// `decoder = false`, so the encoder never caches.
94///
95/// **The decoder does.** Every `decode` step appends to a per-layer KV cache,
96/// which is what makes incremental generation fast — and what makes a stale
97/// cache dangerous: the next translation would concatenate its keys onto the
98/// previous one's and produce a fluent, wrong answer with no error. So
99/// [`translate`][Self::translate] clears the cache before it starts, and
100/// `test_repeated_translation_is_deterministic` fails if that is ever removed.
101pub struct ProstT5Translator {
102 model: Mutex<T5ForConditionalGeneration>,
103 device: Device,
104}
105
106impl ProstT5Translator {
107 /// Load from the hub at the checkpoint's published precision (F16).
108 pub fn from_pretrained(model: ProstT5Models, device: Device) -> Result<Self> {
109 let dtype = model.published_dtype();
110 Self::from_pretrained_with(model, &LoadOptions::new(device).with_dtype(dtype))
111 }
112
113 /// Load with an explicit device and dtype.
114 pub fn from_pretrained_with(model: ProstT5Models, opts: &LoadOptions) -> Result<Self> {
115 let card = model.card();
116 let config = load_config(&card.source, model.fallback_config());
117 let vb = card.source.var_builder(card.file, opts)?;
118 let inner = T5ForConditionalGeneration::load(vb, &config).with_context(|| {
119 format!(
120 "failed to load {} as a T5 encoder-decoder",
121 card.source.repo_id
122 )
123 })?;
124 Ok(Self {
125 model: Mutex::new(inner),
126 device: opts.device.clone(),
127 })
128 }
129
130 /// Translate `sequence` between the amino-acid and 3Di alphabets.
131 ///
132 /// Greedy decoding, which is what ProstT5's own usage does: the mapping is
133 /// close to deterministic and sampling would only add noise to a structural
134 /// annotation.
135 ///
136 /// ProstT5's translation is **length-preserving**: one output token per
137 /// input residue. Length, not `</s>`, is therefore the stopping criterion,
138 /// and the EOS logit is suppressed at every step — which is exactly what
139 /// Rostlab's own inference does by passing `min_length == max_length` to
140 /// `generate`, and what HuggingFace implements as `MinLengthLogitsProcessor`.
141 ///
142 /// Breaking on `</s>` instead would silently return a truncated structure
143 /// for any sequence where the model wanted to stop early, and would
144 /// disagree with the reference on exactly those cases.
145 pub fn translate(&self, sequence: &str, direction: Direction) -> Result<String> {
146 let residues = tokenizer::residue_count(sequence);
147 if residues == 0 {
148 bail!("ProstT5: cannot translate an empty sequence");
149 }
150 let ids = tokenizer::encode_for_translation(sequence, direction);
151 let input = Tensor::new(ids.as_slice(), &self.device)?.unsqueeze(0)?;
152
153 let mut model = self
154 .model
155 .lock()
156 .map_err(|_| anyhow::anyhow!("ProstT5 model mutex was poisoned by an earlier panic"))?;
157
158 // Mandatory, not hygiene: a cache left over from the previous call is
159 // concatenated onto, not overwritten.
160 model.clear_kv_cache();
161 let encoded = model.encode(&input)?;
162
163 let mut generated: Vec<u32> = Vec::with_capacity(residues);
164 // T5 starts the decoder from `decoder_start_token_id`, which is <pad>.
165 let mut next_input = vec![tokenizer::PAD_ID];
166 for _ in 0..residues {
167 let decoder_input = Tensor::new(next_input.as_slice(), &self.device)?.unsqueeze(0)?;
168 let logits = model
169 .decode(&decoder_input, &encoded)?
170 .to_dtype(DType::F32)?;
171 let next = argmax_excluding_eos(&logits)?;
172 generated.push(next);
173 // Only the new token: the KV cache holds the rest.
174 next_input = vec![next];
175 }
176 drop(model);
177
178 decode_generated(&generated, direction)
179 }
180
181 /// Translate `sequence` and weave the result back into SaProt's input form
182 /// (ferritin-goh.13).
183 ///
184 /// This is the pair that removes the Foldseek C++ dependency from the
185 /// structure-aware path: ProstT5 predicts the 3Di states that SaProt would
186 /// otherwise need Foldseek to compute from a real structure.
187 ///
188 /// ```text
189 /// "MQIFVKTLTGK" -> 3Di "dvvvvcvvvvd" -> "MdQvIvFvVvKcTvLvTvGvKd"
190 /// ```
191 ///
192 /// Note what this is and is not: the 3Di states are *predicted from
193 /// sequence*, not measured from a structure. That is the point — it works
194 /// without a structure at all — but a SaProt embedding built this way is
195 /// not interchangeable with one built from an experimental backbone.
196 ///
197 /// Residues outside SaProt's 20 become `#`; see
198 /// [`interleave`][crate::esm2::saprot_tokenizer::interleave].
199 pub fn to_saprot_input(&self, sequence: &str) -> Result<String> {
200 let structure = self.translate(sequence, Direction::AaToFold)?;
201 crate::esm2::saprot_tokenizer::interleave(sequence, &structure)
202 }
203}
204
205/// Greedy pick, with `</s>` masked out.
206///
207/// The mask is why this is not a plain `argmax`: generation runs for a fixed
208/// number of steps, so an EOS that wins the argmax must be passed over in
209/// favour of the next-best real token rather than ending the sequence. Setting
210/// it to `NEG_INFINITY` is safe here because the tensor has already been cast
211/// to F32 — at F16 an infinity would be a live value in later arithmetic, but
212/// nothing follows this except the argmax itself.
213fn argmax_excluding_eos(logits: &Tensor) -> Result<u32> {
214 let mut row: Vec<f32> = logits.flatten_all()?.to_vec1()?;
215 let eos = tokenizer::EOS_ID as usize;
216 if eos < row.len() {
217 row[eos] = f32::NEG_INFINITY;
218 }
219 let (best, _) =
220 row.iter()
221 .enumerate()
222 .fold((0usize, f32::NEG_INFINITY), |(bi, bv), (i, &v)| {
223 if v > bv { (i, v) } else { (bi, bv) }
224 });
225 Ok(best as u32)
226}
227
228/// Turn generated ids back into a string in the direction's output alphabet.
229///
230/// Errors on a token outside that alphabet rather than substituting a
231/// placeholder: a `?` in a 3Di string would flow onward into SaProt as if it
232/// were a structural state.
233fn decode_generated(ids: &[u32], direction: Direction) -> Result<String> {
234 ids.iter()
235 .enumerate()
236 .map(|(i, &id)| {
237 let decoded = match direction {
238 // AA in, 3Di out — and vice versa.
239 Direction::AaToFold => tokenizer::three_di_char(id),
240 Direction::FoldToAa => tokenizer::residue_char(id),
241 };
242 decoded.ok_or_else(|| {
243 anyhow::anyhow!(
244 "ProstT5 generated token {id} at position {i}, which is not in the \
245 {} alphabet this direction produces",
246 match direction {
247 Direction::AaToFold => "3Di",
248 Direction::FoldToAa => "amino-acid",
249 }
250 )
251 })
252 })
253 .collect()
254}
255
256/// Load `config.json` from the hub, falling back to the built-in config.
257///
258/// Unlike the encoder-only path this does **not** force `use_cache` off — the
259/// decoder's cache is what makes incremental generation possible.
260fn load_config(source: &WeightSource, fallback: T5Config) -> T5Config {
261 match source.fetch_optional("config.json") {
262 None => {
263 eprintln!(
264 "warning: {}: could not download config.json; using the built-in config.",
265 source.repo_id
266 );
267 fallback
268 }
269 Some(path) => match std::fs::read_to_string(&path)
270 .map_err(|e| e.to_string())
271 .and_then(|s| parse_t5_config(&s).map_err(|e| e.to_string()))
272 {
273 Ok(config) => config,
274 Err(e) => {
275 eprintln!(
276 "warning: {}: config.json did not parse as a T5 Config ({e}); \
277 using the built-in config instead.",
278 source.repo_id
279 );
280 fallback
281 }
282 },
283 }
284}
285
286// ── Tests ─────────────────────────────────────────────────────────────────────
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_fallback_config_matches_the_card() {
294 let model = ProstT5Models::XlFp16;
295 let card = model.card();
296 let config = model.fallback_config();
297 assert_eq!(config.d_model, card.metadata.d_model);
298 assert_eq!(config.num_layers, card.metadata.n_layers);
299 assert_eq!(config.vocab_size, card.metadata.vocab_size);
300 assert!(
301 config.tie_word_embeddings,
302 "ProstT5 has no lm_head tensor; the head is the shared embedding"
303 );
304 assert!(
305 config.use_cache,
306 "the decoder's KV cache is what makes incremental generation work"
307 );
308 }
309
310 fn logits_row(values: &[(u32, f32)]) -> Tensor {
311 let mut row = vec![-10.0f32; 150];
312 for &(id, v) in values {
313 row[id as usize] = v;
314 }
315 Tensor::from_vec(row, (1, 150), &Device::Cpu).unwrap()
316 }
317
318 /// `</s>` is never selected, even when it wins outright.
319 ///
320 /// This is the whole reason generation does not use a plain `argmax`:
321 /// ProstT5's translation is length-preserving, so an EOS that tops the
322 /// distribution has to be passed over for the next-best real token — the
323 /// same thing HuggingFace's `MinLengthLogitsProcessor` does when Rostlab's
324 /// inference passes `min_length == max_length`.
325 #[test]
326 fn test_eos_is_never_greedily_selected() {
327 let runner_up = tokenizer::three_di_id('v');
328 let logits = logits_row(&[(tokenizer::EOS_ID, 99.0), (runner_up, 5.0)]);
329 assert_eq!(
330 argmax_excluding_eos(&logits).unwrap(),
331 runner_up,
332 "EOS should be masked out in favour of the next-best token"
333 );
334 }
335
336 /// Masking EOS must not disturb the ordinary case.
337 #[test]
338 fn test_argmax_is_otherwise_ordinary() {
339 let best = tokenizer::three_di_id('d');
340 let logits = logits_row(&[(best, 7.0), (tokenizer::three_di_id('v'), 6.5)]);
341 assert_eq!(argmax_excluding_eos(&logits).unwrap(), best);
342 }
343
344 /// Decoding reads the *output* alphabet, which is the opposite of the
345 /// input's — the direction names which way the translation runs, not which
346 /// alphabet comes back.
347 #[test]
348 fn test_decode_generated_uses_the_output_alphabet() {
349 let three_di: Vec<u32> = "dvq".chars().map(tokenizer::three_di_id).collect();
350 assert_eq!(
351 decode_generated(&three_di, Direction::AaToFold).unwrap(),
352 "dvq"
353 );
354
355 let residues: Vec<u32> = "MKT".chars().map(tokenizer::residue_id).collect();
356 assert_eq!(
357 decode_generated(&residues, Direction::FoldToAa).unwrap(),
358 "MKT"
359 );
360 }
361
362 /// A token from the wrong alphabet is an error, not a placeholder: a `?`
363 /// in a 3Di string would flow onward into SaProt as a structural state.
364 #[test]
365 fn test_decode_generated_rejects_out_of_alphabet_tokens() {
366 let err = decode_generated(&[tokenizer::residue_id('M')], Direction::AaToFold)
367 .unwrap_err()
368 .to_string();
369 assert!(err.contains("3Di alphabet"), "unexpected error: {err}");
370 assert!(
371 decode_generated(&[tokenizer::AA_TO_FOLD_ID], Direction::AaToFold).is_err(),
372 "the direction token is not a structural state"
373 );
374 }
375}