Skip to main content

ferritin_plms/
plm_runner.rs

1//! Unified trait for protein language model runners.
2//!
3//! [`PlmRunner`] provides a common interface for sequence embedding across
4//! ESM2, AMPLIFY, ESMC and ESM3, so downstream code can be generic over the
5//! runner type (benchmarking, ensembles, parity harnesses).
6//!
7//! # The special-token contract
8//!
9//! This is the part that matters for correctness. Every runner's tokenizer
10//! wraps the amino-acid sequence in special tokens, so [`PlmRunner::embed`]
11//! returns *more* rows than the sequence has residues. The old doc comment
12//! said `(1, L, d_model)` where `L` includes "any BOS/EOS tokens" — and "any"
13//! was doing far too much work: a caller comparing two runners' embeddings had
14//! no way to know whether their rows were aligned, so a mismatch showed up as
15//! silently misaligned residues rather than an error.
16//!
17//! Each runner now declares its layout via [`PlmRunner::special_tokens`], and
18//! the provided [`PlmRunner::embed_residues`] strips it:
19//!
20//! | method | rows | use it when |
21//! |---|---|---|
22//! | [`embed`][PlmRunner::embed] | `L + leading + trailing` | you want the raw model output, specials included |
23//! | [`embed_residues`][PlmRunner::embed_residues] | exactly `sequence.len()` | you are indexing by residue, or comparing across models |
24//!
25//! All four current runners are `BOS_EOS` (one leading, one trailing), but
26//! that is a fact about these checkpoints, not a guarantee — declare the
27//! layout in the impl rather than assuming it at the call site.
28//!
29//! # Batching
30//!
31//! [`embed_batch`][PlmRunner::embed_batch] runs several sequences through one
32//! forward pass, and [`embed_residues_batch`][PlmRunner::embed_residues_batch]
33//! is its residue-aligned form. The default `embed_batch` just loops and
34//! zero-pads, so it is correct for every runner; a runner overrides it only
35//! once its architecture honours a padding mask, since without one the pad
36//! tokens change the *real* residues' embeddings with no error raised.
37//!
38//! The four ported architectures want that mask in three different shapes, so
39//! [`pad_token_batch`] builds one `TokenBatch` and each runner converts:
40//! ESM-2 passes the `1 = real` mask straight through, AMPLIFY converts it with
41//! [`additive_padding_mask`], and ESM-C and ESM-3 use it as a `where_cond`
42//! predicate.
43//!
44//! ```no_run
45//! # use ferritin_plms::plm_runner::PlmRunner;
46//! # fn compare(a: &dyn PlmRunner, b: &dyn PlmRunner, seq: &str) -> anyhow::Result<()> {
47//! // Row i of each corresponds to residue i, whatever the tokenizers do.
48//! let x = a.embed_residues(seq)?;
49//! let y = b.embed_residues(seq)?;
50//! assert_eq!(x.dim(1)?, y.dim(1)?);
51//! # Ok(())
52//! # }
53//! ```
54
55use anyhow::{Result, anyhow, bail};
56use candle_core::{DType, Device, Tensor};
57
58// ── SpecialTokenLayout ────────────────────────────────────────────────────────
59
60/// How many special tokens a runner's tokenizer wraps a sequence in.
61///
62/// Named counts rather than an enum of model names, so porting a new model
63/// means declaring its shape rather than editing a `match` somewhere else.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct SpecialTokenLayout {
66    /// Tokens prepended before the first residue (e.g. BOS / `<cls>`).
67    pub leading: usize,
68    /// Tokens appended after the last residue (e.g. EOS).
69    pub trailing: usize,
70}
71
72impl SpecialTokenLayout {
73    /// No special tokens: model output rows map 1:1 onto residues.
74    pub const NONE: Self = Self {
75        leading: 0,
76        trailing: 0,
77    };
78
79    /// One leading BOS and one trailing EOS — what every currently ported
80    /// model does.
81    pub const BOS_EOS: Self = Self {
82        leading: 1,
83        trailing: 1,
84    };
85
86    /// One trailing EOS and no BOS — T5's layout, which ProtT5 uses.
87    ///
88    /// Distinct from [`BOS_EOS`][Self::BOS_EOS] by one leading row, which is
89    /// exactly enough to misalign every residue if assumed rather than read.
90    pub const EOS_ONLY: Self = Self {
91        leading: 0,
92        trailing: 1,
93    };
94
95    /// Total number of non-residue rows.
96    pub const fn total(&self) -> usize {
97        self.leading + self.trailing
98    }
99}
100
101// ── ModelMetadata ─────────────────────────────────────────────────────────────
102
103/// Architecture facts a caller needs to size downstream layers.
104///
105/// Without this, callers had to hardcode `d_model` per model.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct ModelMetadata {
108    /// Hidden width of the embeddings returned by [`PlmRunner::embed`].
109    pub d_model: usize,
110    /// Number of transformer layers.
111    pub n_layers: usize,
112    /// Token vocabulary size — the last dimension of [`PlmRunner::logits`].
113    pub vocab_size: usize,
114    /// Maximum supported sequence length, when the architecture has one.
115    /// `None` for models with relative or rotary positions and no hard cap.
116    pub max_positions: Option<usize>,
117}
118
119// ── Batching ──────────────────────────────────────────────────────────────────
120
121/// A right-padded batch of token ids and the padding mask that goes with it.
122///
123/// Built once by [`pad_token_batch`], then converted into whatever shape a
124/// given model's forward pass wants. The four ported architectures disagree
125/// about that — ESM-2 takes a `1 = real token` mask, AMPLIFY takes an additive
126/// pre-softmax bias, ESM-C and ESM-3 take a `where_cond` predicate — and
127/// deriving all three from one mask is what keeps that disagreement from
128/// becoming three subtly different notions of "padded".
129#[derive(Debug, Clone)]
130pub struct TokenBatch {
131    /// `(batch, padded_len)` token ids, dtype `U32`.
132    pub ids: Tensor,
133    /// `(batch, padded_len)`, dtype `U8`: 1 at a real token, 0 at padding.
134    pub mask: Tensor,
135    /// Unpadded token count of each row, in input order.
136    pub lengths: Vec<usize>,
137}
138
139impl TokenBatch {
140    /// The length every row was padded to — the longest row.
141    pub fn padded_len(&self) -> usize {
142        self.lengths.iter().copied().max().unwrap_or(0)
143    }
144
145    /// The padding mask cast to `dtype`, for models that want it as a float.
146    pub fn mask_as(&self, dtype: DType) -> Result<Tensor> {
147        Ok(self.mask.to_dtype(dtype)?)
148    }
149}
150
151/// Right-pad token rows to a common length and build the matching mask.
152///
153/// `pad_id` must be the tokenizer's actual pad token, not an arbitrary unused
154/// id. It is not cosmetic: [`ESMC::forward`][crate::esmc::models::esmc::ESMC::forward]
155/// reconstructs its own key-padding mask as `tokens.ne(pad_id)` when none is
156/// supplied, so padding with a different id would leave those positions
157/// looking like real residues to the one model that infers its own mask.
158pub fn pad_token_batch(rows: &[Vec<u32>], pad_id: u32, device: &Device) -> Result<TokenBatch> {
159    if rows.is_empty() {
160        bail!("pad_token_batch: empty batch — there is nothing to embed");
161    }
162    let lengths: Vec<usize> = rows.iter().map(|r| r.len()).collect();
163    let padded_len = lengths.iter().copied().max().unwrap_or(0);
164
165    let mut ids = Vec::with_capacity(rows.len() * padded_len);
166    let mut mask = Vec::with_capacity(rows.len() * padded_len);
167    for row in rows {
168        ids.extend_from_slice(row);
169        ids.resize(ids.len() + padded_len - row.len(), pad_id);
170        mask.extend(std::iter::repeat_n(1u8, row.len()));
171        mask.extend(std::iter::repeat_n(0u8, padded_len - row.len()));
172    }
173
174    Ok(TokenBatch {
175        ids: Tensor::from_vec(ids, (rows.len(), padded_len), device)?,
176        mask: Tensor::from_vec(mask, (rows.len(), padded_len), device)?,
177        lengths,
178    })
179}
180
181/// Zero every row of `embeddings` that sits at a padded position.
182///
183/// `embeddings` is `(batch, seq_len, d)`, `mask` is `(batch, seq_len)` with
184/// `1` at real tokens. A padded query still produces a full output row — the
185/// key-side mask only stops *other* positions attending to it — and that row
186/// is arbitrary rather than meaningfully zero. Zeroing it makes
187/// [`PlmRunner::embed_batch`] deterministic regardless of what each
188/// architecture happens to compute there, so pooling over the sequence axis
189/// does not silently mix garbage into the result.
190pub fn zero_padded_rows(embeddings: &Tensor, mask: &Tensor) -> Result<Tensor> {
191    let keep = mask.to_dtype(embeddings.dtype())?.unsqueeze(2)?;
192    Ok(embeddings.broadcast_mul(&keep)?)
193}
194
195/// The additive value used for padded key positions, chosen per dtype.
196///
197/// It has to be large enough that `exp(score + fill - max)` underflows to zero
198/// and small enough to stay finite in the tensor's dtype. F16 tops out at
199/// ±65504, so the usual `-1e9` (or `f32::MIN`) would saturate to `-inf` there;
200/// `-1e4` is still ~10^4 below any realistic scaled dot product, so softmax
201/// returns exactly 0 for those positions in every supported dtype.
202pub fn attention_mask_fill_value(dtype: DType) -> f64 {
203    match dtype {
204        DType::F16 => -1e4,
205        _ => -1e9,
206    }
207}
208
209/// Turn a `(batch, seq_len)` padding mask (1 = real token, 0 = pad) into an
210/// additive `(batch, seq_len)` bias in `dtype`.
211///
212/// The bias is `0` at real tokens and [`attention_mask_fill_value`] at pads,
213/// and is meant to be added to the attention **scores before softmax**.
214/// Multiplying the probabilities after softmax is not equivalent: it leaves the
215/// surviving probabilities un-renormalised and still lets pad positions steal
216/// mass.
217///
218/// The two ported architectures that take an additive mask want it at
219/// different ranks — ESM-2 as `(batch, 1, 1, seq)`, AMPLIFY as `(batch, seq)`
220/// which it broadcasts itself — so this returns the 2-D core and each model
221/// reshapes.
222pub fn additive_padding_mask(mask: &Tensor, dtype: DType) -> Result<Tensor> {
223    let (batch, seq_len) = mask.dims2()?;
224    let mask = mask.to_dtype(dtype)?;
225    let ones = Tensor::ones((batch, seq_len), dtype, mask.device())?;
226    // (1 - mask) * fill → 0 where real, `fill` where pad.
227    Ok((ones - &mask)?.affine(attention_mask_fill_value(dtype), 0.0)?)
228}
229
230/// Right-pad a set of `(1, rows_i, d)` tensors with zeros to a common row
231/// count and concatenate them into `(B, rows_max, d)`.
232pub fn stack_padded(rows: &[Tensor]) -> Result<Tensor> {
233    let first = rows
234        .first()
235        .ok_or_else(|| anyhow!("stack_padded: empty batch — there is nothing to stack"))?;
236    let width = first.dim(2)?;
237    let max_rows = rows
238        .iter()
239        .map(|t| t.dim(1))
240        .collect::<candle_core::Result<Vec<_>>>()?
241        .into_iter()
242        .max()
243        .unwrap_or(0);
244
245    let mut padded = Vec::with_capacity(rows.len());
246    for (i, t) in rows.iter().enumerate() {
247        if t.dim(0)? != 1 {
248            bail!(
249                "stack_padded: element {i} has batch dimension {}, expected 1",
250                t.dim(0)?
251            );
252        }
253        if t.dim(2)? != width {
254            bail!(
255                "stack_padded: element {i} is {} wide but element 0 is {width} wide",
256                t.dim(2)?
257            );
258        }
259        let n = t.dim(1)?;
260        padded.push(if n == max_rows {
261            t.clone()
262        } else {
263            let pad = Tensor::zeros((1, max_rows - n, width), t.dtype(), t.device())?;
264            Tensor::cat(&[t, &pad], 1)?
265        });
266    }
267    Ok(Tensor::cat(&padded, 0)?)
268}
269
270// ── PlmRunner ─────────────────────────────────────────────────────────────────
271
272/// Trait implemented by all PLM runner types.
273///
274/// Structure and inverse-folding runners (`ProteinMPNNRunner`,
275/// `StructureEncoderRunner`) deliberately stay outside this trait — they
276/// consume or emit structure, not sequence embeddings.
277pub trait PlmRunner {
278    /// Run a forward pass on `sequence` and return raw per-residue embeddings.
279    ///
280    /// Shape: `(1, L + special, d_model)`, where `special` is
281    /// [`special_tokens().total()`][PlmRunner::special_tokens]. Rows for
282    /// special tokens are **included**; use
283    /// [`embed_residues`][Self::embed_residues] to get one row per residue.
284    fn embed(&self, sequence: &str) -> Result<Tensor>;
285
286    /// Model name / identifier string (e.g. "esm2", "amplify", "esmc").
287    fn model_name(&self) -> &str;
288
289    /// The special tokens this runner's tokenizer adds around a sequence.
290    fn special_tokens(&self) -> SpecialTokenLayout;
291
292    /// Architecture dimensions, for sizing downstream layers.
293    fn metadata(&self) -> ModelMetadata;
294
295    /// Device the model's weights live on.
296    fn device(&self) -> &Device;
297
298    /// How many residues `sequence` encodes.
299    ///
300    /// One per byte for the standard amino-acid alphabets, which is the
301    /// default. SaProt is the exception: it tokenizes each residue as an
302    /// (amino acid, 3Di structural state) pair over a 20x20 product alphabet,
303    /// so `"MdAaLp"` is three residues, not six (ferritin-goh.3).
304    ///
305    /// [`embed_residues`][Self::embed_residues] uses this rather than
306    /// `sequence.len()`, so a model whose alphabet is not one byte per residue
307    /// still gets exactly one row per residue.
308    fn residue_count(&self, sequence: &str) -> usize {
309        sequence.len()
310    }
311
312    /// Per-residue embeddings with special-token rows removed.
313    ///
314    /// Shape: `(1, sequence.len(), d_model)` — guaranteed, and therefore
315    /// comparable row-for-row across models.
316    ///
317    /// Provided in terms of [`embed`][Self::embed] and
318    /// [`special_tokens`][Self::special_tokens], so an implementor declares
319    /// its layout rather than reimplementing the strip — which callers used to
320    /// hand-roll as `.narrow(1, 1, l)` against one model's layout specifically.
321    fn embed_residues(&self, sequence: &str) -> Result<Tensor> {
322        let raw = self.embed(sequence)?;
323        let layout = self.special_tokens();
324        let residues = self.residue_count(sequence);
325        let rows = raw.dim(1)?;
326        let expected = residues + layout.total();
327        if rows != expected {
328            bail!(
329                "{}: embed() returned {rows} rows for a {residues}-residue sequence, but its \
330                 declared special-token layout ({} leading, {} trailing) implies {expected}. \
331                 The runner's SpecialTokenLayout does not match its tokenizer.",
332                self.model_name(),
333                layout.leading,
334                layout.trailing,
335            );
336        }
337        Ok(raw.narrow(1, layout.leading, residues)?)
338    }
339
340    /// Masked-LM logits over the token vocabulary.
341    ///
342    /// Shape: `(1, L + special, vocab_size)` — the raw form, aligned with
343    /// [`embed`][Self::embed] rather than
344    /// [`embed_residues`][Self::embed_residues].
345    ///
346    /// Defaults to an error: not every runner has a masked-LM head, and
347    /// silently returning something else would be worse than refusing.
348    fn logits(&self, _sequence: &str) -> Result<Tensor> {
349        bail!("{} does not expose masked-LM logits", self.model_name())
350    }
351
352    /// Embed several sequences in **one** forward pass.
353    ///
354    /// Shape: `(B, T_max, d_model)`, where `T_max` is the longest
355    /// `residue_count + special_tokens().total()` in `sequences`. Rows are
356    /// right-padded and **every padded row is exactly zero**, so the result is
357    /// the same whatever each architecture happens to compute at a pad
358    /// position.
359    ///
360    /// Row `i`'s real content occupies
361    /// `0 .. residue_count(sequences[i]) + special_tokens().total()`, which
362    /// means the *trailing* special token sits at a different column in each
363    /// row. Use [`embed_residues_batch`][Self::embed_residues_batch] when you
364    /// want column `j` to be residue `j` for every row.
365    ///
366    /// # Default implementation
367    ///
368    /// Loops over [`embed`][Self::embed] and zero-pads. Correct for any
369    /// runner, but it gives up the throughput this method exists for: `B`
370    /// kernel launches instead of one. Override it with a real batched forward
371    /// pass — which requires the model to honour a padding mask, because
372    /// without one the padded keys leak into attention and change the *real*
373    /// residues' embeddings, a silent quality regression rather than an error.
374    fn embed_batch(&self, sequences: &[&str]) -> Result<Tensor> {
375        if sequences.is_empty() {
376            bail!(
377                "{}: embed_batch called with an empty batch",
378                self.model_name()
379            );
380        }
381        let rows = sequences
382            .iter()
383            .map(|s| self.embed(s))
384            .collect::<Result<Vec<_>>>()?;
385        stack_padded(&rows)
386    }
387
388    /// Per-residue embeddings for a batch, special-token rows removed.
389    ///
390    /// Shape: `(B, R_max, d_model)` where `R_max` is the largest
391    /// [`residue_count`][Self::residue_count]. Row `i`, column `j` is residue
392    /// `j` of `sequences[i]`; columns past that sequence's length are exactly
393    /// zero. This is the batched analogue of
394    /// [`embed_residues`][Self::embed_residues] and the form to pool over.
395    ///
396    /// Provided in terms of [`embed_batch`][Self::embed_batch], and it
397    /// re-checks the row count: an override that pads to the wrong length, or
398    /// declares a `SpecialTokenLayout` its tokenizer does not follow, fails
399    /// here rather than returning misaligned residues.
400    fn embed_residues_batch(&self, sequences: &[&str]) -> Result<Tensor> {
401        if sequences.is_empty() {
402            bail!(
403                "{}: embed_residues_batch called with an empty batch",
404                self.model_name()
405            );
406        }
407        let raw = self.embed_batch(sequences)?;
408        let layout = self.special_tokens();
409        let counts: Vec<usize> = sequences.iter().map(|s| self.residue_count(s)).collect();
410        let max_residues = counts.iter().copied().max().unwrap_or(0);
411
412        let batch = raw.dim(0)?;
413        if batch != sequences.len() {
414            bail!(
415                "{}: embed_batch returned {batch} rows for {} sequences",
416                self.model_name(),
417                sequences.len()
418            );
419        }
420        let rows = raw.dim(1)?;
421        let expected = max_residues + layout.total();
422        if rows != expected {
423            bail!(
424                "{}: embed_batch returned {rows} columns for a batch whose longest sequence has \
425                 {max_residues} residues, but its declared special-token layout ({} leading, {} \
426                 trailing) implies {expected}.",
427                self.model_name(),
428                layout.leading,
429                layout.trailing,
430            );
431        }
432
433        let per_row = counts
434            .iter()
435            .enumerate()
436            .map(|(i, &n)| Ok(raw.narrow(0, i, 1)?.narrow(1, layout.leading, n)?))
437            .collect::<Result<Vec<_>>>()?;
438        stack_padded(&per_row)
439    }
440}
441
442// ── Tests ─────────────────────────────────────────────────────────────────────
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use candle_core::{DType, Device};
448
449    /// Emits `sequence.len() + layout.total()` rows, so `embed_residues` has
450    /// something consistent to strip.
451    ///
452    /// Every value in a row is `sequence.len() as f32`, which is never 0 for a
453    /// non-empty sequence — so the batching tests can tell a model-produced row
454    /// from a zero pad row, and can tell which sequence a row came from.
455    struct MockRunner {
456        layout: SpecialTokenLayout,
457        /// When set, `embed` lies about its row count to exercise the guard.
458        rows_override: Option<usize>,
459    }
460
461    impl MockRunner {
462        fn new(layout: SpecialTokenLayout) -> Self {
463            Self {
464                layout,
465                rows_override: None,
466            }
467        }
468    }
469
470    impl PlmRunner for MockRunner {
471        fn embed(&self, sequence: &str) -> Result<Tensor> {
472            let rows = self
473                .rows_override
474                .unwrap_or(sequence.len() + self.layout.total());
475            Ok(
476                (Tensor::ones((1usize, rows, 16usize), DType::F32, &Device::Cpu)?
477                    * sequence.len() as f64)?,
478            )
479        }
480
481        fn model_name(&self) -> &str {
482            "mock"
483        }
484
485        fn special_tokens(&self) -> SpecialTokenLayout {
486            self.layout
487        }
488
489        fn metadata(&self) -> ModelMetadata {
490            ModelMetadata {
491                d_model: 16,
492                n_layers: 2,
493                vocab_size: 33,
494                max_positions: Some(1024),
495            }
496        }
497
498        fn device(&self) -> &Device {
499            &Device::Cpu
500        }
501    }
502
503    #[test]
504    fn test_special_token_layout_totals() {
505        assert_eq!(SpecialTokenLayout::NONE.total(), 0);
506        assert_eq!(SpecialTokenLayout::BOS_EOS.total(), 2);
507    }
508
509    #[test]
510    fn test_mock_runner_model_name() {
511        assert_eq!(
512            MockRunner::new(SpecialTokenLayout::BOS_EOS).model_name(),
513            "mock"
514        );
515    }
516
517    /// `embed` keeps the special-token rows.
518    #[test]
519    fn test_embed_includes_special_tokens() {
520        let runner = MockRunner::new(SpecialTokenLayout::BOS_EOS);
521        let t = runner.embed("ACDE").unwrap();
522        assert_eq!(t.dims(), &[1, 6, 16], "4 residues + BOS + EOS");
523    }
524
525    /// `embed_residues` strips them, giving exactly one row per residue.
526    #[test]
527    fn test_embed_residues_strips_special_tokens() {
528        let runner = MockRunner::new(SpecialTokenLayout::BOS_EOS);
529        let t = runner.embed_residues("ACDE").unwrap();
530        assert_eq!(t.dims(), &[1, 4, 16]);
531    }
532
533    /// A runner with no special tokens is passed through unchanged.
534    #[test]
535    fn test_embed_residues_noop_for_no_special_tokens() {
536        let runner = MockRunner::new(SpecialTokenLayout::NONE);
537        let t = runner.embed_residues("ACDE").unwrap();
538        assert_eq!(t.dims(), &[1, 4, 16]);
539    }
540
541    /// Asymmetric layouts strip from the correct end.
542    #[test]
543    fn test_embed_residues_handles_leading_only() {
544        let runner = MockRunner::new(SpecialTokenLayout {
545            leading: 1,
546            trailing: 0,
547        });
548        let t = runner.embed_residues("ACDE").unwrap();
549        assert_eq!(t.dims(), &[1, 4, 16]);
550    }
551
552    /// A layout that disagrees with the tokenizer is reported, not silently
553    /// turned into misaligned residues — the whole point of the contract.
554    #[test]
555    fn test_embed_residues_rejects_layout_mismatch() {
556        let runner = MockRunner {
557            layout: SpecialTokenLayout::BOS_EOS,
558            rows_override: Some(4), // claims BOS_EOS but returns bare residues
559        };
560        let err = runner
561            .embed_residues("ACDE")
562            .map(|_| ())
563            .expect_err("a layout that does not match the row count must error");
564        assert!(
565            err.to_string().contains("does not match its tokenizer"),
566            "error should name the contract violation; got: {err}"
567        );
568    }
569
570    #[test]
571    fn test_metadata_is_reported() {
572        let md = MockRunner::new(SpecialTokenLayout::BOS_EOS).metadata();
573        assert_eq!(md.d_model, 16);
574        assert_eq!(md.vocab_size, 33);
575        assert_eq!(md.max_positions, Some(1024));
576    }
577
578    /// Runners without a masked-LM head refuse rather than returning something
579    /// that is not logits.
580    #[test]
581    fn test_logits_defaults_to_unsupported() {
582        let err = MockRunner::new(SpecialTokenLayout::BOS_EOS)
583            .logits("ACDE")
584            .map(|_| ())
585            .expect_err("the default logits() must refuse");
586        assert!(err.to_string().contains("does not expose masked-LM logits"));
587    }
588
589    // ── Batching ──────────────────────────────────────────────────────────
590
591    fn values(t: &Tensor) -> Vec<f32> {
592        t.flatten_all().unwrap().to_vec1().unwrap()
593    }
594
595    /// The default `embed_batch` pads to the longest row and reports the shape
596    /// the doc comment promises.
597    #[test]
598    fn test_embed_batch_pads_to_longest_sequence() {
599        let runner = MockRunner::new(SpecialTokenLayout::BOS_EOS);
600        let t = runner.embed_batch(&["ACDEFG", "AC"]).unwrap();
601        assert_eq!(t.dims(), &[2, 8, 16], "6 residues + BOS + EOS");
602    }
603
604    /// Padded rows are exactly zero, and the real rows still carry the model's
605    /// own values — the guarantee that lets a caller pool over the sequence
606    /// axis without a length vector.
607    #[test]
608    fn test_embed_batch_zero_pads_short_rows() {
609        let runner = MockRunner::new(SpecialTokenLayout::BOS_EOS);
610        let t = runner.embed_batch(&["ACDEFG", "AC"]).unwrap();
611        let short = t.narrow(0, 1, 1).unwrap();
612        // "AC" occupies 2 + 2 = 4 of the 8 columns.
613        assert!(
614            values(&short.narrow(1, 0, 4).unwrap())
615                .iter()
616                .all(|&v| v == 2.0),
617            "real rows should keep the model's output"
618        );
619        assert!(
620            values(&short.narrow(1, 4, 4).unwrap())
621                .iter()
622                .all(|&v| v == 0.0),
623            "padded rows should be exactly zero"
624        );
625    }
626
627    /// `embed_residues_batch` puts residue `j` at column `j` in **every** row,
628    /// which `embed_batch` does not: its trailing special token sits at a
629    /// different column per row.
630    #[test]
631    fn test_embed_residues_batch_aligns_columns_across_rows() {
632        let runner = MockRunner::new(SpecialTokenLayout::BOS_EOS);
633        let t = runner.embed_residues_batch(&["ACDEFG", "AC"]).unwrap();
634        assert_eq!(t.dims(), &[2, 6, 16]);
635
636        let short = t.narrow(0, 1, 1).unwrap();
637        assert!(
638            values(&short.narrow(1, 0, 2).unwrap())
639                .iter()
640                .all(|&v| v == 2.0),
641            "the two real residues of \"AC\" should survive the strip"
642        );
643        assert!(
644            values(&short.narrow(1, 2, 4).unwrap())
645                .iter()
646                .all(|&v| v == 0.0),
647            "columns past a sequence's length should be zero, not its EOS row"
648        );
649    }
650
651    /// `EOS_ONLY` has `leading = 0`, so the strip starts at column 0 rather
652    /// than skipping a BOS row. ProtT5 is the only model with that layout, and
653    /// before it every batching path was exercised at `leading = 1` only — an
654    /// off-by-one that happened to be invisible while every runner was
655    /// `BOS_EOS` (ferritin-goh.5 meeting ferritin-100.12).
656    #[test]
657    fn test_embed_residues_batch_handles_a_leading_free_layout() {
658        let runner = MockRunner::new(SpecialTokenLayout::EOS_ONLY);
659        let t = runner.embed_residues_batch(&["ACDEFG", "AC"]).unwrap();
660        assert_eq!(t.dims(), &[2, 6, 16]);
661
662        // Row 1 is "AC": two real residues, then zeros — and crucially the
663        // first row must be residue 0, not a stripped special token.
664        let short = t.narrow(0, 1, 1).unwrap();
665        assert!(
666            values(&short.narrow(1, 0, 2).unwrap())
667                .iter()
668                .all(|&v| v == 2.0),
669            "with leading = 0 the strip must start at column 0"
670        );
671        assert!(
672            values(&short.narrow(1, 2, 4).unwrap())
673                .iter()
674                .all(|&v| v == 0.0),
675            "columns past the sequence should be zero, not the EOS row"
676        );
677    }
678
679    /// A batch of one is the same thing `embed` returns.
680    #[test]
681    fn test_embed_batch_of_one_matches_embed() {
682        let runner = MockRunner::new(SpecialTokenLayout::BOS_EOS);
683        let single = runner.embed("ACDE").unwrap();
684        let batched = runner.embed_batch(&["ACDE"]).unwrap();
685        assert_eq!(single.dims(), batched.dims());
686        assert_eq!(values(&single), values(&batched));
687    }
688
689    /// An empty batch is a caller bug, not a zero-row tensor.
690    #[test]
691    fn test_embed_batch_rejects_empty_input() {
692        let runner = MockRunner::new(SpecialTokenLayout::BOS_EOS);
693        assert!(runner.embed_batch(&[]).is_err());
694        assert!(runner.embed_residues_batch(&[]).is_err());
695    }
696
697    /// The same guard `embed_residues` has, at batch rank: a runner whose
698    /// `embed_batch` disagrees with its declared layout fails loudly.
699    #[test]
700    fn test_embed_residues_batch_rejects_layout_mismatch() {
701        let runner = MockRunner {
702            layout: SpecialTokenLayout::BOS_EOS,
703            rows_override: Some(4), // claims BOS_EOS but returns bare residues
704        };
705        let err = runner.embed_residues_batch(&["ACDE"]).unwrap_err();
706        assert!(
707            err.to_string().contains("special-token layout"),
708            "unexpected error: {err}"
709        );
710    }
711
712    #[test]
713    fn test_pad_token_batch_shapes_ids_and_mask() {
714        let batch = pad_token_batch(&[vec![0, 5, 7, 2], vec![0, 6, 2]], 1, &Device::Cpu).unwrap();
715        assert_eq!(batch.lengths, vec![4, 3]);
716        assert_eq!(batch.padded_len(), 4);
717        assert_eq!(
718            batch.ids.to_vec2::<u32>().unwrap(),
719            vec![vec![0, 5, 7, 2], vec![0, 6, 2, 1]],
720            "the short row should be right-padded with the pad id"
721        );
722        assert_eq!(
723            batch.mask.to_vec2::<u8>().unwrap(),
724            vec![vec![1, 1, 1, 1], vec![1, 1, 1, 0]]
725        );
726    }
727
728    #[test]
729    fn test_pad_token_batch_rejects_empty() {
730        assert!(pad_token_batch(&[], 1, &Device::Cpu).is_err());
731    }
732
733    #[test]
734    fn test_zero_padded_rows_clears_only_pads() {
735        let device = Device::Cpu;
736        let x = Tensor::ones((1usize, 3usize, 2usize), DType::F32, &device).unwrap();
737        let mask = Tensor::new(&[[1u8, 1u8, 0u8]], &device).unwrap();
738        let out = zero_padded_rows(&x, &mask).unwrap();
739        assert_eq!(values(&out), vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0]);
740    }
741
742    /// The additive form: 0 at real tokens, strongly negative at pads.
743    #[test]
744    fn test_additive_padding_mask_values() {
745        let mask = Tensor::new(&[[1f32, 1f32, 0f32]], &Device::Cpu).unwrap();
746        let bias = additive_padding_mask(&mask, DType::F32).unwrap();
747        assert_eq!(bias.dims(), &[1, 3]);
748        let v = values(&bias);
749        assert_eq!(v[0], 0.0);
750        assert_eq!(v[1], 0.0);
751        assert!(v[2] <= -1e4, "pad position bias was {}", v[2]);
752    }
753
754    /// The masked-score fill value must stay finite in every supported dtype;
755    /// F16 saturates past ±65504, which would turn the bias into `-inf`.
756    #[test]
757    fn test_attention_mask_fill_value_is_dtype_safe() {
758        for dtype in [DType::F32, DType::F64, DType::BF16, DType::F16] {
759            let v = attention_mask_fill_value(dtype);
760            assert!(
761                v <= -1e4,
762                "fill value {v} for {dtype:?} is not negative enough"
763            );
764        }
765        assert!(
766            attention_mask_fill_value(DType::F16) > -65504.0,
767            "F16 fill value must stay inside the F16 range"
768        );
769    }
770
771    #[test]
772    fn test_stack_padded_rejects_width_mismatch() {
773        let device = Device::Cpu;
774        let a = Tensor::zeros((1usize, 2usize, 4usize), DType::F32, &device).unwrap();
775        let b = Tensor::zeros((1usize, 2usize, 8usize), DType::F32, &device).unwrap();
776        assert!(stack_padded(&[a, b]).is_err());
777    }
778}