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//! # The pooler, and the two score scales (issue #82)
42//!
43//! `cls` IS HuggingFace's `bert.pooler.dense`: llama.cpp's tensor
44//! mapping renames it, and the `tanh` above is
45//! `BertPooler.activation`. A `BertForSequenceClassification` reranker
46//! was trained as `classifier(tanh(pooler(cls_hidden)))`, so the
47//! `dense` branch is not an optional flourish — it is most of the
48//! head's calibration.
49//!
50//! Every reranker GGUF in circulation is missing it, because
51//! llama.cpp's converter deletes it by name (`conversion/bert.py`,
52//! `BertModel.filter_tensors`: "we are only using BERT for embeddings
53//! so we don't need the pooling layer"). This module does the one
54//! thing an engine can do about that: it **runs the pooler when the
55//! file carries it and refuses to invent one when it does not**. There
56//! is no identity stand-in and no zero-filled `cls` — a made-up pooler
57//! is a made-up score, and the direct-projection shape is legitimate
58//! for `jina-reranker-v1-tiny-en`, so the absence cannot be refused
59//! either.
60//!
61//! What it must never do is leave the difference invisible. The two
62//! regimes differ by roughly a factor of 50 in score magnitude
63//! (about ±11 vs about ±0.2 on `ms-marco-MiniLM-L6-v2`), which changes
64//! nothing for a caller that sorts and everything for a caller that
65//! thresholds. [`RankHead::has_pooler`] and [`RankHead::graph`] are the
66//! machine-readable answer, `/v1/rerank` reports the second one on
67//! every response, and [`missing_pooler_note`] says it once at load for
68//! whoever is reading the server's log rather than its JSON.
69//!
70//! # Which float is the score
71//!
72//! `send_rerank` (`tools/server/server-context.cpp`) reads `embd[0]`:
73//! the FIRST of `n_cls_out` outputs, whatever the rest are. That is
74//! fine for a one-output relevance head and is a silent choice for a
75//! many-output classifier, so [`load_rank_head`] refuses a multi-output
76//! head that does not name its labels — see [`RankHead::labels`].
77
78use ferrox_core::matmul::layer_norm;
79use ferrox_core::weight_matrix::WeightMatrix;
80use ferrox_gguf::{GgufValue, ShardedGguf, TensorSource};
81
82use crate::loader::{load_f32_vec_optional, load_weight_matrix, LoadError};
83
84/// `LLM_TENSOR_CLS` / `CLS_OUT` / `CLS_NORM`, by their GGUF names
85/// (`llama-arch.cpp:428-430`).
86const CLS_W: &str = "cls.weight";
87const CLS_B: &str = "cls.bias";
88const CLS_OUT_W: &str = "cls.output.weight";
89const CLS_OUT_B: &str = "cls.output.bias";
90const CLS_NORM_W: &str = "cls.norm.weight";
91const CLS_NORM_B: &str = "cls.norm.bias";
92
93/// A dense projection with an optional bias: `W · x (+ b)`.
94struct Dense {
95    w: WeightMatrix,
96    b: Option<Vec<f32>>,
97}
98
99impl Dense {
100    fn apply(&self, x: &[f32]) -> Vec<f32> {
101        let mut out = self.w.apply(x);
102        if let Some(b) = &self.b {
103            for (o, bv) in out.iter_mut().zip(b.iter()) {
104                *o += bv;
105            }
106        }
107        out
108    }
109}
110
111/// The classification head a reranker checkpoint carries on top of the
112/// encoder.
113pub struct RankHead {
114    /// `cls` + `cls.bias`, followed by `tanh`. Optional upstream: some
115    /// checkpoints project straight to the output.
116    dense: Option<Dense>,
117    /// `cls.norm`, applied after the `tanh`. Upstream calls
118    /// `build_norm(cur, cls_norm, NULL, LLM_NORM, -1)` — LayerNorm with
119    /// a weight and **no bias**, so a `cls.norm.bias` in a checkpoint
120    /// would be a tensor this graph does not apply and is refused.
121    norm: Option<Vec<f32>>,
122    /// `cls.output` + `cls.output.bias`, the projection to `n_cls_out`.
123    out: Option<Dense>,
124    /// `{arch}.classifier.output_labels`, verbatim. Empty only when the
125    /// head has exactly one output, where a name adds nothing and
126    /// upstream's own default (`hparams.n_cls_out = 1`) is unambiguous.
127    labels: Vec<String>,
128    eps: f32,
129}
130
131impl RankHead {
132    /// How many floats [`Self::apply`] returns.
133    pub fn n_cls_out(&self) -> usize {
134        self.labels.len().max(1)
135    }
136
137    /// `{arch}.classifier.output_labels`, in output order. Empty for a
138    /// single-output head that named none.
139    pub fn labels(&self) -> &[String] {
140        &self.labels
141    }
142
143    /// Every output of the head, for `pooled` — which must already be
144    /// the CLS row, not the whole hidden-state matrix.
145    pub fn apply(&self, pooled: &[f32]) -> Vec<f32> {
146        let mut cur = pooled.to_vec();
147        if let Some(dense) = &self.dense {
148            cur = dense.apply(&cur);
149            for v in cur.iter_mut() {
150                *v = v.tanh();
151            }
152            if let Some(w) = &self.norm {
153                // No bias: `build_norm(..., NULL, ...)` upstream.
154                let zeros = vec![0.0f32; w.len()];
155                cur = layer_norm(&cur, w, &zeros, self.eps);
156            }
157        }
158        if let Some(out) = &self.out {
159            cur = out.apply(&cur);
160        }
161        cur
162    }
163
164    /// The single relevance score `/v1/rerank` reports: output 0, which
165    /// is what upstream's `send_rerank` sends (`res->score = embd[0]`).
166    pub fn score(&self, pooled: &[f32]) -> f32 {
167        self.apply(pooled).first().copied().unwrap_or(0.0)
168    }
169
170    /// Whether `cls` — HuggingFace's `bert.pooler.dense` — is in this
171    /// checkpoint, and therefore in every score this head produces.
172    ///
173    /// The one bit that decides which of the two score scales in the
174    /// module docs a caller is reading. Everything else that reports
175    /// the regime is derived from it, so nothing can disagree with the
176    /// weights that are actually loaded.
177    pub fn has_pooler(&self) -> bool {
178        self.dense.is_some()
179    }
180
181    /// The composition [`Self::apply`] runs, as a formula over the CLS
182    /// row: `classifier(tanh(pooler(cls)))` for a head with its pooler,
183    /// `classifier(cls)` for one without.
184    ///
185    /// Built from the same three `Option`s [`Self::apply`] branches on,
186    /// in the same order, rather than restated as a table of the four
187    /// shapes — two structures that must agree about one thing is this
188    /// repo's dominant bug shape, and a *description* that has drifted
189    /// from the graph is worse than none. The test
190    /// `the_graph_names_a_tanh_exactly_when_a_tanh_is_applied` closes
191    /// the loop by checking the label against the arithmetic.
192    pub fn graph(&self) -> String {
193        let mut g = "cls".to_string();
194        if self.dense.is_some() {
195            g = format!("tanh(pooler({g}))");
196        }
197        if self.norm.is_some() {
198            g = format!("norm({g})");
199        }
200        if self.out.is_some() {
201            g = format!("classifier({g})");
202        }
203        g
204    }
205}
206
207fn refuse(what: String) -> LoadError {
208    LoadError::UnsupportedFeature("bert".to_string(), what)
209}
210
211/// Reads `{arch}.classifier.output_labels`, an array of strings.
212///
213/// Absent is `Ok(vec![])`, which is only *allowed* for a one-output
214/// head — [`load_rank_head`] enforces that. A key of the wrong type is
215/// an error rather than a silent empty: a checkpoint that says
216/// something about its labels and is not understood must stop the load.
217fn read_labels(file: &impl TensorSource, arch: &str) -> Result<Vec<String>, LoadError> {
218    let key = format!("{arch}.classifier.output_labels");
219    let Some(value) = file.metadata(&key) else {
220        return Ok(Vec::new());
221    };
222    match value {
223        GgufValue::Array(items) => items
224            .iter()
225            .map(|v| match v {
226                GgufValue::String(s) => Ok(s.clone()),
227                other => Err(refuse(format!(
228                    "{key} contains a non-string entry {other:?}; it must be an array of \
229                     label names"
230                ))),
231            })
232            .collect(),
233        other => Err(refuse(format!(
234            "{key} is {other:?}, but it must be an array of strings"
235        ))),
236    }
237}
238
239/// The load-time NOTE for a sequence-classification head that arrived
240/// without its pooler — issue #82, and **not** a refusal.
241///
242/// Fires on exactly one combination: `cls.output` present, `cls`
243/// absent, and `{arch}.classifier.output_labels` declared. That triple
244/// is what a HuggingFace `BertForSequenceClassification` looks like
245/// after llama.cpp's converter has deleted `pooler.dense` by name. It
246/// is deliberately narrower than "no pooler":
247/// `jina-reranker-v1-tiny-en` is the direct-projection shape upstream
248/// documents as CORRECT, it names no labels, and warning about it
249/// would train the reader to ignore this line.
250///
251/// A refusal is not available here. The file does not carry anything
252/// that separates "trained without a pooler" from "converted without
253/// one", so refusing would also refuse the checkpoints where the shape
254/// is right — see the module docs. A note is what is left, and it is
255/// why the regime is *also* on every `/v1/rerank` response: an operator
256/// reads a log, a thresholding client reads JSON, and only the second
257/// one is in a position to act on it.
258///
259/// A free function taking three booleans rather than an `if` inside
260/// [`load_rank_head`], so that the condition can be shown to fire — and
261/// shown not to fire on the two shapes next to it — without three GGUFs.
262fn missing_pooler_note(has_dense: bool, has_out: bool, labels: &[String]) -> Option<String> {
263    if has_dense || !has_out || labels.is_empty() {
264        return None;
265    }
266    Some(format!(
267        "ferrox: NOTE this reranker head runs classifier(cls) — it carries {CLS_OUT_W} and \
268         labels ({}) but no {CLS_W}. A HuggingFace BertForSequenceClassification was trained \
269         as classifier(tanh(pooler(cls))), and llama.cpp's converter deletes pooler.dense by \
270         name (conversion/bert.py, \"we are only using BERT for embeddings so we don't need \
271         the pooling layer\"), so this file cannot carry it. ferrox runs the pooler whenever \
272         a file DOES carry it and will not invent one. The ranking is the checkpoint's; the \
273         score SCALE is not (about ±0.2 rather than about ±11 on \
274         ms-marco-MiniLM-L6-v2), so an absolute relevance threshold will not fire. See \
275         ferrox issue #82.",
276        labels.join(", "),
277    ))
278}
279
280/// Builds the head a `bert` checkpoint carries, or `None` when it
281/// carries none (a plain embedding model).
282///
283/// Refuses, rather than loading something that would score wrongly:
284///
285/// * a `cls.norm.bias`, which upstream never applies;
286/// * labels whose count disagrees with `cls.output.weight`'s row count,
287///   because then one of the two is describing a different model;
288/// * a multi-output head with no labels, because
289///   [`RankHead::score`] would silently pick output 0 out of several
290///   that nothing has named.
291pub fn load_rank_head(
292    file: &ShardedGguf,
293    arch: &str,
294    n_embd: usize,
295    eps: f32,
296) -> Result<Option<RankHead>, LoadError> {
297    let has_any = [CLS_W, CLS_OUT_W, CLS_NORM_W]
298        .iter()
299        .any(|n| file.find_tensor(n).is_some());
300    let labels = read_labels(file, arch)?;
301    if !has_any {
302        // Labels without a head is a checkpoint describing a classifier
303        // whose weights are not here. Loading it as a plain embedding
304        // model would quietly drop what the file says it is.
305        if !labels.is_empty() {
306            return Err(refuse(format!(
307                "{arch}.classifier.output_labels names {} label(s) ({}) but the checkpoint \
308                 carries no {CLS_W}, {CLS_OUT_W} or {CLS_NORM_W} — the classification head \
309                 the labels describe is not in this file",
310                labels.len(),
311                labels.join(", "),
312            )));
313        }
314        return Ok(None);
315    }
316
317    let dense = match file.find_tensor(CLS_W) {
318        Some(_) => {
319            let w = load_weight_matrix(file, CLS_W)?;
320            if w.cols() != n_embd {
321                return Err(refuse(format!(
322                    "{CLS_W} takes {} inputs but the encoder is {n_embd} wide",
323                    w.cols()
324                )));
325            }
326            Some(Dense {
327                b: load_f32_vec_optional(file, CLS_B)?,
328                w,
329            })
330        }
331        None => None,
332    };
333
334    if file.find_tensor(CLS_NORM_B).is_some() {
335        return Err(refuse(format!(
336            "checkpoint carries {CLS_NORM_B}, but upstream's head norm is \
337             build_norm(cur, cls_norm, NULL, ...) — a weight and no bias. Applying the \
338             weight and dropping the bias would score wrongly and silently"
339        )));
340    }
341    let norm = load_f32_vec_optional(file, CLS_NORM_W)?;
342    if norm.is_some() && dense.is_none() {
343        return Err(refuse(format!(
344            "checkpoint carries {CLS_NORM_W} but no {CLS_W}; upstream applies the head norm \
345             only inside the `cls` branch, so this norm would never run"
346        )));
347    }
348
349    let out = match file.find_tensor(CLS_OUT_W) {
350        Some(_) => {
351            let w = load_weight_matrix(file, CLS_OUT_W)?;
352            let want = dense.as_ref().map(|d| d.w.rows()).unwrap_or(n_embd);
353            if w.cols() != want {
354                return Err(refuse(format!(
355                    "{CLS_OUT_W} takes {} inputs but the value reaching it is {want} wide",
356                    w.cols()
357                )));
358            }
359            Some(Dense {
360                b: load_f32_vec_optional(file, CLS_OUT_B)?,
361                w,
362            })
363        }
364        None => None,
365    };
366
367    // How many scores this head really produces, from the weights.
368    let n_out = match &out {
369        Some(d) => d.w.rows(),
370        None => match &dense {
371            Some(d) => d.w.rows(),
372            // Unreachable while `has_any` is true and norm-without-dense
373            // is refused, but stated rather than assumed.
374            None => n_embd,
375        },
376    };
377    if !labels.is_empty() && labels.len() != n_out {
378        return Err(refuse(format!(
379            "{arch}.classifier.output_labels names {} label(s) ({}) but the head produces \
380             {n_out} output(s) — the metadata and the weights describe different models",
381            labels.len(),
382            labels.join(", "),
383        )));
384    }
385    if labels.is_empty() && n_out > 1 {
386        return Err(refuse(format!(
387            "this classification head produces {n_out} outputs and the checkpoint carries no \
388             {arch}.classifier.output_labels, so nothing says which one is the relevance \
389             score. Refusing rather than reporting output 0 as if it were named"
390        )));
391    }
392
393    if let Some(note) = missing_pooler_note(dense.is_some(), out.is_some(), &labels) {
394        eprintln!("{note}");
395    }
396
397    Ok(Some(RankHead {
398        dense,
399        norm,
400        out,
401        labels,
402        eps,
403    }))
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use ferrox_core::tensor::Tensor;
410
411    fn dense(rows: usize, cols: usize, fill: f32, bias: Option<f32>) -> Dense {
412        let data: Vec<f32> = (0..rows * cols).map(|i| fill * (i as f32 + 1.0)).collect();
413        Dense {
414            w: WeightMatrix::F32(Tensor::new(data, vec![rows, cols])),
415            b: bias.map(|b| vec![b; rows]),
416        }
417    }
418
419    /// The head's ORDER is the whole content of it: dense, tanh, norm,
420    /// output. Applying the same pieces in another order still returns
421    /// a plausible float, which is why this is checked against a
422    /// hand-computed value rather than against "it ran".
423    #[test]
424    fn the_head_applies_dense_then_tanh_then_output() {
425        let head = RankHead {
426            // 1x2, weights [1, 2], bias 0 -> 1*x0 + 2*x1
427            dense: Some(Dense {
428                w: WeightMatrix::F32(Tensor::new(vec![1.0, 2.0], vec![1, 2])),
429                b: Some(vec![0.5]),
430            }),
431            norm: None,
432            // 1x1, weight [3], bias -1 -> 3*y - 1
433            out: Some(Dense {
434                w: WeightMatrix::F32(Tensor::new(vec![3.0], vec![1, 1])),
435                b: Some(vec![-1.0]),
436            }),
437            labels: vec![],
438            eps: 1e-12,
439        };
440        // dense: 1*0.25 + 2*0.5 + 0.5 = 1.75; tanh(1.75) = 0.94138...
441        // out:   3*0.94138 - 1 = 1.82414...
442        let want = 3.0 * 1.75f32.tanh() - 1.0;
443        let got = head.score(&[0.25, 0.5]);
444        assert!(
445            (got - want).abs() < 1e-6,
446            "head produced {got}, hand-computed {want}"
447        );
448        assert_eq!(head.n_cls_out(), 1);
449    }
450
451    /// A head with no `cls` is the direct-projection shape upstream
452    /// documents for `jina-reranker-v1-tiny-en`: no tanh anywhere.
453    #[test]
454    fn a_head_with_no_dense_does_not_apply_a_tanh() {
455        let head = RankHead {
456            dense: None,
457            norm: None,
458            out: Some(Dense {
459                w: WeightMatrix::F32(Tensor::new(vec![2.0, 0.0], vec![1, 2])),
460                b: None,
461            }),
462            labels: vec![],
463            eps: 1e-12,
464        };
465        // 2 * 5.0 = 10.0. A stray tanh would make this 1.0.
466        assert!((head.score(&[5.0, 1.0]) - 10.0).abs() < 1e-6);
467    }
468
469    /// The label a caller reads off a response must be the arithmetic
470    /// the head performed, so this checks the two against each other
471    /// rather than checking [`RankHead::graph`] against a string
472    /// constant — a constant would still agree with itself after
473    /// [`RankHead::apply`] stopped applying the `tanh`.
474    ///
475    /// The probe is linearity. A bias-free `classifier(cls)` head is a
476    /// matrix, so `f(2x) == 2·f(x)` exactly; a `tanh` in the middle is
477    /// the head's ONLY non-linearity, so the same equality fails as
478    /// soon as one runs. `graph()` naming a `tanh` and `apply` being
479    /// linear (or the reverse) is the drift this is here for.
480    #[test]
481    fn the_graph_names_a_tanh_exactly_when_a_tanh_is_applied() {
482        let out = || {
483            Some(Dense {
484                w: WeightMatrix::F32(Tensor::new(vec![1.0, -0.5], vec![1, 2])),
485                b: None,
486            })
487        };
488        let pooler = || {
489            Some(Dense {
490                w: WeightMatrix::F32(Tensor::new(vec![0.3, 0.7, -0.2, 0.4], vec![2, 2])),
491                b: None,
492            })
493        };
494        let x = [0.5f32, 0.25];
495        let two_x = [1.0f32, 0.5];
496
497        for (head, want_graph, want_pooler) in [
498            (
499                RankHead {
500                    dense: pooler(),
501                    norm: None,
502                    out: out(),
503                    labels: vec![],
504                    eps: 1e-12,
505                },
506                "classifier(tanh(pooler(cls)))",
507                true,
508            ),
509            (
510                RankHead {
511                    dense: None,
512                    norm: None,
513                    out: out(),
514                    labels: vec![],
515                    eps: 1e-12,
516                },
517                "classifier(cls)",
518                false,
519            ),
520        ] {
521            assert_eq!(head.graph(), want_graph);
522            assert_eq!(head.has_pooler(), want_pooler);
523            // `graph()` is derived from the same `Option`s, so it can
524            // never disagree with `has_pooler` -- but the point is that
525            // neither may disagree with the numbers.
526            assert_eq!(head.graph().contains("tanh"), head.has_pooler());
527
528            let linear = (head.score(&two_x) - 2.0 * head.score(&x)).abs() < 1e-6;
529            assert_eq!(
530                linear,
531                !want_pooler,
532                "{want_graph} was {} but its graph says otherwise: f(x)={}, f(2x)={}",
533                match linear {
534                    true => "linear",
535                    false => "non-linear",
536                },
537                head.score(&x),
538                head.score(&two_x),
539            );
540        }
541    }
542
543    /// The NOTE must fire on the shape llama.cpp's converter produces
544    /// and on NOTHING else, because a note that also fires on the
545    /// legitimate direct-projection head is a note operators learn to
546    /// skip. All eight combinations, so the condition is pinned from
547    /// both sides rather than demonstrated once.
548    #[test]
549    fn the_missing_pooler_note_fires_only_on_a_labelled_head_with_no_pooler() {
550        let labelled = vec!["LABEL_0".to_string()];
551        for has_dense in [false, true] {
552            for has_out in [false, true] {
553                for labels in [&[][..], &labelled[..]] {
554                    let got = missing_pooler_note(has_dense, has_out, labels);
555                    // `ms-marco-MiniLM-L6-v2` after conversion, and
556                    // only that: cls.output, labels, no cls.
557                    let want = !has_dense && has_out && !labels.is_empty();
558                    assert_eq!(
559                        got.is_some(),
560                        want,
561                        "dense={has_dense} out={has_out} labels={labels:?} produced {got:?}"
562                    );
563                }
564            }
565        }
566        // And it says which head it is talking about, so a log line is
567        // actionable without reading this file.
568        let note = missing_pooler_note(false, true, &labelled).expect("the note fires");
569        assert!(note.contains("LABEL_0"), "{note}");
570        assert!(note.contains("#82"), "{note}");
571    }
572
573    /// `n_cls_out` follows the labels, and `score` is output 0 of
574    /// however many there are -- upstream's `embd[0]`.
575    #[test]
576    fn score_is_the_first_output_and_labels_size_the_head() {
577        let head = RankHead {
578            dense: None,
579            norm: None,
580            out: Some(dense(3, 2, 1.0, None)),
581            labels: vec!["a".into(), "b".into(), "c".into()],
582            eps: 1e-12,
583        };
584        assert_eq!(head.n_cls_out(), 3);
585        assert_eq!(head.labels(), ["a", "b", "c"]);
586        let all = head.apply(&[1.0, 1.0]);
587        assert_eq!(all.len(), 3);
588        assert_eq!(head.score(&[1.0, 1.0]), all[0]);
589        assert_ne!(all[0], all[1], "the fixture must distinguish the outputs");
590    }
591}