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}