Skip to main content

frink_models/
rerank_pooler.rs

1//! `frink splice-pooler`: put a `BertForSequenceClassification`
2//! reranker's pooler back into the GGUF llama.cpp's converter dropped it
3//! from -- issue #82's missing half.
4//!
5//! # What is wrong with every reranker GGUF in circulation
6//!
7//! `conversion/bert.py`, `BertModel.filter_tensors`, deletes
8//! `pooler.dense.{weight,bias}` by name for every BERT conversion,
9//! classification heads included ("we are only using BERT for
10//! embeddings so we don't need the pooling layer"; still unconditional
11//! on `master` as of 2026-09-11). A cross-encoder trained as
12//! `classifier(tanh(pooler(cls)))` is therefore served as
13//! `classifier(cls)`: the same ORDER, on `ms-marco-MiniLM-L6-v2`, and a
14//! score range about fifty times narrower (about +-0.2 instead of
15//! about +-11). [`crate::rank_head`] already runs the pooler whenever a
16//! file carries one and refuses to invent it when the file does not.
17//! This module is how a file comes to carry one.
18//!
19//! # Why the pooler goes INTO the GGUF and not beside it
20//!
21//! Three places the pooler could come from were weighed:
22//!
23//! 1. **Upstream's converter keeping it.** The right fix, and the only
24//!    one that helps files people already downloaded, once they
25//!    re-convert. Not landed upstream, and not something frink controls.
26//! 2. **A sidecar file read at load time.** Two files that must agree
27//!    about one checkpoint, tied by a digest, discovered by filename --
28//!    exactly the two-structures-with-nothing-enforcing-agreement shape
29//!    this repo keeps shipping bugs in, and llama.cpp could not use it.
30//! 3. **Writing a GGUF that carries the tensor**, which is this module.
31//!    The output is what route 1 would have produced: `cls.weight` /
32//!    `cls.bias` under llama.cpp's own names, so `load_rank_head`
33//!    needs no change, `/v1/rerank` reports
34//!    `classifier(tanh(pooler(cls)))` from the weights it actually
35//!    loaded, and llama.cpp's `build_pooling` RANK arm runs the same
36//!    graph on the same file. One file, no load-time identity question.
37//!
38//! # How the pooler is tied to the checkpoint
39//!
40//! A pooler from the wrong checkpoint scores on a range that LOOKS
41//! calibrated and is not -- the silent-wrong-answer class, and worse
42//! than the uncalibrated file it replaced. The GGUF cannot vouch for
43//! itself by name: the published `ms-marco-MiniLM-L6-v2-Q8_0.gguf`
44//! carries `general.name = "Ms Marco MiniLM L 12 v2"` and a
45//! `base_model.0.repo_url` pointing at the L12 checkpoint, while its
46//! six layers and its scores are L6's. A name-keyed check would pair it
47//! with the wrong pooler and pass.
48//!
49//! So the tie is the one tensor BOTH files carry: the classifier.
50//! `cls.output.{weight,bias}` in the GGUF is `classifier.{weight,bias}`
51//! in the safetensors, and [`classifier_matches`] requires the two to
52//! agree element-wise to within the GGUF's own storage precision.
53//! A pooler whose classifier the GGUF does not contain is refused by
54//! name, with the measured deviation. The slot-save fingerprint
55//! (`frink-server::slots::identity`) is not reused here on purpose: it
56//! identifies one GGUF to a later reader of the same GGUF, and the
57//! question at splice time is whether a *safetensors* and a GGUF are
58//! one checkpoint, which no digest of either can answer and the shared
59//! classifier can.
60//!
61//! # What the output is, exactly
62//!
63//! Every metadata key and every tensor of the input, byte for byte, in
64//! the input's order, plus `cls.weight` (`[n_embd, n_embd]`, F32) and
65//! `cls.bias` (`[n_embd]`, F32), plus one string key
66//! [`POOLER_SOURCE_KEY`] naming the safetensors file so `frink inspect`
67//! can say the file is not the converter's own output. The written
68//! file is then reopened and passed through [`load_rank_head`] before
69//! this function returns, so the only GGUF it ever leaves on disk is
70//! one the loader that will consume it has already accepted.
71
72use std::collections::BTreeMap;
73use std::io::BufWriter;
74use std::path::{Path, PathBuf};
75
76use frink_gguf::{
77    GgmlType, GgufFile, GgufValue, GgufWriter, ShardedGguf, TensorPlan, TensorSource,
78};
79use frink_safetensors::SafetensorsFile;
80
81use crate::loader::{load_f32_vec_optional, load_weight_matrix, LoadError};
82use crate::rank_head::load_rank_head;
83use crate::safetensors_f32::widen_to_f32;
84
85/// The metadata key the spliced file carries, so its provenance is
86/// visible in `frink inspect` and a second splice refuses by name.
87pub const POOLER_SOURCE_KEY: &str = "frink.rerank.pooler_source";
88
89/// llama.cpp's names for the head (`llama-arch.cpp`): `cls` is
90/// HuggingFace's `bert.pooler.dense`, `cls.output` its `classifier`.
91const CLS_W: &str = "cls.weight";
92const CLS_B: &str = "cls.bias";
93const CLS_OUT_W: &str = "cls.output.weight";
94const CLS_OUT_B: &str = "cls.output.bias";
95
96/// HuggingFace's names. The converter strips a leading `bert.` before
97/// filtering, so a checkpoint may spell the pooler either way; the
98/// classifier sits outside the `bert.` module and has one spelling.
99const HF_POOLER_W: [&str; 2] = ["bert.pooler.dense.weight", "pooler.dense.weight"];
100const HF_POOLER_B: [&str; 2] = ["bert.pooler.dense.bias", "pooler.dense.bias"];
101const HF_CLASSIFIER_W: &str = "classifier.weight";
102const HF_CLASSIFIER_B: &str = "classifier.bias";
103
104/// How far the GGUF's classifier may sit from the safetensors' before
105/// the two are different checkpoints, as a fraction of the reference
106/// tensor's largest magnitude.
107///
108/// Derived from the storage precisions a one-row head tensor is ever
109/// written at, not chosen: `llama-quantize` never quantizes a tensor
110/// ggml sees as one-dimensional, and `cls.output.weight` is one (its
111/// trailing dimension of 1 is dropped on disk), so the tensor is F32,
112/// F16 or BF16 from the converter, or Q8_0 from a hand-built file. The
113/// worst half-step among those is BF16's `2^-8` of the value and
114/// Q8_0's `1/254` of the block maximum; `1/128` admits both with a
115/// factor of two to spare and nothing coarser. A classifier that
116/// differs from the file's by less than the file's own rounding cannot
117/// change a score by more than that rounding does, which is what makes
118/// the bound the identity and not a heuristic. [`SPLICEABLE_HEAD_DTYPES`]
119/// is the list this number is true for, and a head stored coarser is
120/// refused rather than admitted under a bound it could pass by accident.
121pub const IDENTITY_TOLERANCE: f32 = 1.0 / 128.0;
122
123/// The `cls.output.weight` storage types [`IDENTITY_TOLERANCE`] is
124/// derived from. Anything else refuses by name.
125pub const SPLICEABLE_HEAD_DTYPES: [GgmlType; 4] =
126    [GgmlType::F32, GgmlType::F16, GgmlType::BF16, GgmlType::Q8_0];
127
128#[derive(Debug, thiserror::Error)]
129pub enum SpliceError {
130    #[error(transparent)]
131    Gguf(#[from] frink_gguf::GgufError),
132    #[error(transparent)]
133    Load(#[from] LoadError),
134    #[error(transparent)]
135    Safetensors(#[from] frink_safetensors::SafetensorsError),
136    #[error("writing {path}: {source}")]
137    Write {
138        path: PathBuf,
139        #[source]
140        source: frink_gguf::GgufWriteError,
141    },
142    #[error("reopening the written file {path}: {source}")]
143    Reopen {
144        path: PathBuf,
145        #[source]
146        source: frink_gguf::ShardError,
147    },
148    #[error(
149        "{path} is a '{arch}' checkpoint; only a `bert` classification head is known to run \
150         classifier(tanh(pooler(cls))), so only a `bert` GGUF can take a pooler"
151    )]
152    NotBert { path: PathBuf, arch: String },
153    #[error(
154        "{path} is a split checkpoint ({shards} shards); merge it first (`frink gguf-split \
155         --merge`) so the pooler goes into one file"
156    )]
157    Split { path: PathBuf, shards: u64 },
158    #[error("{path} is missing `{key}`, which sizes the pooler")]
159    MissingHparam { path: PathBuf, key: String },
160    #[error(
161        "{path} already carries {CLS_W}{spliced_from}; splicing a second pooler over it would \
162         replace the head the file was converted with"
163    )]
164    AlreadyPooled { path: PathBuf, spliced_from: String },
165    #[error(
166        "{path} carries no {CLS_OUT_W}: there is no classifier for a pooler to feed, and \
167         no classifier to tie the pooler to. A plain embedding model has no rerank head"
168    )]
169    NoClassifier { path: PathBuf },
170    #[error(
171        "{path} stores {CLS_OUT_W} as {dtype:?}; the classifier identity check is derived \
172         for {allowed:?} and a coarser storage could pass it by accident"
173    )]
174    HeadDtype {
175        path: PathBuf,
176        dtype: GgmlType,
177        allowed: [GgmlType; 4],
178    },
179    #[error("{path} carries none of {tried:?}; it is not a BertForSequenceClassification export")]
180    MissingSafetensor {
181        path: PathBuf,
182        tried: Vec<&'static str>,
183    },
184    #[error("{path}: `{name}` is {dtype:?}, which is not a float type this splice reads")]
185    SafetensorDtype {
186        path: PathBuf,
187        name: String,
188        dtype: frink_safetensors::SafetensorsDtype,
189    },
190    #[error("{path}: `{name}` is {shape:?}, but the GGUF's encoder is {n_embd} wide so it must be {want:?}")]
191    Shape {
192        path: PathBuf,
193        name: String,
194        shape: Vec<usize>,
195        n_embd: usize,
196        want: Vec<usize>,
197    },
198    #[error(
199        "the pooler in {safetensors} does not belong to {gguf}: {mismatch}. A pooler from \
200         another checkpoint produces scores that look calibrated and are not, so nothing was \
201         written. Check that the safetensors is the exact HuggingFace repo this GGUF was \
202         converted from -- the GGUF's own `general.name` is not evidence, the published \
203         ms-marco-MiniLM-L6-v2 file names the L12 model"
204    )]
205    Mismatch {
206        gguf: PathBuf,
207        safetensors: PathBuf,
208        mismatch: IdentityMismatch,
209    },
210    #[error(
211        "the written file {path} loads without a pooler, which means the splice wrote the \
212         tensors under names the loader does not read; the file was removed"
213    )]
214    NotPooledAfterWrite { path: PathBuf },
215}
216
217/// Where and by how much the GGUF's classifier differs from the
218/// safetensors'. Carried in the refusal so an operator sees a number,
219/// not just "mismatch".
220#[derive(Debug, Clone, PartialEq)]
221pub struct IdentityMismatch {
222    /// The GGUF tensor that disagreed.
223    pub tensor: &'static str,
224    /// Element index of the largest deviation.
225    pub index: usize,
226    pub gguf: f32,
227    pub reference: f32,
228    /// The bound that was exceeded, in absolute units.
229    pub allowed: f32,
230}
231
232impl std::fmt::Display for IdentityMismatch {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        write!(
235            f,
236            "{} differs at element {} (GGUF {}, safetensors {}, allowed |diff| <= {:.3e})",
237            self.tensor, self.index, self.gguf, self.reference, self.allowed
238        )
239    }
240}
241
242/// What was spliced, for the CLI's report.
243#[derive(Debug, Clone, PartialEq)]
244pub struct SplicedPooler {
245    pub output: PathBuf,
246    pub n_embd: usize,
247    pub n_out: usize,
248    /// How `cls.output.weight` is stored in the source, which is what
249    /// [`IDENTITY_TOLERANCE`] was checked against.
250    pub head_dtype: GgmlType,
251    /// The largest element-wise deviation measured between the two
252    /// classifiers, so the report shows the tie was tight and not merely
253    /// under the bound.
254    pub classifier_max_abs_diff: f32,
255    /// The bound that deviation was held to, in absolute units.
256    pub classifier_allowed: f32,
257}
258
259/// The identity check: `gguf` and `reference` are one tensor stored at
260/// two precisions, or they are two tensors.
261///
262/// Same length, and every element within
263/// `IDENTITY_TOLERANCE * max|reference|` -- a bound relative to the
264/// tensor's scale rather than absolute, because a classifier's
265/// magnitude is whatever training left it at. Returns the largest
266/// deviation found, so a pass can be reported as a number.
267pub fn classifier_matches(
268    tensor: &'static str,
269    gguf: &[f32],
270    reference: &[f32],
271) -> Result<(f32, f32), IdentityMismatch> {
272    let absmax = reference.iter().fold(0.0f32, |m, v| m.max(v.abs()));
273    let allowed = absmax * IDENTITY_TOLERANCE;
274    if gguf.len() != reference.len() {
275        return Err(IdentityMismatch {
276            tensor,
277            index: gguf.len().min(reference.len()),
278            gguf: f32::NAN,
279            reference: f32::NAN,
280            allowed,
281        });
282    }
283    let mut worst = (0usize, 0.0f32);
284    for (i, (g, r)) in gguf.iter().zip(reference).enumerate() {
285        let diff = (g - r).abs();
286        if diff > worst.1 || diff.is_nan() {
287            worst = (i, diff);
288        }
289    }
290    if worst.1 > allowed || worst.1.is_nan() {
291        return Err(IdentityMismatch {
292            tensor,
293            index: worst.0,
294            gguf: gguf[worst.0],
295            reference: reference[worst.0],
296            allowed,
297        });
298    }
299    Ok((worst.1, allowed))
300}
301
302/// The first of `names` the file carries, widened to `f32`, with its
303/// declared shape.
304fn read_hf(
305    file: &SafetensorsFile,
306    path: &Path,
307    names: &[&'static str],
308) -> Result<(Vec<usize>, Vec<f32>), SpliceError> {
309    let Some(name) = names.iter().find(|n| file.tensor_info(n).is_some()) else {
310        return Err(SpliceError::MissingSafetensor {
311            path: path.to_path_buf(),
312            tried: names.to_vec(),
313        });
314    };
315    let info = file.tensor_info(name).expect("found above");
316    let data = widen_to_f32(info.dtype, file.tensor_bytes(name)?).ok_or_else(|| {
317        SpliceError::SafetensorDtype {
318            path: path.to_path_buf(),
319            name: name.to_string(),
320            dtype: info.dtype,
321        }
322    })?;
323    Ok((info.shape.clone(), data))
324}
325
326fn want_shape(
327    path: &Path,
328    name: &str,
329    shape: &[usize],
330    want: &[usize],
331    n_embd: usize,
332) -> Result<(), SpliceError> {
333    if shape == want {
334        return Ok(());
335    }
336    Err(SpliceError::Shape {
337        path: path.to_path_buf(),
338        name: name.to_string(),
339        shape: shape.to_vec(),
340        n_embd,
341        want: want.to_vec(),
342    })
343}
344
345fn f32_bytes(v: &[f32]) -> Vec<u8> {
346    v.iter().flat_map(|x| x.to_le_bytes()).collect()
347}
348
349/// Writes `out`: `gguf` plus the pooler from `safetensors`, after the
350/// classifier in both has been shown to be one tensor. See the module
351/// docs for every decision in here.
352pub fn splice_pooler(
353    gguf: &Path,
354    safetensors: &Path,
355    out: &Path,
356) -> Result<SplicedPooler, SpliceError> {
357    let file = GgufFile::open(gguf)?;
358    let arch = file
359        .metadata_str("general.architecture")
360        .unwrap_or("")
361        .to_string();
362    if arch != crate::bert_gguf_loader::BERT_ARCH {
363        return Err(SpliceError::NotBert {
364            path: gguf.to_path_buf(),
365            arch,
366        });
367    }
368    if let Some(shards @ 2..) = file.metadata_u64("split.count") {
369        return Err(SpliceError::Split {
370            path: gguf.to_path_buf(),
371            shards,
372        });
373    }
374    let n_embd_key = format!("{arch}.embedding_length");
375    let n_embd = file
376        .metadata_u64(&n_embd_key)
377        .ok_or_else(|| SpliceError::MissingHparam {
378            path: gguf.to_path_buf(),
379            key: n_embd_key,
380        })? as usize;
381    if file.find_tensor(CLS_W).is_some() {
382        let source = file
383            .metadata_str(POOLER_SOURCE_KEY)
384            .map(|s| format!(" (spliced from {s})"))
385            .unwrap_or_default();
386        return Err(SpliceError::AlreadyPooled {
387            path: gguf.to_path_buf(),
388            spliced_from: source,
389        });
390    }
391    let Some(head_info) = file.find_tensor(CLS_OUT_W) else {
392        return Err(SpliceError::NoClassifier {
393            path: gguf.to_path_buf(),
394        });
395    };
396    let head_dtype = head_info.dtype;
397    if !SPLICEABLE_HEAD_DTYPES.contains(&head_dtype) {
398        return Err(SpliceError::HeadDtype {
399            path: gguf.to_path_buf(),
400            dtype: head_dtype,
401            allowed: SPLICEABLE_HEAD_DTYPES,
402        });
403    }
404
405    // The GGUF's classifier, dequantized row by row through the same
406    // loader `load_rank_head` uses, so the orientation checked here is
407    // the orientation that will score.
408    let head = load_weight_matrix(&file, CLS_OUT_W)?;
409    let n_out = head.rows();
410    let gguf_w: Vec<f32> = (0..n_out).flat_map(|r| head.dequant_row(r)).collect();
411    let gguf_b = load_f32_vec_optional(&file, CLS_OUT_B)?;
412
413    let hf = SafetensorsFile::open(safetensors)?;
414    let (cw_shape, hf_w) = read_hf(&hf, safetensors, &[HF_CLASSIFIER_W])?;
415    want_shape(
416        safetensors,
417        HF_CLASSIFIER_W,
418        &cw_shape,
419        &[n_out, n_embd],
420        n_embd,
421    )?;
422    let (pw_shape, pooler_w) = read_hf(&hf, safetensors, &HF_POOLER_W)?;
423    want_shape(
424        safetensors,
425        HF_POOLER_W[0],
426        &pw_shape,
427        &[n_embd, n_embd],
428        n_embd,
429    )?;
430    let (pb_shape, pooler_b) = read_hf(&hf, safetensors, &HF_POOLER_B)?;
431    want_shape(safetensors, HF_POOLER_B[0], &pb_shape, &[n_embd], n_embd)?;
432
433    let mismatch = |mismatch| SpliceError::Mismatch {
434        gguf: gguf.to_path_buf(),
435        safetensors: safetensors.to_path_buf(),
436        mismatch,
437    };
438    let (mut worst, allowed) = classifier_matches(CLS_OUT_W, &gguf_w, &hf_w).map_err(mismatch)?;
439    // The bias is compared when both files carry one. A bias in one
440    // file and not the other is a shape the loader already treats as
441    // two different heads, so it is refused as a mismatch too.
442    match (gguf_b, hf.tensor_info(HF_CLASSIFIER_B).is_some()) {
443        (Some(gguf_b), true) => {
444            let (_, hf_b) = read_hf(&hf, safetensors, &[HF_CLASSIFIER_B])?;
445            let (worst_b, _) = classifier_matches(CLS_OUT_B, &gguf_b, &hf_b).map_err(mismatch)?;
446            worst = worst.max(worst_b);
447        }
448        (None, false) => {}
449        (gguf_b, _) => {
450            return Err(mismatch(IdentityMismatch {
451                tensor: CLS_OUT_B,
452                index: 0,
453                gguf: gguf_b.map(|b| b[0]).unwrap_or(f32::NAN),
454                reference: f32::NAN,
455                allowed,
456            }));
457        }
458    }
459
460    // Everything the input has, in the input's order, then the pooler.
461    let mut metadata: BTreeMap<String, GgufValue> = file
462        .metadata
463        .iter()
464        .map(|(k, v)| (k.clone(), v.clone()))
465        .collect();
466    metadata.insert(
467        POOLER_SOURCE_KEY.to_string(),
468        GgufValue::String(
469            safetensors
470                .file_name()
471                .map(|n| n.to_string_lossy().into_owned())
472                .unwrap_or_else(|| safetensors.display().to_string()),
473        ),
474    );
475    let mut plan: Vec<TensorPlan> = Vec::with_capacity(file.tensors.len() + 2);
476    for t in &file.tensors {
477        plan.push(TensorPlan {
478            name: t.name.clone(),
479            shape: t.shape.clone(),
480            dtype: t.dtype,
481            byte_len: file.tensor_bytes(&t.name)?.len(),
482        });
483    }
484    let pooler_w_bytes = f32_bytes(&pooler_w);
485    let pooler_b_bytes = f32_bytes(&pooler_b);
486    // GGUF `ne[]` is fastest-dimension-first; a square pooler makes the
487    // order invisible here, so it is the loader's `cols() != n_embd`
488    // check on the reopen below, and `rerank_pooler_present.rs`'s
489    // non-square fixture, that pin it.
490    plan.push(TensorPlan {
491        name: CLS_W.to_string(),
492        shape: vec![n_embd as u64, n_embd as u64],
493        dtype: GgmlType::F32,
494        byte_len: pooler_w_bytes.len(),
495    });
496    plan.push(TensorPlan {
497        name: CLS_B.to_string(),
498        shape: vec![n_embd as u64],
499        dtype: GgmlType::F32,
500        byte_len: pooler_b_bytes.len(),
501    });
502
503    let write_err = |source| SpliceError::Write {
504        path: out.to_path_buf(),
505        source,
506    };
507    let sink = std::fs::File::create(out).map_err(|e| write_err(e.into()))?;
508    let mut w = GgufWriter::create(BufWriter::new(sink), &metadata, plan).map_err(write_err)?;
509    for t in &file.tensors {
510        w.write_tensor(&t.name, file.tensor_bytes(&t.name)?)
511            .map_err(write_err)?;
512    }
513    w.write_tensor(CLS_W, &pooler_w_bytes).map_err(write_err)?;
514    w.write_tensor(CLS_B, &pooler_b_bytes).map_err(write_err)?;
515    w.finish().map_err(write_err)?;
516
517    // The file is not done until the loader that will consume it has
518    // accepted it with the pooler in place.
519    let eps = file
520        .metadata_f32(&format!("{arch}.attention.layer_norm_epsilon"))
521        .unwrap_or(1e-12);
522    let reopened = ShardedGguf::open(out).map_err(|source| SpliceError::Reopen {
523        path: out.to_path_buf(),
524        source,
525    })?;
526    let pooled = load_rank_head(&reopened, &arch, n_embd, eps)
527        .map(|h| h.is_some_and(|h| h.has_pooler()))
528        .unwrap_or(false);
529    if !pooled {
530        std::fs::remove_file(out).ok();
531        return Err(SpliceError::NotPooledAfterWrite {
532            path: out.to_path_buf(),
533        });
534    }
535
536    Ok(SplicedPooler {
537        output: out.to_path_buf(),
538        n_embd,
539        n_out,
540        head_dtype,
541        classifier_max_abs_diff: worst,
542        classifier_allowed: allowed,
543    })
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    /// A deterministic classifier-shaped vector: signed, spanning three
551    /// orders of magnitude, so a bound relative to the maximum is
552    /// exercised on small elements too.
553    fn reference(n: usize) -> Vec<f32> {
554        (0..n)
555            .map(|i| {
556                let x = i as f32;
557                ((x * 0.37).sin() * 0.8 + (x * 0.011).cos() * 0.05)
558                    * if i % 7 == 0 { 3.0 } else { 1.0 }
559            })
560            .collect()
561    }
562
563    /// `IDENTITY_TOLERANCE` is a claim about the storage types in
564    /// `SPLICEABLE_HEAD_DTYPES`, so it is checked against the actual
565    /// round trips rather than asserted: the reference quantized to
566    /// Q8_0 and read back, rounded to BF16 (nearest-even, which is what
567    /// torch writes) and to F16, must all pass. A tolerance tightened
568    /// below any of these would refuse the checkpoint's OWN pooler.
569    #[test]
570    fn every_spliceable_storage_precision_passes_the_identity_bound() {
571        let r = reference(384);
572
573        let q8 = frink_quant::dequant_q8_0(&frink_quant::quantize_q8_0(&r)).unwrap();
574        let (worst, allowed) = classifier_matches("q8_0", &q8, &r).expect("Q8_0 round trip");
575        assert!(
576            worst > 0.0,
577            "the Q8_0 round trip must actually perturb something"
578        );
579        assert!(worst <= allowed);
580
581        let bf16: Vec<f32> = r
582            .iter()
583            .map(|x| {
584                let bits = x.to_bits();
585                let rounded = (bits.wrapping_add(0x7FFF + ((bits >> 16) & 1))) >> 16;
586                f32::from_bits(rounded << 16)
587            })
588            .collect();
589        let (worst, allowed) = classifier_matches("bf16", &bf16, &r).expect("BF16 round trip");
590        assert!(worst > 0.0);
591        assert!(worst <= allowed);
592
593        let f16: Vec<f32> = r.iter().map(|x| half::f16::from_f32(*x).to_f32()).collect();
594        classifier_matches("f16", &f16, &r).expect("F16 round trip");
595        classifier_matches("f32", &r, &r).expect("F32 is exact");
596    }
597
598    /// The case the check exists for: a classifier that is NOT the
599    /// GGUF's. Not a random vector -- a copy with one element moved by
600    /// twice the bound, which is the tightest mismatch worth refusing
601    /// and far tighter than two fine-tunes ever are. The refusal names
602    /// the element and both values.
603    #[test]
604    fn a_classifier_off_by_more_than_the_files_own_rounding_is_refused_by_element() {
605        let r = reference(384);
606        let absmax = r.iter().fold(0.0f32, |m, v| m.max(v.abs()));
607        let mut other = r.clone();
608        other[200] += 2.0 * absmax * IDENTITY_TOLERANCE;
609        let err = classifier_matches(CLS_OUT_W, &other, &r).unwrap_err();
610        assert_eq!(err.tensor, CLS_OUT_W);
611        assert_eq!(err.index, 200);
612        assert_eq!(err.gguf, other[200]);
613        assert_eq!(err.reference, r[200]);
614        assert!(err.to_string().contains("element 200"), "{err}");
615    }
616
617    /// A different width is two heads, whatever the values.
618    #[test]
619    fn a_classifier_of_another_width_is_refused_before_any_element_is_compared() {
620        let r = reference(384);
621        assert!(classifier_matches(CLS_OUT_W, &r[..383], &r).is_err());
622        assert!(classifier_matches(CLS_OUT_W, &r, &r[..383]).is_err());
623    }
624
625    /// A NaN in the GGUF's head is not "within tolerance" of anything.
626    #[test]
627    fn a_nan_never_matches() {
628        let r = reference(8);
629        let mut g = r.clone();
630        g[3] = f32::NAN;
631        assert!(classifier_matches(CLS_OUT_W, &g, &r).is_err());
632    }
633}