Skip to main content

ferritin_plms/esmc/
pretrained.rs

1//! ESMC pretrained model loading.
2//!
3//! Downloads weights from `biohub/ESMC-{300M,600M,6B}` on HuggingFace and
4//! wraps the ESMC model for sequence embedding.
5//!
6//! ## Weight key layout (`biohub/ESMC-*` safetensors)
7//!
8//! The weights are saved from `ESMCForMaskedLM`, a HuggingFace wrapper that
9//! nests the backbone under an `esmc` attribute. All backbone keys are
10//! therefore prefixed with `esmc.`:
11//!
12//! | Python (HF safetensors key)                              | Rust VarBuilder path                          |
13//! |----------------------------------------------------------|-----------------------------------------------|
14//! | `esmc.embed.weight`                                      | `embed.weight`                                |
15//! | `esmc.transformer.blocks.{i}.attn.layernorm_qkv.0.*`   | `transformer.blocks.{i}.attn.layernorm_qkv.0.*` |
16//! | `esmc.transformer.blocks.{i}.attn.layernorm_qkv.1.*`   | `transformer.blocks.{i}.attn.layernorm_qkv.1.*` |
17//! | `esmc.transformer.blocks.{i}.attn.out_proj.weight`      | `transformer.blocks.{i}.attn.out_proj.weight`   |
18//! | `esmc.transformer.blocks.{i}.attn.q_ln.weight`          | `transformer.blocks.{i}.attn.q_ln.weight`       |
19//! | `esmc.transformer.blocks.{i}.attn.k_ln.weight`          | `transformer.blocks.{i}.attn.k_ln.weight`       |
20//! | `esmc.transformer.blocks.{i}.attn.rotary.*`             | `transformer.blocks.{i}.attn.rotary.*`          |
21//! | `esmc.transformer.blocks.{i}.ffn.0.*`                   | `transformer.blocks.{i}.ffn.0.*`                |
22//! | `esmc.transformer.blocks.{i}.ffn.1.weight`              | `transformer.blocks.{i}.ffn.1.weight`           |
23//! | `esmc.transformer.blocks.{i}.ffn.3.weight`              | `transformer.blocks.{i}.ffn.3.weight`           |
24//! | `esmc.transformer.norm.weight`                           | `transformer.norm.weight`                       |
25//! | `esmc.sequence_head.0.weight/bias`                       | `sequence_head.0.weight/bias`                   |
26//! | `esmc.sequence_head.2.weight`                            | `sequence_head.2.weight`                        |
27//! | `esmc.sequence_head.3.weight/bias`                       | `sequence_head.3.weight/bias`                   |
28//!
29//! The `from_pretrained` loader auto-detects whether the `esmc.` prefix is
30//! present and sets the VarBuilder root accordingly, so the same Rust model
31//! code works against both the wrapped and unwrapped formats.
32
33use crate::esmc::models::esmc::{ESMC, ESMCConfig, LogitsConfig};
34use crate::loader::{LoadOptions, WeightSource, optional_prefix};
35use crate::plm_runner::{
36    ModelMetadata, PlmRunner, SpecialTokenLayout, pad_token_batch, zero_padded_rows,
37};
38use crate::registry::{self, ModelCard};
39use anyhow::{Result, bail};
40use candle_core::{Device, Tensor};
41
42/// Available ESMC model variants hosted on HuggingFace.
43pub enum ESMCModels {
44    /// ESMC 300M — 30 layers, d_model=960 (~1.3 GB weights)
45    ESMC300M,
46    /// ESMC 600M — 36 layers, d_model=1152
47    ESMC600M,
48    /// ESMC 6B — 80 layers, d_model=2560 (backbone for ESMFold2)
49    ESMC6B,
50}
51
52impl ESMCModels {
53    /// This variant's registry id.
54    pub const fn registry_id(&self) -> &'static str {
55        match self {
56            Self::ESMC300M => "esmc-300m",
57            Self::ESMC600M => "esmc-600m",
58            Self::ESMC6B => "esmc-6b",
59        }
60    }
61
62    /// This variant's [`ModelCard`].
63    pub fn card(&self) -> &'static ModelCard {
64        registry::lookup(self.registry_id())
65            .expect("every ESMCModels variant must have a registry entry")
66    }
67
68    /// Returns `(weight_source, filename, config)` for this variant.
69    ///
70    /// Repo and filename come from [`REGISTRY`][crate::registry::REGISTRY]
71    /// (ferritin-goh.1). Those point at the **EvolutionaryScale** originals,
72    /// not the `biohub/ESMC-*` re-exports this once used: those are
73    /// TransformerEngine-FUSED, so nothing this port asks for resolved
74    /// (ferritin-100.23).
75    ///
76    /// `ESMC6B` loads through its shard index and keeps its head at
77    /// `lm_head` rather than `sequence_head` (ferritin-100.24).
78    pub fn model_info(&self) -> Result<(WeightSource, &'static str, ESMCConfig)> {
79        let card = self.card();
80        if let Some(reason) = card.unsupported {
81            bail!("{} is not supported: {reason}", card.id);
82        }
83        let config = match self {
84            Self::ESMC300M => ESMCConfig::esmc_300m(),
85            Self::ESMC600M => ESMCConfig::esmc_600m(),
86            Self::ESMC6B => ESMCConfig::esmc_6b(),
87        };
88        Ok((card.source, card.file, config))
89    }
90}
91
92/// Wraps a loaded ESMC model for sequence embedding inference.
93pub struct ESMCRunner {
94    model: ESMC,
95    /// Retained so `PlmRunner::metadata` can report dimensions.
96    config: ESMCConfig,
97}
98
99impl ESMCRunner {
100    /// Download weights from HuggingFace and load the model.
101    ///
102    /// Handles the `ESMCForMaskedLM` wrapper prefix (`esmc.`) transparently:
103    /// probes for `esmc.embed.weight` in the safetensors and sets the
104    /// VarBuilder root to `vb.pp("esmc")` when found, otherwise uses the
105    /// flat (unwrapped) layout.
106    pub fn from_pretrained(model: ESMCModels, device: Device) -> Result<Self> {
107        Self::from_pretrained_with(model, &LoadOptions::new(device))
108    }
109
110    /// Load with an explicit device and dtype.
111    ///
112    /// `ESMC6B` is ~24 GB at F32 but ~12 GB at BF16 — its on-disk precision —
113    /// so reduced precision is the difference between loadable and not on a
114    /// 32 GB machine. See `tests/test_plm_dtype_parity.rs` for the numerical
115    /// cost (ferritin-100.9).
116    pub fn from_pretrained_with(model: ESMCModels, opts: &LoadOptions) -> Result<Self> {
117        let (source, filename, config) = model.model_info()?;
118        let vb = source.var_builder(filename, opts)?;
119        // Weights saved from ESMCForMaskedLM nest the backbone under "esmc".
120        // ESMC-6B nests its backbone under "esmc." but keeps its head at the
121        // top level as "lm_head"; 300M/600M are flat with "sequence_head".
122        // So the head is resolved from the ORIGINAL root, not the descended
123        // one (ferritin-100.24).
124        let head = if vb.contains_tensor("lm_head.0.weight") {
125            vb.pp("lm_head")
126        } else {
127            vb.pp("sequence_head")
128        };
129        let vb_root = optional_prefix(vb, "esmc", "embed.weight");
130        let esmc = ESMC::load_with_head(vb_root, head, config.clone())?;
131        Ok(Self {
132            model: esmc,
133            config,
134        })
135    }
136
137    /// Tokenize `sequence` and run a forward pass.
138    ///
139    /// Returns per-residue embeddings with shape `(1, L, d_model)` where
140    /// `L` includes the BOS and EOS tokens added by the tokenizer.
141    pub fn embed_sequence(&self, sequence: &str) -> Result<Tensor> {
142        let tokens = self.model.encode(sequence)?;
143        let tokens = tokens.unsqueeze(0)?;
144        let output = self.model.forward(&tokens, None, false)?;
145        output
146            .embeddings
147            .ok_or_else(|| anyhow::anyhow!("ESMC forward() returned no embeddings"))
148    }
149}
150
151impl PlmRunner for ESMCRunner {
152    /// Delegate to `embed_sequence`, returning per-residue embeddings `(1, L, d_model)`.
153    fn embed(&self, sequence: &str) -> Result<Tensor> {
154        self.embed_sequence(sequence)
155    }
156
157    fn model_name(&self) -> &str {
158        "esmc"
159    }
160
161    /// `ESMC::encode` calls `tokenize_sequence(.., true)`, which wraps the
162    /// sequence in BOS and EOS.
163    fn special_tokens(&self) -> SpecialTokenLayout {
164        SpecialTokenLayout::BOS_EOS
165    }
166
167    fn metadata(&self) -> ModelMetadata {
168        ModelMetadata {
169            d_model: self.config.d_model,
170            n_layers: self.config.n_layers,
171            vocab_size: self.config.embedding_dim,
172            // Rotary positions: no hard architectural cap.
173            max_positions: None,
174        }
175    }
176
177    fn device(&self) -> &Device {
178        self.model.device()
179    }
180
181    /// Sequence logits `(1, L + 2, vocab_size)`.
182    fn logits(&self, sequence: &str) -> Result<Tensor> {
183        let tokens = self.model.encode(sequence)?.unsqueeze(0)?;
184        let out = self.model.logits(
185            &tokens,
186            LogitsConfig {
187                sequence: true,
188                return_embeddings: false,
189                return_hidden_states: false,
190            },
191        )?;
192        out.sequence_logits
193            .ok_or_else(|| anyhow::anyhow!("ESMC logits() returned no sequence logits"))
194    }
195
196    /// One batched forward pass over right-padded sequences (ferritin-100.12).
197    ///
198    /// ESM-C's `sequence_id` is a `where_cond` predicate — `1` keeps a key,
199    /// `0` replaces its score with `-inf` — so the `U8` mask goes in as-is.
200    /// It is passed explicitly rather than relying on `ESMC::forward`'s
201    /// fallback (`tokens.ne(pad_id)`), which would happen to agree here but
202    /// ties correctness to the padding id rather than to the batch itself.
203    fn embed_batch(&self, sequences: &[&str]) -> Result<Tensor> {
204        let rows: Vec<Vec<u32>> = sequences.iter().map(|s| self.model.tokenize(s)).collect();
205        let batch = pad_token_batch(&rows, self.model.pad_token_id(), self.model.device())?;
206        let output = self.model.forward(&batch.ids, Some(&batch.mask), false)?;
207        let embeddings = output
208            .embeddings
209            .ok_or_else(|| anyhow::anyhow!("ESMC forward() returned no embeddings"))?;
210        zero_padded_rows(&embeddings, &batch.mask)
211    }
212}