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}