ferritin_plms/esmfold2/pretrained.rs
1//! ESMFold2 pretrained model loading.
2//!
3//! Downloads the structure-head weights from HuggingFace and optionally wires
4//! in the ESMC-6B backbone for end-to-end structure prediction.
5//!
6//! ## Weight layout
7//!
8//! `biohub/ESMFold2-Fast` `model.safetensors` (~755 MB) contains the structure
9//! head only. The ESMC-6B backbone (~12 GB) must be loaded from a second repo
10//! (`biohub/ESMC-6B`).
11//!
12//! | Weight prefix | Component |
13//! |------------------------|-------------------------------------------|
14//! | `lm_encoder.*` | LM adapter (4 blocks, 2560 → d_single=384) |
15//! | `folding_trunk.*` | 24-layer Pairformer trunk |
16//! | `inputs.atom_encoder.*`| Atom encoder (3 blocks, d_atom=128→768) |
17//! | `msa_encoder.*` | MSA encoder (disabled in Fast) |
18//! | `structure_head.*` | Diffusion module (token 12-block + atom 3-block) |
19//! | `confidence_head.*` | pLDDT / pAE / pDE / distogram heads |
20//!
21//! ## Two loading modes
22//!
23//! - [`ESMFold2Runner::from_pretrained`] — structure head only (~755 MB).
24//! Backbone hidden states are zero-initialised; coordinates will be
25//! physically meaningless but all shapes are correct (useful for shape testing).
26//!
27//! - [`ESMFold2Runner::from_pretrained_with_backbone`] — structure head +
28//! ESMC-6B backbone (~12 GB total). Required for real structure predictions.
29
30use super::config::ESMFold2Config;
31use super::model::ESMFold2Model;
32use super::output::ESMFold2Output;
33use crate::esmc::pretrained::{ESMCModels, ESMCRunner};
34use anyhow::Result;
35use candle_core::{DType, Device, Tensor};
36use candle_nn::VarBuilder;
37use hf_hub::HFClientSync;
38
39const ESMFOLD2_DTYPE: DType = DType::F32;
40
41// ── ESMFold2Models enum ───────────────────────────────────────────────────────
42
43/// Available ESMFold2 model variants hosted on HuggingFace.
44pub enum ESMFold2Models {
45 /// ESMFold2-Fast — single-sequence only, no MSA, optimized for speed.
46 /// Use `num_sampling_steps = 50` for quality, `14` for speed.
47 Fast,
48 /// ESMFold2 (Full) — optional MSA conditioning for higher accuracy.
49 Full,
50}
51
52impl ESMFold2Models {
53 /// Returns `(hf_repo_id, config)` for this variant.
54 pub fn model_info(&self) -> (&'static str, ESMFold2Config) {
55 match self {
56 Self::Fast => ("biohub/ESMFold2-Fast", ESMFold2Config::fast()),
57 Self::Full => ("biohub/ESMFold2", ESMFold2Config::fast()),
58 }
59 }
60}
61
62// ── ESMFold2Runner ────────────────────────────────────────────────────────────
63
64/// Wraps a loaded ESMFold2 model for all-atom structure prediction inference.
65///
66/// Create with:
67/// - [`from_pretrained`][Self::from_pretrained] — structure head only (~755 MB),
68/// backbone stubs with zeros (shape testing only).
69/// - [`from_pretrained_with_backbone`][Self::from_pretrained_with_backbone] —
70/// structure head + ESMC-6B backbone (~12 GB total, real predictions).
71pub struct ESMFold2Runner {
72 model: ESMFold2Model,
73 config: ESMFold2Config,
74 device: Device,
75 /// ESMC-6B backbone. `None` → hidden states are zeroed (stub mode).
76 backbone: Option<ESMCRunner>,
77}
78
79impl ESMFold2Runner {
80 /// Download and load the ESMFold2 structure head only (~755 MB).
81 ///
82 /// The ESMC-6B backbone is **not** loaded; hidden states passed to
83 /// `fold_protein` will be all-zeros, so predicted coordinates are
84 /// physically meaningless. Use this for shape/pipeline testing without
85 /// requiring 12 GB of disk space.
86 pub fn from_pretrained(model_variant: ESMFold2Models, device: Device) -> Result<Self> {
87 let (repo_id, config) = model_variant.model_info();
88 let model = Self::load_structure_head(repo_id, &config, &device)?;
89 Ok(Self { model, config, device, backbone: None })
90 }
91
92 /// Download and load the structure head (~755 MB) **and** the ESMC-6B
93 /// backbone (~12 GB). Required for predictions with meaningful pLDDT.
94 pub fn from_pretrained_with_backbone(
95 model_variant: ESMFold2Models,
96 device: Device,
97 ) -> Result<Self> {
98 let (repo_id, config) = model_variant.model_info();
99 let model = Self::load_structure_head(repo_id, &config, &device)?;
100 eprintln!("ESMFold2Runner: loading ESMC-6B backbone (~12 GB)...");
101 let backbone = ESMCRunner::from_pretrained(ESMCModels::ESMC6B, device.clone())?;
102 eprintln!("ESMFold2Runner: backbone ready.");
103 Ok(Self { model, config, device, backbone: Some(backbone) })
104 }
105
106 fn load_structure_head(
107 repo_id: &str,
108 config: &ESMFold2Config,
109 device: &Device,
110 ) -> Result<ESMFold2Model> {
111 eprintln!("ESMFold2Runner: downloading structure head from {repo_id}...");
112 let (owner, name) = repo_id.split_once('/').unwrap_or(("", repo_id));
113 let client = HFClientSync::new()?;
114 let weights_path = client
115 .model(owner, name)
116 .download_file()
117 .filename("model.safetensors")
118 .send()?;
119 eprintln!("ESMFold2Runner: weights at {}", weights_path.display());
120
121 let vb = unsafe {
122 VarBuilder::from_mmaped_safetensors(&[&weights_path], ESMFOLD2_DTYPE, device)?
123 };
124 Ok(ESMFold2Model::load(vb, config.clone())?)
125 }
126
127 /// Fold a single protein sequence.
128 ///
129 /// # Arguments
130 /// * `sequence` — amino-acid string (standard one-letter codes)
131 /// * `num_loops` — recycling iterations (typically 3)
132 /// * `num_sampling_steps` — diffusion denoising steps (14 fast / 50 quality)
133 ///
134 /// When the runner was created with [`from_pretrained`][Self::from_pretrained]
135 /// (no backbone), hidden states are zeros and coordinates will be arbitrary.
136 /// Use [`from_pretrained_with_backbone`][Self::from_pretrained_with_backbone]
137 /// for real predictions.
138 pub fn fold_protein(
139 &self,
140 sequence: &str,
141 num_loops: usize,
142 num_sampling_steps: usize,
143 ) -> Result<ESMFold2Output> {
144 let l = sequence.len();
145 let device = &self.device;
146
147 // ESMC-6B backbone → (1, L, 2560).
148 // When backbone is None, use zeros (stub for shape testing).
149 let hidden_states = match &self.backbone {
150 Some(esmc) => {
151 // embed_sequence returns (1, L+2, d_model) with BOS and EOS tokens.
152 let embs = esmc.embed_sequence(sequence)?;
153 // Strip BOS (index 0) and EOS (index L+1), keeping L residues.
154 embs.narrow(1, 1, l)?
155 }
156 None => Tensor::zeros(
157 &[1, l, self.config.lm_d_model],
158 ESMFOLD2_DTYPE,
159 device,
160 )?,
161 };
162
163 let res_idx: Vec<f32> = (0..l).map(|i| i as f32).collect();
164 let residue_indices = Tensor::from_vec(res_idx, &[1, l], device)?;
165 let chain_ids = Tensor::zeros(&[1, l], ESMFOLD2_DTYPE, device)?;
166
167 Ok(self.model.forward(
168 &hidden_states,
169 &residue_indices,
170 &chain_ids,
171 num_loops,
172 num_sampling_steps,
173 )?)
174 }
175}
176
177// ── Tests ─────────────────────────────────────────────────────────────────────
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use candle_core::Device;
183
184 /// Shape smoke-test with stub backbone (no download needed).
185 /// Uses a tiny config so the test runs in milliseconds.
186 #[test]
187 fn test_fold_protein_stub_shapes() {
188 use super::super::config::ESMFold2Config;
189 use candle_nn::VarBuilder;
190 use candle_core::DType;
191
192 let device = Device::Cpu;
193 let cfg = ESMFold2Config {
194 lm_d_model: 64,
195 lm_encoder_n_layers: 1,
196 d_single: 32,
197 d_pair: 16,
198 trunk_n_layers: 1,
199 trunk_n_heads: 2,
200 c_token: 32,
201 c_atom: 16,
202 token_num_blocks: 1,
203 token_num_heads: 2,
204 fourier_dim: 16,
205 num_plddt_bins: 50,
206 num_pae_bins: 64,
207 num_pde_bins: 64,
208 distogram_bins: 39,
209 ..ESMFold2Config::fast()
210 };
211 let vb = VarBuilder::zeros(DType::F32, &device);
212 let model = ESMFold2Model::load(vb, cfg.clone()).unwrap();
213 let runner = ESMFold2Runner { model, config: cfg, device: device.clone(), backbone: None };
214
215 let seq = "ACDEFGHIK"; // 9 residues
216 let out = runner.fold_protein(seq, 1, 2).unwrap();
217
218 assert_eq!(out.sample_atom_coords.dims(), &[1, seq.len(), 3]);
219 assert_eq!(out.plddt.dims(), &[1, seq.len()]);
220 assert_eq!(out.ptm.dims(), &[1]);
221 assert_eq!(out.iptm.dims(), &[1]);
222 }
223
224 /// Integration test: downloads structure head (~755 MB) and folds ubiquitin.
225 /// The backbone is stubbed with zeros so coordinates are not meaningful,
226 /// but all shapes and the full pipeline must succeed.
227 ///
228 /// Run with:
229 /// cargo test -p ferritin-plms test_esmfold2_fold_stub_integration -- --ignored
230 #[test]
231 #[ignore = "requires downloading biohub/ESMFold2-Fast weights (~755 MB)"]
232 fn test_esmfold2_fold_stub_integration() {
233 let device = Device::Cpu;
234 let runner =
235 ESMFold2Runner::from_pretrained(ESMFold2Models::Fast, device).expect("load failed");
236
237 // Ubiquitin (76 aa)
238 let seq = "MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG";
239 let out = runner.fold_protein(seq, 1, 2).expect("fold_protein failed");
240
241 assert_eq!(out.sample_atom_coords.dims(), &[1, seq.len(), 3]);
242 assert_eq!(out.plddt.dims(), &[1, seq.len()]);
243 }
244
245 /// Full end-to-end integration: ESMC-6B backbone + structure head.
246 /// Folds ubiquitin and asserts pLDDT > 0.5 on average (backbone gives
247 /// real signal; exact quality depends on diffusion steps).
248 ///
249 /// Run with:
250 /// cargo test -p ferritin-plms test_esmfold2_fold_with_backbone -- --ignored
251 #[test]
252 #[ignore = "requires biohub/ESMFold2-Fast (~755 MB) + biohub/ESMC-6B (~12 GB)"]
253 fn test_esmfold2_fold_with_backbone() -> Result<()> {
254 let device = Device::Cpu;
255 let runner =
256 ESMFold2Runner::from_pretrained_with_backbone(ESMFold2Models::Fast, device)?;
257
258 let seq = "MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG";
259 let out = runner.fold_protein(seq, 3, 14)?;
260
261 assert_eq!(out.sample_atom_coords.dims(), &[1, seq.len(), 3]);
262 assert_eq!(out.plddt.dims(), &[1, seq.len()]);
263
264 let mean_plddt = out.plddt.mean_all()?.to_scalar::<f32>()?;
265 assert!(mean_plddt > 0.5, "expected mean pLDDT > 0.5, got {mean_plddt:.3}");
266
267 Ok(())
268 }
269}