Skip to main content

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}