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}