Skip to main content

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}