Skip to main content

ferritin_plms/ligandmpnn/
model.rs

1//! A message passing protein design neural network
2//! that samples sequences diffusing conditional probabilities.
3//!
4//! - See the [LigandMPNN Repo](https://github.com/dauparas/LigandMPNN)
5//!
6
7// The layer stacks fold a `Result` accumulator over the encoder/decoder layers;
8// the explicit `fold(Ok(..), ..)` reads more clearly than `try_fold` here.
9#![allow(clippy::manual_try_fold)]
10
11use super::configs::{ModelTypes, ProteinMPNNConfig};
12use super::ligandfeaturesmodel::ProteinFeaturesLigand;
13use super::proteinfeatures::ProteinFeatures;
14use super::proteinfeaturesmodel::ProteinFeaturesModel;
15use crate::featurize::utilities::{cat_neighbors_nodes, gather_nodes, int_to_aa1, linear_last_dim};
16use crate::types::PseudoProbability;
17use candle_core::safetensors;
18use candle_core::{D, DType, Device, IndexOp, Module, Result, Tensor};
19use candle_nn::encoding::one_hot;
20use candle_nn::ops::{log_softmax, softmax};
21use candle_nn::{Dropout, Embedding, LayerNorm, Linear, VarBuilder, embedding, layer_norm, linear};
22use candle_transformers::generation::LogitsProcessor;
23use std::collections::HashMap;
24
25// refactoring common fn
26fn concat_node_tensors(h_v: &Tensor, h_e: &Tensor, e_idx: &Tensor) -> Result<Tensor> {
27    let h_ev = cat_neighbors_nodes(h_v, h_e, e_idx)?;
28    let h_v_expand = h_v.unsqueeze(D::Minus2)?;
29    let expand_shape = [
30        h_ev.dims()[0],
31        h_ev.dims()[1],
32        h_ev.dims()[2],
33        h_v_expand.dims()[3],
34    ];
35    let h_v_expand = h_v_expand.expand(&expand_shape)?.to_dtype(h_ev.dtype())?;
36    Tensor::cat(&[&h_v_expand, &h_ev], D::Minus1)?.contiguous()
37}
38// refactoring common fn
39fn apply_dropout_and_norm(
40    input: &Tensor,
41    delta: &Tensor,
42    dropout: &Dropout,
43    norm: &LayerNorm,
44    training: bool,
45) -> Result<Tensor> {
46    let delta_dropout = dropout.forward(delta, training)?;
47    norm.forward(&(input + delta_dropout)?)
48}
49
50pub fn multinomial_sample(probs: &Tensor, temperature: f64, seed: u64) -> Result<Tensor> {
51    let mut logits_processor = LogitsProcessor::new(
52        seed,              // seed for reproducibility
53        Some(temperature), // temperature scaling
54        // None,              // top_p (nucleus sampling), we don't need this
55        Some(0.95), // top_p (nucleus sampling), we don't need this
56    );
57    let idx = logits_processor.sample(probs)?;
58    // println!("Selected index: {}", idx);
59    if idx >= 21 {
60        println!("WARNING: Invalid index {} selected", idx);
61    }
62    Tensor::new(&[idx], probs.device())
63}
64
65// Primary Return Object from the ProtMPNN Model
66#[derive(Clone, Debug)]
67pub struct ScoreOutput {
68    // Sequence
69    pub(crate) s: Tensor,
70    pub(crate) log_probs: Tensor,
71    pub(crate) logits: Tensor,
72    pub(crate) decoding_order: Tensor,
73}
74///  Score dims are [Batch, seqlength]
75impl ScoreOutput {
76    pub fn get_sequences(&self) -> Result<Vec<String>> {
77        let (b, l) = self.s.dims2()?;
78        let mut sequences = Vec::with_capacity(b);
79        for batch_idx in 0..b {
80            let batch = self.s.get(batch_idx)?;
81            let mut sequence = String::with_capacity(l);
82            for pos in 0..l {
83                let aa_idx = batch.get(pos)?.to_vec0::<u32>()?;
84                sequence.push(int_to_aa1(aa_idx));
85            }
86            sequences.push(sequence);
87        }
88        Ok(sequences)
89    }
90    pub fn get_decoding_order(&self) -> Result<Vec<u32>> {
91        let values = self.decoding_order.flatten_all()?.to_vec1::<u32>()?;
92        Ok(values)
93    }
94    pub fn get_log_probs(&self) -> &Tensor {
95        &self.log_probs
96    }
97    pub fn get_pseudo_probabilities(&self) -> Result<Vec<PseudoProbability>> {
98        let (batch_size, seq_len, _vocab_size) = self.logits.dims3()?;
99        let mut all_probabilities = Vec::with_capacity(batch_size * seq_len);
100
101        for batch_idx in 0..batch_size {
102            let batch_logits = self.logits.get(batch_idx)?;
103
104            for pos in 0..seq_len {
105                let pos_logits = batch_logits.get(pos)?;
106                let probs = softmax(&pos_logits, 0)?;
107                let probs = probs.to_vec1::<f32>()?;
108
109                // Get the decoding order to determine the actual position
110                let actual_pos = if let Ok(order) = self
111                    .decoding_order
112                    .get(batch_idx)?
113                    .get(pos)?
114                    .to_scalar::<u32>()
115                {
116                    order as usize
117                } else {
118                    pos
119                };
120
121                #[allow(clippy::needless_range_loop)] // aa_idx also names the output amino acid
122                for aa_idx in 0..probs.len() {
123                    if probs[aa_idx] > 0.01 {
124                        // Only include probabilities above threshold
125                        all_probabilities.push(PseudoProbability {
126                            position: actual_pos,
127                            pseudo_prob: probs[aa_idx],
128                            amino_acid: int_to_aa1(aa_idx as u32),
129                        });
130                    }
131                }
132            }
133        }
134        // Sort by position and then by probability (descending)
135        all_probabilities.sort_by(|a, b| {
136            a.position.cmp(&b.position).then(
137                b.pseudo_prob
138                    .partial_cmp(&a.pseudo_prob)
139                    .unwrap_or(std::cmp::Ordering::Equal),
140            )
141        });
142        Ok(all_probabilities)
143    }
144    pub fn save_as_safetensors(&self, filename: String) -> Result<()> {
145        let mut tensors = HashMap::new();
146        tensors.insert("S".to_string(), self.s.clone());
147        tensors.insert("log_probs".to_string(), self.log_probs.clone());
148        tensors.insert("logits".to_string(), self.logits.clone());
149        tensors.insert("decoding_order".to_string(), self.decoding_order.clone());
150        // Create directory if it doesn't exist
151        if let Some(parent) = std::path::Path::new(&filename).parent() {
152            std::fs::create_dir_all(parent)?;
153        }
154        let _ = safetensors::save(&tensors, &filename);
155        Ok(())
156    }
157}
158
159#[derive(Clone, Debug)]
160struct PositionWiseFeedForward {
161    w1: Linear,
162    w2: Linear,
163}
164
165impl PositionWiseFeedForward {
166    fn new(vb: VarBuilder, dim_input: usize, dim_feedforward: usize) -> Result<Self> {
167        let w1 = linear::linear(dim_input, dim_feedforward, vb.pp("W_in"))?;
168        let w2 = linear::linear(dim_feedforward, dim_input, vb.pp("W_out"))?;
169        Ok(Self { w1, w2 })
170    }
171}
172
173impl PositionWiseFeedForward {
174    /// [`Module::forward`] for inputs above rank 3.
175    ///
176    /// `DecLayerJ` feeds this `(B, L, M, C)`, which `Linear::forward` cannot
177    /// matmul directly (ferritin-100.11).
178    fn forward_any_rank(&self, x: &Tensor) -> Result<Tensor> {
179        let h = linear_last_dim(&self.w1, x)?.gelu_erf()?;
180        linear_last_dim(&self.w2, &h)
181    }
182}
183
184impl Module for PositionWiseFeedForward {
185    /// `gelu_erf`, not `gelu`.
186    ///
187    /// candle's `Tensor::gelu` is the tanh approximation; `torch.nn.GELU()`
188    /// defaults to `approximate="none"`, the exact erf form. Every activation
189    /// in this module used the approximation, which left ~1e-3 of drift per
190    /// layer against the reference (ferritin-100.11).
191    fn forward(&self, x: &Tensor) -> Result<Tensor> {
192        self.w1
193            .forward(x)?
194            .gelu_erf()
195            .and_then(|x| self.w2.forward(&x))
196    }
197}
198
199#[derive(Clone, Debug)]
200#[allow(dead_code)]
201pub struct EncLayer {
202    num_hidden: usize,
203    num_in: usize,
204    scale: f64,
205    dropout1: Dropout,
206    dropout2: Dropout,
207    dropout3: Dropout,
208    norm1: LayerNorm,
209    norm2: LayerNorm,
210    norm3: LayerNorm,
211    w1: Linear,
212    w2: Linear,
213    w3: Linear,
214    w11: Linear,
215    w12: Linear,
216    w13: Linear,
217    dense: PositionWiseFeedForward,
218}
219
220impl EncLayer {
221    pub fn load(vb: VarBuilder, config: &ProteinMPNNConfig, layer: i32) -> Result<Self> {
222        let vb = vb.pp(layer); // handle the layer number here.
223        let num_hidden = config.hidden_dim as usize;
224        let augment_eps = 1e-5f64;
225        let num_in = (config.hidden_dim * 2) as usize;
226        let dropout_ratio = config.dropout_ratio;
227
228        // Create layer norms
229        let norm1 = layer_norm(num_hidden, augment_eps, vb.pp("norm1"))?;
230        let norm2 = layer_norm(num_hidden, augment_eps, vb.pp("norm2"))?;
231        let norm3 = layer_norm(num_hidden, augment_eps, vb.pp("norm3"))?;
232
233        // Create linear layers
234        let w1 = linear(num_hidden + num_in, num_hidden, vb.pp("W1"))?;
235        let w2 = linear(num_hidden, num_hidden, vb.pp("W2"))?;
236        let w3 = linear(num_hidden, num_hidden, vb.pp("W3"))?;
237        let w11 = linear(num_hidden + num_in, num_hidden, vb.pp("W11"))?;
238        let w12 = linear(num_hidden, num_hidden, vb.pp("W12"))?;
239        let w13 = linear(num_hidden, num_hidden, vb.pp("W13"))?;
240
241        // Create dropouts with same ratio
242        let dropout1 = Dropout::new(dropout_ratio);
243        let dropout2 = Dropout::new(dropout_ratio);
244        let dropout3 = Dropout::new(dropout_ratio);
245
246        let dense = PositionWiseFeedForward::new(vb.pp("dense"), num_hidden, num_hidden * 4)?;
247
248        Ok(Self {
249            num_hidden,
250            num_in,
251            scale: config.scale_factor,
252            dropout1,
253            dropout2,
254            dropout3,
255            norm1,
256            norm2,
257            norm3,
258            w1,
259            w2,
260            w3,
261            w11,
262            w12,
263            w13,
264            dense,
265        })
266    }
267    fn forward(
268        &self,
269        h_v: &Tensor,
270        h_e: &Tensor,
271        e_idx: &Tensor,
272        mask_v: Option<&Tensor>,
273        mask_attend: Option<&Tensor>,
274        training: Option<bool>,
275    ) -> Result<(Tensor, Tensor)> {
276        let training = training.unwrap_or(false);
277        let h_v = h_v.to_dtype(DType::F32)?;
278        let h_ev = concat_node_tensors(&h_v, h_e, e_idx)?;
279        let h_message = self
280            .w1
281            .forward(&h_ev)?
282            .gelu_erf()?
283            .apply(&self.w2)?
284            .gelu_erf()?
285            .apply(&self.w3)?;
286
287        let h_message = mask_attend
288            .map(|mask| mask.unsqueeze(D::Minus1)?.broadcast_mul(&h_message))
289            .transpose()?
290            .unwrap_or(h_message);
291
292        // Safe division with scale
293        let scale = if self.scale == 0.0 { 1.0 } else { self.scale };
294        let dh = (h_message.sum(D::Minus2)? / scale)?;
295
296        let h_v = apply_dropout_and_norm(&h_v, &dh, &self.dropout1, &self.norm1, training)?;
297        let dense_output = self.dense.forward(&h_v)?;
298        let h_v =
299            apply_dropout_and_norm(&h_v, &dense_output, &self.dropout2, &self.norm2, training)?;
300
301        // Apply mask if provided
302        let h_v = mask_v
303            .map(|mask| mask.unsqueeze(D::Minus1)?.broadcast_mul(&h_v))
304            .transpose()?
305            .unwrap_or(h_v);
306
307        let h_ev = concat_node_tensors(&h_v, h_e, e_idx)?;
308        let h_message = self
309            .w11
310            .forward(&h_ev)?
311            .gelu_erf()?
312            .apply(&self.w12)?
313            .gelu_erf()?
314            .apply(&self.w13)?;
315
316        let h_e = apply_dropout_and_norm(h_e, &h_message, &self.dropout3, &self.norm3, training)?;
317        Ok((h_v, h_e))
318    }
319}
320
321#[derive(Clone, Debug)]
322#[allow(dead_code)]
323pub struct DecLayer {
324    num_hidden: usize,
325    num_in: usize,
326    scale: f64,
327    dropout1: Dropout,
328    dropout2: Dropout,
329    norm1: LayerNorm,
330    norm2: LayerNorm,
331    w1: Linear,
332    w2: Linear,
333    w3: Linear,
334    dense: PositionWiseFeedForward,
335}
336
337impl DecLayer {
338    /// The main decoder stack: `num_in = 3 * hidden_dim`.
339    pub fn load(vb: VarBuilder, config: &ProteinMPNNConfig, layer: i32) -> Result<Self> {
340        Self::load_with_num_in(vb, config, layer, (config.hidden_dim * 3) as usize)
341    }
342
343    /// `num_in` explicitly, for LigandMPNN's `context_encoder_layers`.
344    ///
345    /// Those are `DecLayer(hidden_dim, hidden_dim * 2)` in the reference — the
346    /// message is `[h_V_C, h_E_context, Y_nodes]` rather than the decoder's
347    /// three hidden-width blocks — so their `W1` is `(128, 384)` where the
348    /// decoder's is `(128, 512)` (ferritin-100.11).
349    pub fn load_with_num_in(
350        vb: VarBuilder,
351        config: &ProteinMPNNConfig,
352        layer: i32,
353        num_in: usize,
354    ) -> Result<Self> {
355        let vb = vb.pp(layer); // handle the layer number here.
356        let num_hidden = config.hidden_dim as usize;
357        let augment_eps = 1e-5f64;
358        let dropout_ratio = config.dropout_ratio;
359
360        let norm1 = layer_norm::layer_norm(num_hidden, augment_eps, vb.pp("norm1"))?;
361        let norm2 = layer_norm::layer_norm(num_hidden, augment_eps, vb.pp("norm2"))?;
362
363        let w1 = linear::linear(num_hidden + num_in, num_hidden, vb.pp("W1"))?;
364        let w2 = linear::linear(num_hidden, num_hidden, vb.pp("W2"))?;
365        let w3 = linear::linear(num_hidden, num_hidden, vb.pp("W3"))?;
366        let dropout1 = Dropout::new(dropout_ratio);
367        let dropout2 = Dropout::new(dropout_ratio);
368
369        let dense = PositionWiseFeedForward::new(vb.pp("dense"), num_hidden, num_hidden * 4)?;
370
371        Ok(Self {
372            num_hidden,
373            num_in,
374            scale: config.scale_factor,
375            dropout1,
376            dropout2,
377            norm1,
378            norm2,
379            w1,
380            w2,
381            w3,
382            dense,
383        })
384    }
385    pub fn forward(
386        &self,
387        h_v: &Tensor,
388        h_e: &Tensor,
389        mask_v: Option<&Tensor>,
390        mask_attend: Option<&Tensor>,
391        training: Option<bool>,
392    ) -> Result<Tensor> {
393        let training_bool = training.unwrap_or(false);
394
395        // Expand node features to match edge dimensions
396        let expand_shape = [
397            h_e.dims()[0], // batch (1)
398            h_e.dims()[1], // sequence length (93)
399            h_e.dims()[2], // number of neighbors (24)
400            h_v.dims()[2], // keep original hidden dim (128)
401        ];
402
403        let h_v_expand = h_v.unsqueeze(D::Minus2)?.expand(&expand_shape)?;
404        let h_ev = Tensor::cat(&[&h_v_expand, h_e], D::Minus1)?.contiguous()?;
405
406        let h_message = self
407            .w1
408            .forward(&h_ev)?
409            .gelu_erf()?
410            .apply(&self.w2)?
411            .gelu_erf()?
412            .apply(&self.w3)?;
413
414        let h_message = self.dropout1.forward(&h_message, training_bool)?;
415
416        let h_message = mask_attend
417            .map(|mask| mask.unsqueeze(D::Minus1)?.broadcast_mul(&h_message))
418            .transpose()?
419            .unwrap_or(h_message);
420
421        let dh = (h_message.sum(D::Minus2)? / self.scale)?;
422        let h_v = self.norm1.forward(&(h_v + dh)?)?;
423        let dh = self.dense.forward(&h_v)?;
424        let dh_dropout = self.dropout2.forward(&dh, training_bool)?;
425        let h_v = self.norm2.forward(&(h_v + dh_dropout)?)?;
426
427        // Apply optional node mask
428        let h_v = mask_v
429            .map(|mask| mask.unsqueeze(D::Minus1)?.broadcast_mul(&h_v))
430            .transpose()?
431            .unwrap_or(h_v);
432
433        Ok(h_v)
434    }
435}
436
437/// How many context-encoder rounds LigandMPNN runs.
438///
439/// Fixed at 2 in the reference — `range(2)` for both `context_encoder_layers`
440/// and `y_context_encoder_layers` — not derived from any config field, and
441/// every published checkpoint has exactly two of each.
442const LIGAND_CONTEXT_LAYERS: i32 = 2;
443
444/// [`DecLayer`] applied one dimension deeper, over LigandMPNN's ligand graph.
445///
446/// Port of `DecLayerJ`
447/// ([model_utils.py](https://github.com/dauparas/LigandMPNN/blob/main/model_utils.py#L1582)).
448/// Structurally identical to [`DecLayer`] — same weight names, same
449/// `W3(act(W2(act(W1))))` message and same two residual norms — and the
450/// reference comments the single difference as "the only difference": the node
451/// expansion.
452///
453/// [`DecLayer`] carries `h_V` as `(B, L, C)` and broadcasts it over `K`
454/// neighbours. Here `h_V` is `Y_nodes`, already `(B, L, M, C)` — one node per
455/// (residue, ligand atom) pair — and it broadcasts over the ligand graph's own
456/// `M` edges to `(B, L, M, M, C)`.
457#[derive(Clone, Debug)]
458pub struct DecLayerJ {
459    scale: f64,
460    norm1: LayerNorm,
461    norm2: LayerNorm,
462    w1: Linear,
463    w2: Linear,
464    w3: Linear,
465    dense: PositionWiseFeedForward,
466}
467
468impl DecLayerJ {
469    pub fn load(vb: VarBuilder, config: &ProteinMPNNConfig, layer: i32) -> Result<Self> {
470        let vb = vb.pp(layer);
471        let num_hidden = config.hidden_dim as usize;
472        // `DecLayerJ(hidden_dim, hidden_dim)`: the message is [Y_nodes,
473        // Y_edges], two hidden-width blocks, so W1 is (128, 256).
474        let num_in = num_hidden;
475        let eps = 1e-5f64;
476
477        Ok(Self {
478            scale: config.scale_factor,
479            norm1: layer_norm::layer_norm(num_hidden, eps, vb.pp("norm1"))?,
480            norm2: layer_norm::layer_norm(num_hidden, eps, vb.pp("norm2"))?,
481            w1: linear::linear(num_hidden + num_in, num_hidden, vb.pp("W1"))?,
482            w2: linear::linear(num_hidden, num_hidden, vb.pp("W2"))?,
483            w3: linear::linear(num_hidden, num_hidden, vb.pp("W3"))?,
484            dense: PositionWiseFeedForward::new(vb.pp("dense"), num_hidden, num_hidden * 4)?,
485        })
486    }
487
488    /// `h_v` is `(B, L, M, C)`; `h_e` is `(B, L, M, M, C)`.
489    pub fn forward(
490        &self,
491        h_v: &Tensor,
492        h_e: &Tensor,
493        mask_v: Option<&Tensor>,
494        mask_attend: Option<&Tensor>,
495    ) -> Result<Tensor> {
496        let (b, l, m, _, _) = h_e.dims5()?;
497        let c = h_v.dim(D::Minus1)?;
498        let h_v_expand = h_v.unsqueeze(D::Minus2)?.broadcast_as((b, l, m, m, c))?;
499        let h_ev = Tensor::cat(&[&h_v_expand, h_e], D::Minus1)?.contiguous()?;
500
501        // Rank 5 throughout: `Linear::forward` matmuls directly and caps at
502        // rank 3, so every projection here goes through `linear_last_dim`.
503        let h_message = linear_last_dim(&self.w1, &h_ev)?.gelu_erf()?;
504        let h_message = linear_last_dim(&self.w2, &h_message)?.gelu_erf()?;
505        let h_message = linear_last_dim(&self.w3, &h_message)?;
506        let h_message = mask_attend
507            .map(|mask| mask.unsqueeze(D::Minus1)?.broadcast_mul(&h_message))
508            .transpose()?
509            .unwrap_or(h_message);
510
511        let dh = (h_message.sum(D::Minus2)? / self.scale)?;
512        let h_v = self.norm1.forward(&(h_v + dh)?)?;
513        let dh = self.dense.forward_any_rank(&h_v)?;
514        let h_v = self.norm2.forward(&(h_v + dh)?)?;
515
516        mask_v
517            .map(|mask| mask.unsqueeze(D::Minus1)?.broadcast_mul(&h_v))
518            .transpose()?
519            .map_or(Ok(h_v), Ok)
520    }
521}
522
523/// ProteinMPNN Model
524/// - [link](https://github.com/dauparas/LigandMPNN/blob/main/model_utils.py#L10C7-L10C18)
525pub struct ProteinMPNN {
526    pub(crate) config: ProteinMPNNConfig,
527    pub(crate) decoder_layers: Vec<DecLayer>,
528    pub(crate) device: Device,
529    pub(crate) encoder_layers: Vec<EncLayer>,
530    /// The protein-only featurizer, for `ModelTypes::ProteinMPNN`.
531    pub(crate) features: Option<ProteinFeaturesModel>,
532    /// Everything only `ModelTypes::LigandMPNN` has.
533    pub(crate) ligand: Option<LigandModules>,
534    pub(crate) w_e: Linear,
535    pub(crate) w_out: Linear,
536    pub(crate) w_s: Embedding,
537}
538
539/// The modules LigandMPNN adds on top of the shared ProteinMPNN stack.
540///
541/// Grouped rather than scattered as eight `Option` fields so that "this is a
542/// LigandMPNN" is one check, and so a ProteinMPNN cannot be half-constructed
543/// with some of them present.
544pub(crate) struct LigandModules {
545    features: ProteinFeaturesLigand,
546    /// Projects the ligand node features `V` into the context message.
547    w_v: Linear,
548    /// Projects `h_V` into the context path before the context encoder runs.
549    w_c: Linear,
550    w_nodes_y: Linear,
551    w_edges_y: Linear,
552    /// Final projection of the context path, added back onto `h_V`.
553    v_c: Linear,
554    v_c_norm: LayerNorm,
555    context_encoder_layers: Vec<DecLayer>,
556    y_context_encoder_layers: Vec<DecLayerJ>,
557}
558
559impl ProteinMPNN {
560    pub fn load(vb: VarBuilder, config: &ProteinMPNNConfig) -> Result<Self> {
561        let hidden_dim = config.hidden_dim as usize;
562        let edge_features = config.edge_features as usize;
563        let num_letters = config.num_letters as usize;
564        let node_features = config.node_features as usize;
565        let vocab_size = config.vocab as usize;
566
567        // Create encoder and decoder layers using iterators
568        let encoder_layers = (0..config.num_encoder_layers)
569            .map(|i| EncLayer::load(vb.pp("encoder_layers"), config, i as i32))
570            .collect::<Result<Vec<_>>>()?;
571
572        let decoder_layers = (0..config.num_decoder_layers)
573            .map(|i| DecLayer::load(vb.pp("decoder_layers"), config, i as i32))
574            .collect::<Result<Vec<_>>>()?;
575
576        // Initialize weights
577        let w_e = linear::linear(edge_features, hidden_dim, vb.pp("W_e"))?;
578        let w_out = linear::linear(hidden_dim, num_letters, vb.pp("W_out"))?;
579        let w_s = embedding(vocab_size, hidden_dim, vb.pp("W_s"))?;
580
581        // The two model types use DIFFERENT featurizers under the same
582        // `features.` prefix: ProteinFeatures for ProteinMPNN,
583        // ProteinFeaturesLigand for LigandMPNN. The latter is a superset —
584        // same `embeddings`/`edge_embedding`/`norm_edges`, plus the ligand
585        // node and graph projections — so loading the wrong one against a
586        // LigandMPNN checkpoint silently ignores nine tensors rather than
587        // failing.
588        let (features, ligand) = match config.model_type {
589            ModelTypes::ProteinMPNN => (
590                Some(ProteinFeaturesModel::load(
591                    vb.pp("features"),
592                    config.clone(),
593                )?),
594                None,
595            ),
596            ModelTypes::LigandMPNN => (
597                None,
598                Some(LigandModules {
599                    features: ProteinFeaturesLigand::load(vb.pp("features"), config)?,
600                    w_v: linear::linear(node_features, hidden_dim, vb.pp("W_v"))?,
601                    w_c: linear::linear(hidden_dim, hidden_dim, vb.pp("W_c"))?,
602                    w_nodes_y: linear::linear(hidden_dim, hidden_dim, vb.pp("W_nodes_y"))?,
603                    w_edges_y: linear::linear(hidden_dim, hidden_dim, vb.pp("W_edges_y"))?,
604                    v_c: linear::linear_no_bias(hidden_dim, hidden_dim, vb.pp("V_C"))?,
605                    v_c_norm: layer_norm::layer_norm(hidden_dim, 1e-5f64, vb.pp("V_C_norm"))?,
606                    // DecLayer(hidden, hidden * 2), not the decoder's
607                    // hidden * 3: the message is [h_V_C, h_E_context, Y_nodes].
608                    context_encoder_layers: (0..LIGAND_CONTEXT_LAYERS)
609                        .map(|i| {
610                            DecLayer::load_with_num_in(
611                                vb.pp("context_encoder_layers"),
612                                config,
613                                i,
614                                hidden_dim * 2,
615                            )
616                        })
617                        .collect::<Result<Vec<_>>>()?,
618                    y_context_encoder_layers: (0..LIGAND_CONTEXT_LAYERS)
619                        .map(|i| DecLayerJ::load(vb.pp("y_context_encoder_layers"), config, i))
620                        .collect::<Result<Vec<_>>>()?,
621                }),
622            ),
623        };
624
625        Ok(Self {
626            config: config.clone(),
627            decoder_layers,
628            device: vb.device().clone(),
629            encoder_layers,
630            features,
631            ligand,
632            w_e,
633            w_out,
634            w_s,
635        })
636    }
637    /// Encode the structure into node and edge embeddings.
638    ///
639    /// Both model types run the same three [`EncLayer`]s over the same protein
640    /// neighbour graph. LigandMPNN then folds in a ligand context path before
641    /// returning — `Self::encode_ligand_context`, a private helper, so not
642    /// linkable from here.
643    pub fn encode(&self, features: &ProteinFeatures) -> Result<(Tensor, Tensor, Tensor)> {
644        let mask = self.sequence_mask(features)?;
645
646        let (v, e, e_idx, ligand_graph) = match (&self.features, &self.ligand) {
647            (Some(protein), None) => {
648                let (e, e_idx) = protein.forward(features, &self.device)?;
649                (None, e, e_idx, None)
650            }
651            (None, Some(ligand)) => {
652                let f = ligand.features.forward(features)?;
653                (Some(f.v), f.e, f.e_idx, Some((f.y_nodes, f.y_edges, f.y_m)))
654            }
655            // `load` builds exactly one of the two, keyed on model_type.
656            _ => candle_core::bail!(
657                "{:?}: built with no featurizer, or with both",
658                self.config.model_type
659            ),
660        };
661
662        let h_v = Tensor::zeros(
663            (e.dim(0)?, e.dim(1)?, e.dim(D::Minus1)?),
664            DType::F32,
665            &self.device,
666        )?;
667        let h_e = self.w_e.forward(&e)?;
668
669        // mask_attend = mask[..., None] * gather_nodes(mask[..., None], E_idx)
670        let mask_gathered =
671            gather_nodes(&mask.unsqueeze(D::Minus1)?, &e_idx)?.squeeze(D::Minus1)?;
672        let mask_attend = mask.unsqueeze(D::Minus1)?.broadcast_mul(&mask_gathered)?;
673
674        let (h_v, h_e) = self
675            .encoder_layers
676            .iter()
677            .try_fold((h_v, h_e), |(h_v, h_e), layer| {
678                layer.forward(
679                    &h_v,
680                    &h_e,
681                    &e_idx,
682                    Some(&mask),
683                    Some(&mask_attend),
684                    Some(false),
685                )
686            })?;
687
688        let h_v = match (&self.ligand, v, ligand_graph) {
689            (Some(ligand), Some(v), Some((y_nodes, y_edges, y_m))) => {
690                self.encode_ligand_context(ligand, &h_v, &v, &y_nodes, &y_edges, &y_m, &mask)?
691            }
692            _ => h_v,
693        };
694
695        Ok((h_v, h_e, e_idx))
696    }
697
698    /// Per-residue validity as F32, defaulting to all-valid.
699    fn sequence_mask(&self, features: &ProteinFeatures) -> Result<Tensor> {
700        match features.get_sequence_mask() {
701            Some(m) => m.to_dtype(DType::F32),
702            None => Tensor::ones_like(features.get_sequence())?.to_dtype(DType::F32),
703        }
704    }
705
706    /// LigandMPNN's ligand context path.
707    ///
708    /// Two interleaved message-passing rounds, then one residual back onto the
709    /// protein node embeddings:
710    ///
711    /// 1. `Y_nodes` exchange messages over the ligand graph's own edges
712    ///    ([`DecLayerJ`], one dimension deeper than the protein layers).
713    /// 2. The updated `Y_nodes` are concatenated onto the projected ligand
714    ///    node features and passed to a [`DecLayer`], updating a per-residue
715    ///    context vector `h_V_C`.
716    /// 3. `h_V = h_V + V_C_norm(V_C(h_V_C))`.
717    ///
718    /// Note both loops read `y_context_encoder_layers[i]` and
719    /// `context_encoder_layers[i]` in the same iteration — the ligand graph is
720    /// updated first, and the context layer sees that round's output, not the
721    /// previous one's.
722    #[allow(clippy::too_many_arguments)] // one call site; naming a struct for it adds nothing
723    fn encode_ligand_context(
724        &self,
725        ligand: &LigandModules,
726        h_v: &Tensor,
727        v: &Tensor,
728        y_nodes: &Tensor,
729        y_edges: &Tensor,
730        y_m: &Tensor,
731        mask: &Tensor,
732    ) -> Result<Tensor> {
733        // Rank 4 (B, L, M, C).
734        let h_e_context = linear_last_dim(&ligand.w_v, v)?;
735        let mut h_v_c = ligand.w_c.forward(h_v)?;
736
737        let y_m = y_m.to_dtype(DType::F32)?;
738        // Y_m_edges = Y_m[:, :, :, None] * Y_m[:, :, None, :]
739        let y_m_edges = y_m
740            .unsqueeze(D::Minus1)?
741            .broadcast_mul(&y_m.unsqueeze(D::Minus2)?)?;
742
743        // Rank 4 (B, L, M, C).
744        let mut y_nodes = linear_last_dim(&ligand.w_nodes_y, y_nodes)?;
745        // Rank 5 (B, L, M, M, C).
746        let y_edges = linear_last_dim(&ligand.w_edges_y, y_edges)?;
747
748        for (y_layer, context_layer) in ligand
749            .y_context_encoder_layers
750            .iter()
751            .zip(&ligand.context_encoder_layers)
752        {
753            y_nodes = y_layer.forward(&y_nodes, &y_edges, Some(&y_m), Some(&y_m_edges))?;
754            let h_e_context_cat = Tensor::cat(&[&h_e_context, &y_nodes], D::Minus1)?;
755            h_v_c = context_layer.forward(
756                &h_v_c,
757                &h_e_context_cat,
758                Some(mask),
759                Some(&y_m),
760                Some(false),
761            )?;
762        }
763
764        let h_v_c = ligand.v_c.forward(&h_v_c)?;
765        h_v + ligand.v_c_norm.forward(&h_v_c)?
766    }
767
768    // Removed unused decode methods
769    pub fn simple_decode(&self, features: &ProteinFeatures) -> Result<ScoreOutput> {
770        // Create a batch size of 1 for simple decoding
771        let b_decoder = 1;
772
773        // Extract relevant features
774        let ProteinFeatures { s, x_mask, .. } = features;
775        let device = s.device();
776        let (_, l) = s.dims2()?;
777
778        // Encode the structure once
779        let (h_v_enc, h_e_enc, e_idx_enc) = self.encode(features)?;
780
781        // Process all positions at once with a simplified approach
782        let s_true = s.clone();
783        let mask = x_mask.clone().unwrap();
784
785        // Create tensors for the decoder
786        let zeros = Tensor::zeros((b_decoder, l, h_v_enc.dim(D::Minus1)?), DType::F32, device)?;
787        let h_v = h_v_enc.clone();
788        let h_e = h_e_enc.clone();
789        let e_idx = e_idx_enc.clone();
790
791        // Build encoder embeddings for neighbors
792        let h_ex_encoder = cat_neighbors_nodes(&zeros, &h_e, &e_idx)?;
793        let h_exv_encoder = cat_neighbors_nodes(&h_v, &h_ex_encoder, &e_idx)?;
794
795        // Apply decoder layers using only structure information
796        let h_v_final = self.decoder_layers.iter().fold(Ok(h_v), |acc, layer| {
797            layer.forward(&acc?, &h_exv_encoder, Some(&mask), None, None)
798        })?;
799
800        // Calculate logits and log probabilities
801        let logits = self.w_out.forward(&h_v_final)?;
802        let log_probs = log_softmax(&logits, D::Minus1)?;
803
804        // For the decoding order, just use a placeholder
805        let decoding_order = Tensor::arange(0, l as i64, device)?
806            .reshape((1, l))?
807            .broadcast_as((b_decoder, l))?
808            .to_dtype(DType::F32)?;
809
810        // Return the output directly
811        Ok(ScoreOutput {
812            s: s_true,
813            log_probs,
814            logits,
815            decoding_order,
816        })
817    }
818    pub fn sample(
819        &self,
820        features: &ProteinFeatures,
821        temperature: f64,
822        seed: u64,
823    ) -> Result<ScoreOutput> {
824        let sample_dtype = DType::F32;
825        let ProteinFeatures {
826            s,
827            x_mask,
828            // symmetry_residues,
829            // symmetry_weights,
830            ..
831        } = features;
832        let s_true = s.to_dtype(sample_dtype)?;
833        let device = s.device();
834        let (b, l) = s.dims2()?;
835        // Todo: This is a hack. we should be passing in encoded chains.
836        // let chain_mask = Tensor::ones_like(&x_mask.as_ref().unwrap())?.to_dtype(sample_dtype)?;
837        // let chain_mask = x_mask.as_ref().unwrap().mul(&chain_mask)?;
838        let chain_mask = x_mask.as_ref().unwrap().to_dtype(sample_dtype)?;
839        let (h_v, h_e, e_idx) = self.encode(features)?;
840        let rand_tensor = Tensor::randn(0f32, 0.25f32, (b, l), device)?.to_dtype(sample_dtype)?;
841        // Ascending, matching `torch.argsort`, whose default is ascending.
842        // Positions with chain_mask == 0 are fixed, so their product is ~1e-4
843        // and they must be decoded FIRST; sorting descending put them last and
844        // inverted the intent (ferritin-100.11).
845        let decoding_order = (&chain_mask + 0.0001)?
846            .mul(&rand_tensor.abs()?)?
847            .arg_sort_last_dim(true)?;
848        // TodoL add  bias
849        // # [B,L,21] - amino acid bias per position
850        let bias = Tensor::ones((b, l, 21), sample_dtype, device)?;
851        let symmetry_residues: Option<Vec<i32>> = None;
852        match symmetry_residues {
853            None => {
854                let e_idx = e_idx.repeat(&[b, 1, 1])?;
855                let permutation_matrix_reverse = one_hot(decoding_order.clone(), l, 1f32, 0f32)?
856                    .to_dtype(sample_dtype)?
857                    .contiguous()?;
858                let tril = Tensor::tril2(l, sample_dtype, device)?;
859                let tril = tril.unsqueeze(0)?;
860                let temp = tril
861                    .matmul(&permutation_matrix_reverse.transpose(1, 2)?)?
862                    .contiguous()?; //tensor of shape (b, i, q)
863                let order_mask_backward = temp
864                    .matmul(&permutation_matrix_reverse.transpose(1, 2)?)?
865                    .contiguous()?; // This will give us a tensor of shape (b, q, p)
866                let mask_attend = order_mask_backward
867                    .gather(&e_idx, 2)?
868                    .unsqueeze(D::Minus1)?;
869                let mask_1d = x_mask.as_ref().unwrap().reshape((b, l, 1, 1))?;
870                // Broadcast mask_1d to match mask_attend's shape
871                let mask_1d = mask_1d
872                    .broadcast_as(mask_attend.shape())?
873                    .to_dtype(sample_dtype)?;
874                let mask_bw = mask_1d.mul(&mask_attend)?;
875                let mask_fw = mask_1d.mul(&(Tensor::ones_like(&mask_attend)? - mask_attend)?)?;
876                // Note: `sample` begins to diverge from the `score` here.
877                // repeat for decoding
878                let s_true = s_true.repeat((b, 1))?;
879                let h_v = h_v.repeat((b, 1, 1))?;
880                let h_e = h_e.repeat((b, 1, 1, 1))?;
881                let mask = x_mask.as_ref().unwrap().repeat((b, 1))?.contiguous()?;
882                let chain_mask = &chain_mask.repeat((b, 1))?;
883                let bias = bias.repeat((b, 1, 1))?;
884                let mut all_probs = Tensor::zeros((b, l, 20), sample_dtype, device)?;
885                // why is this one 21 and the others are 20?
886                let mut all_log_probs = Tensor::zeros((b, l, 21), sample_dtype, device)?;
887                let mut h_s = Tensor::zeros_like(&h_v)?;
888                // note: we this value of 20 is `X`. We will need to replace the values below, not add them
889                let mut s = Tensor::full(20u32, (b, l), device)?;
890                let mut h_v_stack = vec![h_v.clone()];
891
892                for _ in 0..self.decoder_layers.len() {
893                    let zeros = Tensor::zeros_like(&h_v)?;
894                    h_v_stack.push(zeros);
895                }
896                let h_ex_encoder = cat_neighbors_nodes(&Tensor::zeros_like(&h_s)?, &h_e, &e_idx)?;
897                let h_exv_encoder = cat_neighbors_nodes(&h_v, &h_ex_encoder, &e_idx)?;
898                let mask_fw = mask_fw
899                    .broadcast_as(h_exv_encoder.shape())?
900                    .to_dtype(h_exv_encoder.dtype())?;
901                let h_exv_encoder_fw = mask_fw.mul(&h_exv_encoder)?;
902                for t_ in 0..l {
903                    let t = decoding_order.i((.., t_))?;
904                    let t_gather = t.unsqueeze(1)?; // Shape [B, 1]
905                    // Gather masks and bias
906                    let chain_mask_t = chain_mask.gather(&t_gather, 1)?.squeeze(1)?;
907                    let mask_t = mask.gather(&t_gather, 1)?.squeeze(1)?.contiguous()?;
908                    let bias_t = bias
909                        .gather(&t_gather.unsqueeze(2)?.expand((b, 1, 21))?.contiguous()?, 1)?
910                        .squeeze(1)?;
911                    // Gather edge and node indices/features
912                    let e_idx_t = e_idx
913                        .gather(
914                            &t_gather
915                                .unsqueeze(2)?
916                                .expand((b, 1, e_idx.dim(2)?))?
917                                .contiguous()?,
918                            1,
919                        )?
920                        .contiguous()?;
921                    let h_e_t = h_e.gather(
922                        &t_gather
923                            .unsqueeze(2)?
924                            .unsqueeze(3)?
925                            .expand((b, 1, h_e.dim(2)?, h_e.dim(3)?))?
926                            .contiguous()?,
927                        1,
928                    )?;
929                    let n = e_idx_t.dim(2)?; // number of neighbors
930                    let c = h_s.dim(2)?; // channels/features
931                    let h_e_t = h_e_t
932                        .squeeze(1)? // [B, N, C]
933                        .unsqueeze(1)? // [B, 1, N, C]
934                        .expand((b, l, n, c))? // [B, L, N, C]
935                        .contiguous()?;
936                    let e_idx_t = e_idx_t
937                        .expand((b, l, n))? // [B, L, N]
938                        .contiguous()?;
939                    let h_es_t = cat_neighbors_nodes(&h_s, &h_e_t, &e_idx_t)?;
940                    let h_exv_encoder_t = h_exv_encoder_fw.gather(
941                        &t_gather
942                            .unsqueeze(2)?
943                            .unsqueeze(3)?
944                            .expand((b, 1, h_exv_encoder_fw.dim(2)?, h_exv_encoder_fw.dim(3)?))?
945                            .contiguous()?,
946                        1,
947                    )?;
948                    let mask_bw_t = mask_bw.gather(
949                        &t_gather
950                            .unsqueeze(2)?
951                            .unsqueeze(3)?
952                            .expand((b, 1, mask_bw.dim(2)?, mask_bw.dim(3)?))?
953                            .contiguous()?,
954                        1,
955                    )?;
956
957                    // Decoder layers loop
958                    for l in 0..self.decoder_layers.len() {
959                        let h_v_stack_l = &h_v_stack[l];
960                        let h_esv_decoder_t = cat_neighbors_nodes(h_v_stack_l, &h_es_t, &e_idx_t)?;
961                        let h_v_t = h_v_stack_l.gather(
962                            &t_gather
963                                .unsqueeze(2)?
964                                .expand((b, 1, h_v_stack_l.dim(2)?))?
965                                .contiguous()?,
966                            1,
967                        )?;
968                        let mask_bw_t = mask_bw_t.expand(h_esv_decoder_t.dims())?.contiguous()?;
969                        let h_exv_encoder_t = h_exv_encoder_t
970                            .expand(h_esv_decoder_t.dims())?
971                            .contiguous()?
972                            .to_dtype(sample_dtype)?;
973                        let h_esv_t = mask_bw_t
974                            .mul(&h_esv_decoder_t.to_dtype(sample_dtype)?)?
975                            .add(&h_exv_encoder_t)?
976                            .to_dtype(sample_dtype)?
977                            .contiguous()?;
978                        let h_v_t = h_v_t
979                            .expand((
980                                h_esv_t.dim(0)?, // batch size
981                                h_esv_t.dim(1)?, // sequence length (93)
982                                h_v_t.dim(2)?,   // features (128)
983                            ))?
984                            .contiguous()?;
985                        let decoder_output = self.decoder_layers[l].forward(
986                            &h_v_t,
987                            &h_esv_t,
988                            Some(&mask_t),
989                            None,
990                            None,
991                        )?;
992                        let t_expanded = t_gather.reshape(&[b])?; // This will give us a 1D tensor of shape [b]
993                        let decoder_output = decoder_output
994                            .narrow(1, 0, 1)?
995                            .squeeze(1)? // Now [1, 128]
996                            .unsqueeze(1)?; // Now [1, 1, 128] - same rank as target
997                        h_v_stack[l + 1] =
998                            h_v_stack[l + 1].index_add(&t_expanded, &decoder_output, 1)?;
999                        // h_v_stack[l + 1] =
1000                        //     h_v_stack[l + 1].index_add(&t_expanded, &decoder_output, 1)?;
1001                    }
1002                    let h_v_t = h_v_stack
1003                        .last()
1004                        .unwrap()
1005                        .gather(
1006                            &t_gather
1007                                .unsqueeze(2)?
1008                                .expand((b, 1, h_v_stack.last().unwrap().dim(2)?))?
1009                                .contiguous()?,
1010                            1,
1011                        )?
1012                        .squeeze(1)?;
1013                    // Generate logits and probabilities
1014                    let logits = self.w_out.forward(&h_v_t)?;
1015                    let log_probs = log_softmax(&logits, D::Minus1)?;
1016
1017                    // explicit for OoO
1018                    let probs = {
1019                        let biased_logits = logits.add(&bias_t)?; // (logits + bias_t)
1020                        let scaled_logits = (biased_logits / temperature)?; // (logits + bias_t) / temperature
1021                        softmax(&scaled_logits, D::Minus1)? // softmax((logits + bias_t) / temperature)
1022                    };
1023
1024                    let probs_sample = probs
1025                        .narrow(1, 0, 20)?
1026                        .div(&probs.narrow(1, 0, 20)?.sum_keepdim(1)?.expand((b, 20))?)?;
1027                    // Sample new token
1028                    let sum = probs_sample.sum(1)?;
1029                    let probs_sample_1d = probs_sample
1030                        .squeeze(0)? // Remove batch dimension -> [20]
1031                        .clamp(1e-10, 1.0)?
1032                        .broadcast_div(&sum)?
1033                        .contiguous()?;
1034
1035                    let s_t = multinomial_sample(&probs_sample_1d, temperature, seed)?;
1036                    let s_t = s_t.to_dtype(sample_dtype)?;
1037                    let s_true = s_true.to_dtype(sample_dtype)?;
1038                    let s_true_t = s_true.gather(&t_gather, 1)?.squeeze(1)?;
1039                    let s_t = s_t
1040                        .mul(&chain_mask_t)?
1041                        .add(&s_true_t.mul(&(&chain_mask_t.neg()? + 1.0)?)?)?
1042                        .to_dtype(DType::U32)?;
1043
1044                    let s_t_idx = s_t.to_dtype(DType::U32)?;
1045                    let s_t_idx = s_t_idx.reshape(&[s_t_idx.dim(0)?])?;
1046                    let h_s_update = self.w_s.forward(&s_t_idx)?.unsqueeze(1)?;
1047                    let t_gather_expanded = t_gather.reshape(&[b])?;
1048                    let h_s_update = h_s_update.squeeze(0)?.unsqueeze(1)?;
1049                    h_s =
1050                        h_s.index_add(&t_gather_expanded, &Tensor::zeros_like(&h_s_update)?, 1)?;
1051                    h_s = h_s.index_add(&t_gather_expanded, &h_s_update, 1)?;
1052
1053                    s = {
1054                        let dim = 1;
1055                        let start = t_gather.squeeze(0)?.squeeze(0)?.to_scalar::<u32>()? as usize;
1056                        let s_t_expanded = s_t.unsqueeze(1)?;
1057                        s.slice_scatter(&s_t_expanded, dim, start)?
1058                    };
1059
1060                    let probs_update = chain_mask_t
1061                        .unsqueeze(1)?
1062                        .unsqueeze(2)?
1063                        .expand((b, 1, 20))?
1064                        .mul(&probs_sample.unsqueeze(1)?)?;
1065                    let t_expanded = t_gather.reshape(&[b])?;
1066                    let probs_update = probs_update
1067                        .squeeze(1)? // Remove extra dimension
1068                        .unsqueeze(1)?;
1069                    all_probs =
1070                        all_probs.index_add(&t_expanded, &Tensor::zeros_like(&probs_update)?, 1)?;
1071                    all_probs = all_probs.index_add(&t_expanded, &probs_update, 1)?;
1072                    let log_probs_update = chain_mask_t
1073                        .unsqueeze(1)?
1074                        .unsqueeze(2)?
1075                        .expand((b, 1, 21))?
1076                        .mul(&log_probs.unsqueeze(1)?)?
1077                        .squeeze(1)?
1078                        .unsqueeze(1)?;
1079
1080                    all_log_probs = all_log_probs.index_add(
1081                        &t_expanded,
1082                        &Tensor::zeros_like(&log_probs_update)?,
1083                        1,
1084                    )?;
1085                    all_log_probs = all_log_probs.index_add(&t_expanded, &log_probs_update, 1)?;
1086                }
1087                Ok(ScoreOutput {
1088                    s,
1089                    log_probs: all_probs,
1090                    logits: all_log_probs,
1091                    decoding_order,
1092                })
1093            }
1094            Some(_symmetry_residues) => {
1095                candle_core::bail!("symmetry residues in sample() not yet implemented")
1096            }
1097        }
1098    }
1099
1100    pub fn score(&self, features: &ProteinFeatures, use_sequence: bool) -> Result<ScoreOutput> {
1101        let ProteinFeatures { s, x_mask, .. } = &features;
1102        let sample_dtype = DType::F32;
1103        let s_true = &s.clone();
1104        let device = s_true.device();
1105        let (b, l) = s_true.dims2()?;
1106        let mask = &x_mask.as_ref().clone();
1107        let b_decoder: usize = b;
1108
1109        // Todo: This is a hack. we should be passing in encoded chains.
1110        // Update chain_mask to include missing regions
1111        let chain_mask = Tensor::zeros_like(mask.unwrap())?.to_dtype(sample_dtype)?;
1112        let chain_mask = mask.unwrap().mul(&chain_mask)?; // does the order count here?
1113
1114        // encode ...
1115        let (h_v, h_e, e_idx) = self.encode(features)?;
1116        let rand_tensor = Tensor::randn(0f32, 1f32, (b, l), device)?.to_dtype(sample_dtype)?;
1117        // Compute decoding order
1118        // Ascending: see the note in `sample` (ferritin-100.11).
1119        let decoding_order = (chain_mask + 0.001)?
1120            .mul(&rand_tensor.abs()?)?
1121            .arg_sort_last_dim(true)?;
1122
1123        let symmetry_residues: Option<Vec<i32>> = None;
1124
1125        let (mask_fw, mask_bw, e_idx, decoding_order) = match symmetry_residues {
1126            Some(_symmetry_residues) => {
1127                candle_core::bail!("symmetry residues in score() not yet implemented")
1128            }
1129            None => {
1130                let e_idx = e_idx.repeat(&[b_decoder, 1, 1])?;
1131                let permutation_matrix_reverse = one_hot(decoding_order.clone(), l, 1f32, 0f32)?
1132                    .to_dtype(sample_dtype)?
1133                    .contiguous()?;
1134
1135                let tril = Tensor::tril2(l, sample_dtype, device)?.unsqueeze(0)?;
1136                let temp = tril
1137                    .matmul(&permutation_matrix_reverse.transpose(1, 2)?)?
1138                    .contiguous()?; // shape (b, i, q)
1139                let order_mask_backward = temp
1140                    .matmul(&permutation_matrix_reverse.transpose(1, 2)?)?
1141                    .contiguous()?; // shape (b, q, p)
1142                let mask_attend = order_mask_backward
1143                    .gather(&e_idx, 2)?
1144                    .unsqueeze(D::Minus1)?;
1145
1146                // Broadcast mask_1d to match mask_attend's shape
1147                let mask_1d = mask
1148                    .unwrap()
1149                    .reshape((b, l, 1, 1))?
1150                    .broadcast_as(mask_attend.shape())?
1151                    .to_dtype(sample_dtype)?;
1152
1153                let mask_bw = mask_1d.mul(&mask_attend)?;
1154                let mask_fw = mask_1d.mul(&(mask_attend - 1.0)?.neg()?)?;
1155                (mask_fw, mask_bw, e_idx, decoding_order)
1156            }
1157        };
1158
1159        let s_true = s_true.repeat(&[b_decoder, 1])?;
1160        let h_v = h_v.repeat(&[b_decoder, 1, 1])?;
1161        let h_e = h_e.repeat(&[b_decoder, 1, 1, 1])?;
1162        let mask = mask.as_ref().unwrap().repeat(&[b_decoder, 1])?;
1163
1164        let h_s = self.w_s.forward(&s_true)?; // embedding layer
1165        let h_es = cat_neighbors_nodes(&h_s, &h_e, &e_idx)?;
1166
1167        // Build encoder embeddings
1168        let h_ex_encoder = cat_neighbors_nodes(&Tensor::zeros_like(&h_s)?, &h_e, &e_idx)?;
1169        let h_exv_encoder = cat_neighbors_nodes(&h_v, &h_ex_encoder, &e_idx)?;
1170        let h_exv_encoder_fw = mask_fw
1171            .broadcast_as(h_exv_encoder.shape())?
1172            .to_dtype(h_exv_encoder.dtype())?
1173            .mul(&h_exv_encoder)?;
1174
1175        // Apply decoder layers
1176        let h_v = if !use_sequence {
1177            // Simple forward pass through decoder layers
1178            self.decoder_layers.iter().fold(Ok(h_v), |acc, layer| {
1179                layer.forward(&acc?, &h_exv_encoder_fw, Some(&mask), None, None)
1180            })?
1181        } else {
1182            // Forward pass with sequence-aware processing
1183            self.decoder_layers.iter().fold(Ok(h_v), |acc, layer| {
1184                let current_h_v = acc?;
1185                let h_esv = cat_neighbors_nodes(&current_h_v, &h_es, &e_idx)?
1186                    .mul(&mask_bw)?
1187                    .add(&h_exv_encoder_fw)?;
1188                layer.forward(&current_h_v, &h_esv, Some(&mask), None, None)
1189            })?
1190        };
1191
1192        let logits = self.w_out.forward(&h_v)?;
1193        let log_probs = log_softmax(&logits, D::Minus1)?;
1194
1195        Ok(ScoreOutput {
1196            s: s_true,
1197            log_probs,
1198            logits,
1199            decoding_order,
1200        })
1201    }
1202}