Skip to main content

ferritin_plms/esm3/models/
vqvae.rs

1//! ESM3 VQ-VAE structure token encoder and decoder (vqvae.py port).
2//!
3//! `StructureTokenEncoder` maps backbone coordinates to discrete structure
4//! tokens. It is a *local* encoder: for every residue it runs a 2-layer
5//! geometric transformer over that residue's 16 nearest neighbours in space,
6//! then quantizes the query node's output against a 4096-entry VQ codebook
7//! (ferritin-100.22).
8//!
9//! `StructureTokenDecoder` is stubbed (not needed for ESM3 inference).
10
11use crate::esm3::layers::relative_position::RelativePositionEmbedding;
12use crate::esm3::layers::transformer_stack::TransformerStack;
13use crate::esm3::models::esm3::ESM3Config;
14use crate::esm3::utils::affine3d::Affine3D;
15use candle_core::{D, DType, Module, Result, Tensor};
16use candle_nn::{self as nn, VarBuilder};
17
18// ── VQ-VAE config ─────────────────────────────────────────────────────────────
19
20#[derive(Debug, Clone)]
21pub struct VqVaeConfig {
22    // Encoder mini-transformer
23    pub enc_d_model: usize,  // 1024
24    pub enc_n_heads: usize,  // 1
25    pub enc_v_heads: usize,  // 128
26    pub enc_n_layers: usize, // 2
27    // Codebook
28    pub d_codebook: usize, // 128 (projected dimension before VQ)
29    pub n_codes: usize,    // 4096
30    // Decoder (kept for future use; not loaded in MVP)
31    pub dec_d_model: usize,  // 1280
32    pub dec_n_heads: usize,  // 20
33    pub dec_n_layers: usize, // 30
34}
35
36impl Default for VqVaeConfig {
37    fn default() -> Self {
38        Self {
39            enc_d_model: 1024,
40            enc_n_heads: 1,
41            enc_v_heads: 128,
42            enc_n_layers: 2,
43            d_codebook: 128,
44            n_codes: 4096,
45            dec_d_model: 1280,
46            dec_n_heads: 20,
47            dec_n_layers: 30,
48        }
49    }
50}
51
52impl VqVaeConfig {
53    /// Build an `ESM3Config` for the encoder TransformerStack.
54    ///
55    /// All layers use geometric attention; vocab sizes are unused and zeroed.
56    fn encoder_esm3_config(&self) -> ESM3Config {
57        ESM3Config {
58            d_model: self.enc_d_model,
59            n_heads: self.enc_n_heads,
60            n_layers: self.enc_n_layers,
61            n_layers_geom: self.enc_n_layers, // every layer uses geometric attention
62            v_head_transformer: self.enc_v_heads,
63            // 4, not the main model's 8/3: with the 256-rounding correction
64            // this gives an FFN hidden width of exactly 4096, matching the
65            // checkpoint's ffn.1.weight [8192, 1024] (gated, so 2x4096) and
66            // ffn.3.weight [1024, 4096]. The 8/3 used here before produced
67            // 2816 and failed to load (ferritin-100.22).
68            expansion_ratio: 4.0,
69            scale_residue: false,
70            mask_and_zero_frameless: true,
71            qk_layernorm: true,
72            // esm3_structure_encoder_v0.pth stores geom_attn with bias terms
73            // (s_norm.bias, proj.bias, out_proj.bias), unlike the main model
74            // (ferritin-100.21).
75            bias: true,
76            // Vocab/embedding sizes unused by TransformerStack
77            d_sequence_vocab: 0,
78            d_structure_vocab: self.n_codes,
79            d_ss8_vocab: 0,
80            d_sasa_vocab: 0,
81            n_function_tracks: 0,
82            d_function_vocab: 0,
83            d_residue_vocab: 0,
84            n_rbf_bins: 0,
85        }
86    }
87}
88
89// ── Inference VQ codebook ─────────────────────────────────────────────────────
90
91/// Inference-only VQ codebook: loads the `(n_codes, d_codebook)` embedding table and
92/// performs nearest-neighbour quantization via L2 distances.
93struct VqCodebook {
94    embeddings: Tensor, // (n_codes, d_codebook)
95}
96
97impl VqCodebook {
98    pub fn load(vb: VarBuilder, n_codes: usize, d_codebook: usize) -> Result<Self> {
99        let embeddings = vb.get((n_codes, d_codebook), "embeddings")?;
100        Ok(Self { embeddings })
101    }
102
103    /// Quantize `z` to the nearest codebook entry.
104    ///
105    /// `z`: `(B, L, d_codebook)`.
106    /// Returns `(B, L)` u32 structure token indices.
107    pub fn quantize(&self, z: &Tensor) -> Result<Tensor> {
108        let (b, l, d) = z.dims3()?;
109        let z_flat = z.reshape((b * l, d))?; // (B*L, d)
110
111        // ||z - e||^2 = ||z||^2 - 2*(z @ e^T) + ||e||^2
112        let z_sq = z_flat.sqr()?.sum_keepdim(1)?; // (B*L, 1)
113        let z_et = z_flat.matmul(&self.embeddings.transpose(0, 1)?)?; // (B*L, n_codes)
114        let e_sq = self.embeddings.sqr()?.sum_keepdim(1)?.transpose(0, 1)?; // (1, n_codes)
115
116        // distances: (B*L, n_codes)
117        let distances = z_sq
118            .broadcast_sub(&z_et.affine(2.0, 0.0)?)?
119            .broadcast_add(&e_sq)?;
120
121        let indices = distances.argmin(1)?; // (B*L,) u32
122        indices.reshape((b, l))
123    }
124}
125
126// ── k-nearest-neighbour graph ─────────────────────────────────────────────────
127
128/// Neighbours per residue in the structure encoder's local graph.
129pub const KNN: usize = 16;
130
131/// Distance used to push invalid residues to the end of every neighbour list.
132///
133/// Finite rather than infinite: these entries are still gathered and run
134/// through the transformer (they are masked there), and an infinity would
135/// propagate NaN through the frame arithmetic instead of being ignored.
136const FAR: f64 = 1e9;
137
138/// Build the k-nearest-neighbour edge list over CA positions.
139///
140/// * `ca`    — `(B, L, 3)` CA coordinates.
141/// * `valid` — `(B, L)` 1 where the residue has a usable backbone frame.
142///
143/// Returns `(B, L, KNN)` `u32` neighbour indices, sorted by increasing
144/// distance. Because a residue is at distance zero from itself, **index 0 of
145/// each row is the residue itself** — the encoder relies on this to pick the
146/// query node back out after the transformer.
147///
148/// Residues with no frame are pushed to the end of every list rather than
149/// removed, so the edge tensor stays rectangular.
150fn knn_edges(ca: &Tensor, valid: &Tensor, knn: usize) -> Result<Tensor> {
151    let (b, l, _) = ca.dims3()?;
152    let knn = knn.min(l);
153
154    // Squared distances via ||a||^2 - 2 a·b + ||b||^2.
155    let sq = ca.sqr()?.sum_keepdim(D::Minus1)?; // (B, L, 1)
156    let cross = ca.matmul(&ca.transpose(1, 2)?)?; // (B, L, L)
157    let d2 = (sq.broadcast_add(&sq.transpose(1, 2)?)? - cross.affine(2.0, 0.0)?)?;
158
159    // Invalid residues become unreachable columns.
160    let invalid = valid
161        .to_dtype(d2.dtype())?
162        .affine(-1.0, 1.0)? // 1 - valid
163        .reshape((b, 1, l))?
164        .broadcast_as((b, l, l))?;
165    let d2 = (d2 + invalid.affine(FAR, 0.0)?)?;
166
167    let order = d2.arg_sort_last_dim(true)?; // ascending
168    order.narrow(D::Minus1, 0, knn)?.contiguous()
169}
170
171/// Gather one value per edge.
172///
173/// `src` is `(B, L, D)` and `edges` is `(B, L, K)`; the result is
174/// `(B, L, K, D)` — for each residue, the `D`-vectors of its `K` neighbours.
175fn node_gather(src: &Tensor, edges: &Tensor) -> Result<Tensor> {
176    let (b, l, d) = src.dims3()?;
177    let k = edges.dim(D::Minus1)?;
178    // gather along the residue axis wants an index of the same rank as `src`,
179    // so flatten the (L, K) grid into one axis and widen it across D.
180    let idx = edges
181        .reshape((b, l * k, 1))?
182        .broadcast_as((b, l * k, d))?
183        .contiguous()?;
184    src.contiguous()?.gather(&idx, 1)?.reshape((b, l, k, d))
185}
186
187// ── StructureTokenEncoder ─────────────────────────────────────────────────────
188
189/// Encodes backbone coordinates into discrete structure tokens via a 2-layer geometric
190/// mini-transformer followed by nearest-neighbour VQ codebook lookup.
191///
192/// Weight layout (in encoder checkpoint):
193/// - `encoder.blocks.*`   — transformer
194/// - `pre_vq_proj.weight` — `(d_codebook, enc_d_model)`
195/// - `codebook.embeddings`— `(n_codes, d_codebook)`
196pub struct StructureTokenEncoder {
197    transformer: TransformerStack,
198    relative_positional_embedding: RelativePositionEmbedding,
199    pre_vq_proj: nn::Linear,
200    codebook: VqCodebook,
201    config: VqVaeConfig,
202}
203
204impl StructureTokenEncoder {
205    /// Load from `esm3_structure_encoder_v0.pth`.
206    ///
207    /// Every prefix here was read off the real checkpoint rather than inferred
208    /// from the reference source: it roots at `transformer` (not `encoder`),
209    /// and `pre_vq_proj` carries a bias. Both were wrong before, and both
210    /// would have failed loudly — unlike the missing relative position
211    /// embedding, which simply left the encoder computing from zeros.
212    pub fn load(vb: VarBuilder, config: VqVaeConfig) -> Result<Self> {
213        let enc_cfg = config.encoder_esm3_config();
214        let transformer = TransformerStack::load_geometric_encoder(vb.pp("transformer"), &enc_cfg)?;
215        let relative_positional_embedding = RelativePositionEmbedding::load(
216            vb.pp("relative_positional_embedding"),
217            config.enc_d_model,
218        )?;
219        let pre_vq_proj = nn::linear(config.enc_d_model, config.d_codebook, vb.pp("pre_vq_proj"))?;
220        let codebook = VqCodebook::load(vb.pp("codebook"), config.n_codes, config.d_codebook)?;
221        Ok(Self {
222            transformer,
223            relative_positional_embedding,
224            pre_vq_proj,
225            codebook,
226            config,
227        })
228    }
229
230    /// Encode backbone coordinates into structure tokens.
231    ///
232    /// * `coords` — `(B, L, 3, 3)` backbone `(N, CA, C)` positions.
233    /// * `sequence_id` — optional `(B, L)` bin-packing ids.
234    ///
235    /// Returns `(B, L)` `u32` structure token indices.
236    ///
237    /// # Why this is not a sequence transformer
238    ///
239    /// This encoder is **local**. It does not run the transformer over the
240    /// chain; it runs it over each residue's 16 nearest neighbours in space,
241    /// as `(B*L, 16, d_model)`. The earlier port ran a full-sequence pass from
242    /// a zero hidden state, which is a different computation entirely — the
243    /// weights would load (once the prefixes were right) and the output would
244    /// be quietly meaningless.
245    pub fn encode(&self, coords: &Tensor, sequence_id: Option<&Tensor>) -> Result<Tensor> {
246        let (b, l, _, _) = coords.dims4()?;
247        let device = coords.device();
248        let dtype = coords.dtype();
249
250        let (affine, affine_mask) = Affine3D::build_affine3d_from_coordinates(coords)?;
251
252        // Neighbourhoods, in distance order: column 0 is the residue itself.
253        let ca = coords.narrow(D::Minus2, 1, 1)?.squeeze(D::Minus2)?; // (B, L, 3)
254        let edges = knn_edges(&ca, &affine_mask, KNN)?; // (B, L, K)
255        let k = edges.dim(D::Minus1)?;
256
257        // Gather each neighbourhood's frames and masks, flattening the residue
258        // axis into the batch so the transformer sees B*L sequences of K.
259        let rot =
260            node_gather(&affine.rot.reshape((b, l, 9))?, &edges)?.reshape((b * l, k, 3, 3))?;
261        let trans = node_gather(&affine.trans, &edges)?.reshape((b * l, k, 3))?;
262        let knn_affine = Affine3D::new(rot, trans);
263
264        let knn_affine_mask =
265            node_gather(&affine_mask.to_dtype(dtype)?.unsqueeze(D::Minus1)?, &edges)?
266                .reshape((b * l, k))?
267                .to_dtype(affine_mask.dtype())?;
268
269        let knn_sequence_id = match sequence_id {
270            Some(sid) => node_gather(&sid.to_dtype(dtype)?.unsqueeze(D::Minus1)?, &edges)?
271                .reshape((b * l, k))?
272                .to_dtype(sid.dtype())?,
273            None => Tensor::zeros((b * l, k), DType::U32, device)?,
274        };
275        let chain_id = Tensor::zeros((b * l, k), DType::U32, device)?;
276
277        // The initial hidden state is the sequence offset to each neighbour —
278        // there is no token embedding in this encoder.
279        let res_idxs = edges.reshape((b * l, k))?;
280        let centre = res_idxs.narrow(D::Minus1, 0, 1)?.squeeze(D::Minus1)?;
281        let z = self
282            .relative_positional_embedding
283            .forward(&centre, &res_idxs)?
284            .to_dtype(dtype)?;
285
286        let (z, _pre_norm) = self.transformer.forward(
287            &z,
288            Some(&knn_sequence_id),
289            Some(&knn_affine),
290            Some(&knn_affine_mask),
291            Some(&chain_id),
292        )?;
293
294        // Take the query node back out: neighbours are distance-sorted, so it
295        // is column 0.
296        let z = z
297            .reshape((b, l, k, self.config.enc_d_model))?
298            .narrow(2, 0, 1)?
299            .squeeze(2)?;
300
301        let z = self.pre_vq_proj.forward(&z)?;
302        self.codebook.quantize(&z)
303    }
304}
305
306// ── StructureTokenDecoder (stub) ──────────────────────────────────────────────
307
308/// Stub for the structure token decoder. Not needed for ESM3 inference.
309///
310/// The decoder (d_model=1280, n_heads=20, n_layers=30) maps structure tokens back to
311/// coordinates, but ESM3 inference only needs the encoder.
312pub struct StructureTokenDecoder;
313
314impl StructureTokenDecoder {
315    pub fn stub() -> Self {
316        Self
317    }
318}
319
320// ── Tests ─────────────────────────────────────────────────────────────────────
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use candle_core::{Device, Tensor};
326
327    #[test]
328    fn test_vq_codebook_quantize_shape() -> Result<()> {
329        let device = &Device::Cpu;
330        let n_codes = 16usize;
331        let d = 8usize;
332        let b = 2usize;
333        let l = 5usize;
334
335        let embeddings = Tensor::randn(0f32, 1f32, (n_codes, d), device)?;
336        let codebook = VqCodebook { embeddings };
337
338        let z = Tensor::randn(0f32, 1f32, (b, l, d), device)?;
339        let tokens = codebook.quantize(&z)?;
340
341        assert_eq!(tokens.shape().dims(), &[b, l]);
342        Ok(())
343    }
344
345    #[test]
346    fn test_vq_codebook_nearest_neighbour() -> Result<()> {
347        let device = &Device::Cpu;
348        // Two codebook entries: [1,0] and [-1,0]
349        let embeddings = Tensor::new(&[[1f32, 0.], [-1., 0.]], device)?;
350        let codebook = VqCodebook { embeddings };
351
352        // z close to code 0 ([1,0]): should map to index 0
353        let z = Tensor::new(&[[[0.9f32, 0.1]]], device)?; // (1,1,2)
354        let tokens = codebook.quantize(&z)?;
355        assert_eq!(tokens.to_vec2::<u32>()?, vec![vec![0u32]]);
356
357        // z close to code 1 ([-1,0]): should map to index 1
358        let z = Tensor::new(&[[[-0.8f32, 0.1]]], device)?;
359        let tokens = codebook.quantize(&z)?;
360        assert_eq!(tokens.to_vec2::<u32>()?, vec![vec![1u32]]);
361        Ok(())
362    }
363
364    #[test]
365    fn test_vqvae_config_encoder_esm3_config() {
366        let cfg = VqVaeConfig::default();
367        let enc = cfg.encoder_esm3_config();
368        assert_eq!(enc.d_model, 1024);
369        assert_eq!(enc.n_heads, 1);
370        assert_eq!(enc.n_layers, 2);
371        assert_eq!(
372            enc.n_layers_geom, 2,
373            "all layers should use geometric attention"
374        );
375        assert_eq!(enc.v_head_transformer, 128);
376        assert!(!enc.scale_residue);
377        assert!(enc.mask_and_zero_frameless);
378    }
379}
380
381#[cfg(test)]
382mod encoder_tests {
383    use super::*;
384    use candle_core::Device;
385
386    /// Ideal alpha-helix backbone: rise 1.5 A, 100 degrees per residue.
387    ///
388    /// Real secondary structure rather than a straight line, so the geometric
389    /// attention has something to distinguish.
390    fn helix(l: usize) -> Result<Tensor> {
391        let (r, rise, turn) = (2.3f64, 1.5f64, 100f64.to_radians());
392        let mut v: Vec<f32> = Vec::with_capacity(l * 9);
393        for i in 0..l {
394            for (k, off) in [(-1.0f64), 0.0, 1.0].iter().enumerate() {
395                let _ = k;
396                let t = (i as f64 + off * 0.35) * turn;
397                let z = (i as f64 + off * 0.35) * rise;
398                v.extend_from_slice(&[(r * t.cos()) as f32, (r * t.sin()) as f32, z as f32]);
399            }
400        }
401        Tensor::from_vec(v, (1, l, 3, 3), &Device::Cpu)
402    }
403
404    /// Column 0 of every neighbour list is the residue itself.
405    ///
406    /// The encoder pulls the query node back out by taking index 0 after the
407    /// transformer, so if this ordering broke, every residue would silently be
408    /// tokenized as one of its neighbours.
409    #[test]
410    fn test_first_neighbour_is_the_residue_itself() -> Result<()> {
411        let coords = helix(12)?;
412        let ca = coords.narrow(D::Minus2, 1, 1)?.squeeze(D::Minus2)?;
413        let valid = Tensor::ones((1, 12), DType::U8, &Device::Cpu)?;
414        let edges = knn_edges(&ca, &valid, KNN)?;
415        let first = edges
416            .narrow(D::Minus1, 0, 1)?
417            .flatten_all()?
418            .to_vec1::<u32>()?;
419        assert_eq!(
420            first,
421            (0..12).collect::<Vec<u32>>(),
422            "a residue is at distance zero from itself, so it must sort first"
423        );
424        Ok(())
425    }
426
427    /// Neighbour lists are ordered by increasing distance.
428    #[test]
429    fn test_neighbours_are_distance_sorted() -> Result<()> {
430        let coords = helix(20)?;
431        let ca = coords.narrow(D::Minus2, 1, 1)?.squeeze(D::Minus2)?;
432        let valid = Tensor::ones((1, 20), DType::U8, &Device::Cpu)?;
433        let edges = knn_edges(&ca, &valid, KNN)?
434            .flatten_all()?
435            .to_vec1::<u32>()?;
436        let ca_v = ca.flatten_all()?.to_vec1::<f32>()?;
437        let k = KNN.min(20);
438        for i in 0..20 {
439            let mut prev = -1.0f32;
440            for j in 0..k {
441                let n = edges[i * k + j] as usize;
442                let d: f32 = (0..3)
443                    .map(|c| (ca_v[i * 3 + c] - ca_v[n * 3 + c]).powi(2))
444                    .sum();
445                assert!(
446                    d >= prev - 1e-4,
447                    "neighbour {j} of residue {i} is out of order"
448                );
449                prev = d;
450            }
451        }
452        Ok(())
453    }
454
455    /// A residue with no valid backbone frame is pushed to the end of every
456    /// neighbour list rather than being chosen as a near neighbour.
457    #[test]
458    fn test_invalid_residues_are_not_selected_as_neighbours() -> Result<()> {
459        let coords = helix(20)?;
460        let ca = coords.narrow(D::Minus2, 1, 1)?.squeeze(D::Minus2)?;
461        // Mark residue 1 invalid; it is spatially adjacent to residue 0.
462        let mut mask = vec![1u8; 20];
463        mask[1] = 0;
464        let valid = Tensor::from_vec(mask, (1, 20), &Device::Cpu)?;
465        let edges = knn_edges(&ca, &valid, KNN)?
466            .flatten_all()?
467            .to_vec1::<u32>()?;
468        let k = KNN.min(20);
469        // Residue 0's nearest few neighbours should skip the invalid residue 1.
470        assert!(
471            !edges[0..k / 2].contains(&1),
472            "an invalid residue must not rank as a near neighbour: {:?}",
473            &edges[0..k]
474        );
475        Ok(())
476    }
477
478    #[test]
479    fn test_node_gather_selects_the_named_rows() -> Result<()> {
480        // (1, 4, 2): row i is [i, i*10].
481        let src = Tensor::from_vec(
482            vec![0f32, 0., 1., 10., 2., 20., 3., 30.],
483            (1, 4, 2),
484            &Device::Cpu,
485        )?;
486        // One row per residue: 4 residues, 2 neighbours each.
487        let edges = Tensor::from_vec(vec![2u32, 0, 3, 1, 0, 3, 1, 2], (1, 4, 2), &Device::Cpu)?;
488        let out = node_gather(&src, &edges)?;
489        assert_eq!(out.dims(), &[1, 4, 2, 2]);
490        assert_eq!(
491            out.flatten_all()?.to_vec1::<f32>()?,
492            vec![
493                2., 20., 0., 0., // residue 0 gathers rows 2 and 0
494                3., 30., 1., 10., // residue 1 gathers rows 3 and 1
495                0., 0., 3., 30., // residue 2 gathers rows 0 and 3
496                1., 10., 2., 20., // residue 3 gathers rows 1 and 2
497            ]
498        );
499        Ok(())
500    }
501}