frink_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(
57 "layer {layer} runs a gated FFN and carries no ffn_gate; the loader builds the \
58 pair together, so this is a bug in frink rather than in the checkpoint"
59 )]
60 MissingGate { layer: usize },
61 #[error(
62 "segment id {id} at position {pos} is outside this checkpoint's {n_segments}-row \
63 token-type table"
64 )]
65 SegmentOutOfRange {
66 id: u32,
67 pos: usize,
68 n_segments: usize,
69 },
70 #[error(
71 "{tokens} token(s) were given {segments} segment id(s); every position needs exactly \
72 one, or the graph would add a segment embedding to the wrong row"
73 )]
74 RaggedSegments { tokens: usize, segments: usize },
75 #[error(transparent)]
76 Pooling(#[from] PoolingError),
77}
78
79/// One two-segment encoder input: the token ids and, for each of them,
80/// which half of the pair it belongs to.
81///
82/// The two vectors are built together and travel together on purpose.
83/// They are the repo's dominant bug shape waiting to happen — two
84/// structures that must agree, here about a length and about where the
85/// boundary is — and the single place that can get them right is
86/// [`TextEncoder::wrap_special_pair`], which inserts the `[SEP]` that
87/// the boundary is defined by. Handing a caller the ids alone (what
88/// this seam used to do) meant the segment ids did not exist at all
89/// and every position was scored as "Sentence A".
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct PairSequence {
92 /// `[CLS] a [SEP] b [SEP]` for BERT.
93 pub tokens: Vec<u32>,
94 /// `0` for the `[CLS] a [SEP]` half, `1` for the `b [SEP]` half —
95 /// HuggingFace's `token_type_ids`. Always the same length as
96 /// [`Self::tokens`].
97 pub segments: Vec<u32>,
98}
99
100/// A model that turns a whole token sequence into hidden states in one
101/// pass, with no carried state and no logits.
102///
103/// `Sync` because [`TextEncoder::encode`] hands `&self` to a rayon
104/// worker for the duration of the pass; see its doc comment.
105pub trait TextEncoder: Sync {
106 /// Width of one hidden-state row, and of the pooled embedding.
107 fn n_embd(&self) -> usize;
108
109 /// Longest sequence this checkpoint can represent. For a learned
110 /// position table this is the table's height, and exceeding it is
111 /// an error rather than a degradation.
112 fn n_ctx_train(&self) -> usize;
113
114 /// What the checkpoint's own `{arch}.pooling_type` said.
115 fn pooling_type(&self) -> PoolingType;
116
117 /// The BERT-family hyper-parameters, for a caller that needs the
118 /// facts the architecture decides -- the rotation and the FFN
119 /// shape (`crate::bert_encoder`). `None` for an encoder that is
120 /// not on that graph, so a second encoder family does not have to
121 /// invent a `BertHparams` to answer.
122 fn bert_hparams(&self) -> Option<&crate::bert_encoder::BertHparams> {
123 None
124 }
125
126 /// Wraps a tokenizer's pieces in whatever the *model* requires
127 /// around them — for BERT, `[CLS] … [SEP]`.
128 ///
129 /// This is on the encoder rather than the tokenizer because frink's
130 /// tokenizers deliberately encode text only (they are checked
131 /// token-for-token against `llama_tokenize(..., add_special =
132 /// false, ...)`), while llama.cpp keeps `add_special` in the vocab
133 /// and applies it here. The default adds nothing, so an encoder that
134 /// genuinely needs no wrapper does not have to say so.
135 fn wrap_special(&self, pieces: &[u32]) -> Vec<u32> {
136 pieces.to_vec()
137 }
138
139 /// How many rows the checkpoint's token-type ("segment") table
140 /// carries, i.e. the number of distinct segment ids
141 /// [`Self::encode`] will accept.
142 ///
143 /// `1` — the default — means the encoder can only represent
144 /// "Sentence A", which is enough for an embedding pass and is not
145 /// enough for a cross-encoder pair. Read at load time by
146 /// [`crate::EmbeddingModel`], which refuses a reranker checkpoint
147 /// that cannot express segment 1 rather than silently scoring the
148 /// document half as segment 0.
149 fn n_segments(&self) -> usize {
150 1
151 }
152
153 /// The two-segment input a **cross-encoder** scores: one sequence
154 /// holding a query and a document with the model's own boundary
155 /// between them, and the segment id of every position. For BERT
156 /// that is `[CLS] a [SEP] b [SEP]` with segments `0…0 1…1`, which is
157 /// exactly what HuggingFace's `tokenizer(query, document)` emits.
158 ///
159 /// `None` — the default — means this encoder has no two-segment
160 /// form, and a caller that needs one must refuse. Deliberately NOT
161 /// defaulted to `wrap_special(a ++ b)`: a cross-encoder was trained
162 /// with a separator between the halves, and one that never sees it
163 /// still returns a plausible float. That is the "computes something
164 /// else" failure, and it is invisible — a rerank with no boundary
165 /// produces an ordering, just not the model's.
166 fn wrap_special_pair(&self, _a: &[u32], _b: &[u32]) -> Option<PairSequence> {
167 None
168 }
169
170 /// `n_tokens × n_embd` hidden states, in row order.
171 ///
172 /// `segments` is the per-position segment id, or `None` for "all
173 /// zeros" — the single-sequence case. There is deliberately ONE
174 /// graph with a parameter rather than a segment-aware copy of a
175 /// segment-blind one: this repo has lost a model feature to every
176 /// copied forward pass it has ever had, and the pair path is
177 /// exercised far less often than the embedding path, so a copy is
178 /// exactly where a fix would fail to land.
179 ///
180 /// This is the body an encoder writes; callers want
181 /// [`Self::encode`], which is the same computation with the CPU
182 /// worker pool entered once for the whole pass.
183 fn encode_on_worker(
184 &self,
185 tokens: &[u32],
186 segments: Option<&[u32]>,
187 ) -> Result<Vec<f32>, EncodeError>;
188
189 /// [`Self::encode_on_worker`], with the CPU worker pool entered once
190 /// for the whole pass.
191 ///
192 /// Same rule, and the same reason, as
193 /// [`crate::engine::Engine::forward_token`]: a forward pass through
194 /// a stack of quantized projections opens a parallel region per
195 /// matmul, and every one of them costs a pthread park and wake when
196 /// the driving thread is not a rayon worker. An encoder pass is one
197 /// pass over the whole sequence rather than one per token, so the
198 /// saving is smaller than a decode loop's -- but it is the same
199 /// saving, and an encoder that had to remember to ask for it would
200 /// be one more place for the rule to be forgotten.
201 ///
202 /// Do not override. See
203 /// [`frink_core::par::on_workers`] for the three cases it declines
204 /// to promote.
205 fn encode(&self, tokens: &[u32], segments: Option<&[u32]>) -> Result<Vec<f32>, EncodeError> {
206 frink_core::par::on_workers(move || self.encode_on_worker(tokens, segments))
207 }
208
209 /// [`Self::encode`] for a single sequence: every position is
210 /// segment 0.
211 fn encode_tokens(&self, tokens: &[u32]) -> Result<Vec<f32>, EncodeError> {
212 self.encode(tokens, None)
213 }
214
215 /// [`Self::encode_tokens`] followed by the checkpoint's own pooling.
216 /// Not L2-normalized — see [`crate::pooling::pool`].
217 fn embed_tokens(&self, tokens: &[u32]) -> Result<Vec<f32>, EncodeError> {
218 let hidden = self.encode_tokens(tokens)?;
219 Ok(pool(&hidden, self.n_embd(), self.pooling_type())?)
220 }
221}