ferrox_models/rank_head.rs
1//! The reranker classification head: `cls`, `cls.output`, `cls.norm`
2//! and `{arch}.classifier.output_labels`.
3//!
4//! Transcribed from llama.cpp `llm_graph_context::build_pooling`'s
5//! `LLAMA_POOLING_TYPE_RANK` arm (`src/llama-graph.cpp`), which is a
6//! **classification head and not a pooling rule** — the reason
7//! [`crate::pooling::PoolingType::Rank`] still refuses in
8//! [`crate::pooling::pool`] and always will. `pool` sees hidden states
9//! and a width; it cannot see these matrices, so RANK is not a
10//! question it can answer. The rank path is CLS pooling *followed by*
11//! this head, and this module is the "followed by".
12//!
13//! # The graph, for the `bert` shape
14//!
15//! ```text
16//! cur = hidden[CLS] (row 0 — see below)
17//! if cls: cur = cls · cur + cls_b
18//! cur = tanh(cur)
19//! if cls_norm: cur = LayerNorm(cur, cls_norm, no bias)
20//! if cls_out: cur = cls_out · cur + cls_out_b
21//! score = cur[0]
22//! ```
23//!
24//! Three of upstream's branches are deliberately **not** here, and each
25//! is refused rather than approximated, because each belongs to an
26//! architecture [`crate::bert_gguf_loader`] already refuses by name:
27//!
28//! * `modern-bert` pools with MEAN rather than CLS and uses GELU in
29//! place of the `tanh`.
30//! * `qwen3` / `qwen3vl` take the **last** token rather than row 0
31//! (`build_inp_cls`'s `last` flag, `llama-graph.cpp:296-299`) and
32//! append a softmax over the outputs.
33//! * `jina-reranker-v1-tiny-en` is the checkpoint upstream cites for
34//! the `cls_out`-absent case; it is `jina-bert-v2`, which this crate
35//! does not load.
36//!
37//! Since only `bert` reaches here, row 0 is the CLS row unconditionally
38//! and there is no softmax. If another architecture is ever admitted,
39//! this module has to grow its branch — it must not inherit `bert`'s.
40//!
41//! # Which float is the score
42//!
43//! `send_rerank` (`tools/server/server-context.cpp`) reads `embd[0]`:
44//! the FIRST of `n_cls_out` outputs, whatever the rest are. That is
45//! fine for a one-output relevance head and is a silent choice for a
46//! many-output classifier, so [`load_rank_head`] refuses a multi-output
47//! head that does not name its labels — see [`RankHead::labels`].
48
49use ferrox_core::matmul::layer_norm;
50use ferrox_core::weight_matrix::WeightMatrix;
51use ferrox_gguf::{GgufValue, ShardedGguf, TensorSource};
52
53use crate::loader::{load_f32_vec_optional, load_weight_matrix, LoadError};
54
55/// `LLM_TENSOR_CLS` / `CLS_OUT` / `CLS_NORM`, by their GGUF names
56/// (`llama-arch.cpp:428-430`).
57const CLS_W: &str = "cls.weight";
58const CLS_B: &str = "cls.bias";
59const CLS_OUT_W: &str = "cls.output.weight";
60const CLS_OUT_B: &str = "cls.output.bias";
61const CLS_NORM_W: &str = "cls.norm.weight";
62const CLS_NORM_B: &str = "cls.norm.bias";
63
64/// A dense projection with an optional bias: `W · x (+ b)`.
65struct Dense {
66 w: WeightMatrix,
67 b: Option<Vec<f32>>,
68}
69
70impl Dense {
71 fn apply(&self, x: &[f32]) -> Vec<f32> {
72 let mut out = self.w.apply(x);
73 if let Some(b) = &self.b {
74 for (o, bv) in out.iter_mut().zip(b.iter()) {
75 *o += bv;
76 }
77 }
78 out
79 }
80}
81
82/// The classification head a reranker checkpoint carries on top of the
83/// encoder.
84pub struct RankHead {
85 /// `cls` + `cls.bias`, followed by `tanh`. Optional upstream: some
86 /// checkpoints project straight to the output.
87 dense: Option<Dense>,
88 /// `cls.norm`, applied after the `tanh`. Upstream calls
89 /// `build_norm(cur, cls_norm, NULL, LLM_NORM, -1)` — LayerNorm with
90 /// a weight and **no bias**, so a `cls.norm.bias` in a checkpoint
91 /// would be a tensor this graph does not apply and is refused.
92 norm: Option<Vec<f32>>,
93 /// `cls.output` + `cls.output.bias`, the projection to `n_cls_out`.
94 out: Option<Dense>,
95 /// `{arch}.classifier.output_labels`, verbatim. Empty only when the
96 /// head has exactly one output, where a name adds nothing and
97 /// upstream's own default (`hparams.n_cls_out = 1`) is unambiguous.
98 labels: Vec<String>,
99 eps: f32,
100}
101
102impl RankHead {
103 /// How many floats [`Self::apply`] returns.
104 pub fn n_cls_out(&self) -> usize {
105 self.labels.len().max(1)
106 }
107
108 /// `{arch}.classifier.output_labels`, in output order. Empty for a
109 /// single-output head that named none.
110 pub fn labels(&self) -> &[String] {
111 &self.labels
112 }
113
114 /// Every output of the head, for `pooled` — which must already be
115 /// the CLS row, not the whole hidden-state matrix.
116 pub fn apply(&self, pooled: &[f32]) -> Vec<f32> {
117 let mut cur = pooled.to_vec();
118 if let Some(dense) = &self.dense {
119 cur = dense.apply(&cur);
120 for v in cur.iter_mut() {
121 *v = v.tanh();
122 }
123 if let Some(w) = &self.norm {
124 // No bias: `build_norm(..., NULL, ...)` upstream.
125 let zeros = vec![0.0f32; w.len()];
126 cur = layer_norm(&cur, w, &zeros, self.eps);
127 }
128 }
129 if let Some(out) = &self.out {
130 cur = out.apply(&cur);
131 }
132 cur
133 }
134
135 /// The single relevance score `/v1/rerank` reports: output 0, which
136 /// is what upstream's `send_rerank` sends (`res->score = embd[0]`).
137 pub fn score(&self, pooled: &[f32]) -> f32 {
138 self.apply(pooled).first().copied().unwrap_or(0.0)
139 }
140}
141
142fn refuse(what: String) -> LoadError {
143 LoadError::UnsupportedFeature("bert".to_string(), what)
144}
145
146/// Reads `{arch}.classifier.output_labels`, an array of strings.
147///
148/// Absent is `Ok(vec![])`, which is only *allowed* for a one-output
149/// head — [`load_rank_head`] enforces that. A key of the wrong type is
150/// an error rather than a silent empty: a checkpoint that says
151/// something about its labels and is not understood must stop the load.
152fn read_labels(file: &impl TensorSource, arch: &str) -> Result<Vec<String>, LoadError> {
153 let key = format!("{arch}.classifier.output_labels");
154 let Some(value) = file.metadata(&key) else {
155 return Ok(Vec::new());
156 };
157 match value {
158 GgufValue::Array(items) => items
159 .iter()
160 .map(|v| match v {
161 GgufValue::String(s) => Ok(s.clone()),
162 other => Err(refuse(format!(
163 "{key} contains a non-string entry {other:?}; it must be an array of \
164 label names"
165 ))),
166 })
167 .collect(),
168 other => Err(refuse(format!(
169 "{key} is {other:?}, but it must be an array of strings"
170 ))),
171 }
172}
173
174/// Builds the head a `bert` checkpoint carries, or `None` when it
175/// carries none (a plain embedding model).
176///
177/// Refuses, rather than loading something that would score wrongly:
178///
179/// * a `cls.norm.bias`, which upstream never applies;
180/// * labels whose count disagrees with `cls.output.weight`'s row count,
181/// because then one of the two is describing a different model;
182/// * a multi-output head with no labels, because
183/// [`RankHead::score`] would silently pick output 0 out of several
184/// that nothing has named.
185pub fn load_rank_head(
186 file: &ShardedGguf,
187 arch: &str,
188 n_embd: usize,
189 eps: f32,
190) -> Result<Option<RankHead>, LoadError> {
191 let has_any = [CLS_W, CLS_OUT_W, CLS_NORM_W]
192 .iter()
193 .any(|n| file.find_tensor(n).is_some());
194 let labels = read_labels(file, arch)?;
195 if !has_any {
196 // Labels without a head is a checkpoint describing a classifier
197 // whose weights are not here. Loading it as a plain embedding
198 // model would quietly drop what the file says it is.
199 if !labels.is_empty() {
200 return Err(refuse(format!(
201 "{arch}.classifier.output_labels names {} label(s) ({}) but the checkpoint \
202 carries no {CLS_W}, {CLS_OUT_W} or {CLS_NORM_W} — the classification head \
203 the labels describe is not in this file",
204 labels.len(),
205 labels.join(", "),
206 )));
207 }
208 return Ok(None);
209 }
210
211 let dense = match file.find_tensor(CLS_W) {
212 Some(_) => {
213 let w = load_weight_matrix(file, CLS_W)?;
214 if w.cols() != n_embd {
215 return Err(refuse(format!(
216 "{CLS_W} takes {} inputs but the encoder is {n_embd} wide",
217 w.cols()
218 )));
219 }
220 Some(Dense {
221 b: load_f32_vec_optional(file, CLS_B)?,
222 w,
223 })
224 }
225 None => None,
226 };
227
228 if file.find_tensor(CLS_NORM_B).is_some() {
229 return Err(refuse(format!(
230 "checkpoint carries {CLS_NORM_B}, but upstream's head norm is \
231 build_norm(cur, cls_norm, NULL, ...) — a weight and no bias. Applying the \
232 weight and dropping the bias would score wrongly and silently"
233 )));
234 }
235 let norm = load_f32_vec_optional(file, CLS_NORM_W)?;
236 if norm.is_some() && dense.is_none() {
237 return Err(refuse(format!(
238 "checkpoint carries {CLS_NORM_W} but no {CLS_W}; upstream applies the head norm \
239 only inside the `cls` branch, so this norm would never run"
240 )));
241 }
242
243 let out = match file.find_tensor(CLS_OUT_W) {
244 Some(_) => {
245 let w = load_weight_matrix(file, CLS_OUT_W)?;
246 let want = dense.as_ref().map(|d| d.w.rows()).unwrap_or(n_embd);
247 if w.cols() != want {
248 return Err(refuse(format!(
249 "{CLS_OUT_W} takes {} inputs but the value reaching it is {want} wide",
250 w.cols()
251 )));
252 }
253 Some(Dense {
254 b: load_f32_vec_optional(file, CLS_OUT_B)?,
255 w,
256 })
257 }
258 None => None,
259 };
260
261 // How many scores this head really produces, from the weights.
262 let n_out = match &out {
263 Some(d) => d.w.rows(),
264 None => match &dense {
265 Some(d) => d.w.rows(),
266 // Unreachable while `has_any` is true and norm-without-dense
267 // is refused, but stated rather than assumed.
268 None => n_embd,
269 },
270 };
271 if !labels.is_empty() && labels.len() != n_out {
272 return Err(refuse(format!(
273 "{arch}.classifier.output_labels names {} label(s) ({}) but the head produces \
274 {n_out} output(s) — the metadata and the weights describe different models",
275 labels.len(),
276 labels.join(", "),
277 )));
278 }
279 if labels.is_empty() && n_out > 1 {
280 return Err(refuse(format!(
281 "this classification head produces {n_out} outputs and the checkpoint carries no \
282 {arch}.classifier.output_labels, so nothing says which one is the relevance \
283 score. Refusing rather than reporting output 0 as if it were named"
284 )));
285 }
286
287 Ok(Some(RankHead {
288 dense,
289 norm,
290 out,
291 labels,
292 eps,
293 }))
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use ferrox_core::tensor::Tensor;
300
301 fn dense(rows: usize, cols: usize, fill: f32, bias: Option<f32>) -> Dense {
302 let data: Vec<f32> = (0..rows * cols).map(|i| fill * (i as f32 + 1.0)).collect();
303 Dense {
304 w: WeightMatrix::F32(Tensor::new(data, vec![rows, cols])),
305 b: bias.map(|b| vec![b; rows]),
306 }
307 }
308
309 /// The head's ORDER is the whole content of it: dense, tanh, norm,
310 /// output. Applying the same pieces in another order still returns
311 /// a plausible float, which is why this is checked against a
312 /// hand-computed value rather than against "it ran".
313 #[test]
314 fn the_head_applies_dense_then_tanh_then_output() {
315 let head = RankHead {
316 // 1x2, weights [1, 2], bias 0 -> 1*x0 + 2*x1
317 dense: Some(Dense {
318 w: WeightMatrix::F32(Tensor::new(vec![1.0, 2.0], vec![1, 2])),
319 b: Some(vec![0.5]),
320 }),
321 norm: None,
322 // 1x1, weight [3], bias -1 -> 3*y - 1
323 out: Some(Dense {
324 w: WeightMatrix::F32(Tensor::new(vec![3.0], vec![1, 1])),
325 b: Some(vec![-1.0]),
326 }),
327 labels: vec![],
328 eps: 1e-12,
329 };
330 // dense: 1*0.25 + 2*0.5 + 0.5 = 1.75; tanh(1.75) = 0.94138...
331 // out: 3*0.94138 - 1 = 1.82414...
332 let want = 3.0 * 1.75f32.tanh() - 1.0;
333 let got = head.score(&[0.25, 0.5]);
334 assert!(
335 (got - want).abs() < 1e-6,
336 "head produced {got}, hand-computed {want}"
337 );
338 assert_eq!(head.n_cls_out(), 1);
339 }
340
341 /// A head with no `cls` is the direct-projection shape upstream
342 /// documents for `jina-reranker-v1-tiny-en`: no tanh anywhere.
343 #[test]
344 fn a_head_with_no_dense_does_not_apply_a_tanh() {
345 let head = RankHead {
346 dense: None,
347 norm: None,
348 out: Some(Dense {
349 w: WeightMatrix::F32(Tensor::new(vec![2.0, 0.0], vec![1, 2])),
350 b: None,
351 }),
352 labels: vec![],
353 eps: 1e-12,
354 };
355 // 2 * 5.0 = 10.0. A stray tanh would make this 1.0.
356 assert!((head.score(&[5.0, 1.0]) - 10.0).abs() < 1e-6);
357 }
358
359 /// `n_cls_out` follows the labels, and `score` is output 0 of
360 /// however many there are -- upstream's `embd[0]`.
361 #[test]
362 fn score_is_the_first_output_and_labels_size_the_head() {
363 let head = RankHead {
364 dense: None,
365 norm: None,
366 out: Some(dense(3, 2, 1.0, None)),
367 labels: vec!["a".into(), "b".into(), "c".into()],
368 eps: 1e-12,
369 };
370 assert_eq!(head.n_cls_out(), 3);
371 assert_eq!(head.labels(), ["a", "b", "c"]);
372 let all = head.apply(&[1.0, 1.0]);
373 assert_eq!(all.len(), 3);
374 assert_eq!(head.score(&[1.0, 1.0]), all[0]);
375 assert_ne!(all[0], all[1], "the fixture must distinguish the outputs");
376 }
377}