Skip to main content

ferrox_models/
encoder.rs

1//! The seam for encoder-only (embedding) models, which is deliberately
2//! **not** [`crate::engine::Engine`].
3//!
4//! # Why a second trait and not a variant of the first
5//!
6//! [`crate::engine::Engine`] is `forward_token(token_id, pos, &mut
7//! State) -> Vec<f32>`: one token in, one logit vector out, carrying
8//! per-layer state forward. Every part of that signature is an
9//! autoregression assumption, and a BERT encoder violates all of them:
10//!
11//! * **There is no state to carry.** Attention is bidirectional, so
12//!   token 0's output depends on token 7. Nothing can be computed until
13//!   the whole sequence is present, and nothing computed for one
14//!   sequence is reusable for the next. A KV cache is not merely
15//!   unnecessary here, it is meaningless — there is no "next token" for
16//!   a cached key to be attended to by.
17//! * **There are no logits.** The result is `n_tokens × n_embd` hidden
18//!   states (llama.cpp's `res->t_embd`); this checkpoint has no output
19//!   head at all, and `token_embd.weight` is not tied to one.
20//! * **`pos` is not a cursor, it is an index into a learned table.**
21//!   BERT adds `position_embd.weight[i]` to the token embedding rather
22//!   than rotating Q/K, which is why the sequence length is hard-capped
23//!   by `n_ctx_train` instead of merely degrading past it.
24//!
25//! Forcing that through `Engine` would mean a `State` that is a
26//! pretend-cache, a `forward_token` that can only be called with the
27//! last position after a hidden batch call, and a `vocab_size` that has
28//! no meaning. The Kimi comment on `Engine` already records the cost of
29//! bending a trait around a model it does not fit; this is the same
30//! judgement, made before rather than after.
31//!
32//! So: [`TextEncoder`] is sequence-in, matrix-out, stateless. What the
33//! two seams *do* share is pooling ([`crate::pooling`]), which is why
34//! that lives in its own module and not in either of them.
35
36use crate::pooling::{pool, PoolingError, PoolingType};
37use thiserror::Error;
38
39#[derive(Debug, Error)]
40pub enum EncodeError {
41    #[error("an encoder needs at least one token; got an empty sequence")]
42    EmptySequence,
43    #[error(
44        "sequence of {got} tokens exceeds the {max} learned position embeddings this \
45         checkpoint carries ({arch}.context_length). A learned position table cannot be \
46         extrapolated the way RoPE can, so this is a hard limit, not a quality cliff — \
47         truncate the input or use a longer-context embedding model"
48    )]
49    TooLong {
50        got: usize,
51        max: usize,
52        arch: String,
53    },
54    #[error("token id {id} is outside this checkpoint's {vocab_size}-entry vocabulary")]
55    TokenOutOfRange { id: u32, vocab_size: usize },
56    #[error(transparent)]
57    Pooling(#[from] PoolingError),
58}
59
60/// A model that turns a whole token sequence into hidden states in one
61/// pass, with no carried state and no logits.
62pub trait TextEncoder {
63    /// Width of one hidden-state row, and of the pooled embedding.
64    fn n_embd(&self) -> usize;
65
66    /// Longest sequence this checkpoint can represent. For a learned
67    /// position table this is the table's height, and exceeding it is
68    /// an error rather than a degradation.
69    fn n_ctx_train(&self) -> usize;
70
71    /// What the checkpoint's own `{arch}.pooling_type` said.
72    fn pooling_type(&self) -> PoolingType;
73
74    /// Wraps a tokenizer's pieces in whatever the *model* requires
75    /// around them — for BERT, `[CLS] … [SEP]`.
76    ///
77    /// This is on the encoder rather than the tokenizer because ferrox's
78    /// tokenizers deliberately encode text only (they are checked
79    /// token-for-token against `llama_tokenize(..., add_special =
80    /// false, ...)`), while llama.cpp keeps `add_special` in the vocab
81    /// and applies it here. The default adds nothing, so an encoder that
82    /// genuinely needs no wrapper does not have to say so.
83    fn wrap_special(&self, pieces: &[u32]) -> Vec<u32> {
84        pieces.to_vec()
85    }
86
87    /// The two-segment input a **cross-encoder** scores: one sequence
88    /// holding a query and a document with the model's own boundary
89    /// between them. For BERT that is `[CLS] a [SEP] b [SEP]`.
90    ///
91    /// `None` — the default — means this encoder has no two-segment
92    /// form, and a caller that needs one must refuse. Deliberately NOT
93    /// defaulted to `wrap_special(a ++ b)`: a cross-encoder was trained
94    /// with a separator between the halves, and one that never sees it
95    /// still returns a plausible float. That is the "computes something
96    /// else" failure, and it is invisible — a rerank with no boundary
97    /// produces an ordering, just not the model's.
98    fn wrap_special_pair(&self, _a: &[u32], _b: &[u32]) -> Option<Vec<u32>> {
99        None
100    }
101
102    /// `n_tokens × n_embd` hidden states, in row order.
103    fn encode_tokens(&self, tokens: &[u32]) -> Result<Vec<f32>, EncodeError>;
104
105    /// [`Self::encode_tokens`] followed by the checkpoint's own pooling.
106    /// Not L2-normalized — see [`crate::pooling::pool`].
107    fn embed_tokens(&self, tokens: &[u32]) -> Result<Vec<f32>, EncodeError> {
108        let hidden = self.encode_tokens(tokens)?;
109        Ok(pool(&hidden, self.n_embd(), self.pooling_type())?)
110    }
111}