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}