Skip to main content

candle_graph/
verify.rs

1//! Cross-check the static parameter set against a real checkpoint.
2//!
3//! The checkpoint is *evidence*, not ground truth. A tensor missing from a safetensors file may
4//! mean the analyzer invented it, or that the checkpoint is stale, or that the parameter is
5//! genuinely conditional and this configuration did not create it. The report states what was
6//! observed and leaves the conclusion to the reader.
7//!
8//! Reading the header directly rather than depending on `safetensors` keeps this decoupled from
9//! candle's version: the layout is a little-endian `u64` byte count followed by that many bytes
10//! of JSON.
11
12use anyhow::{bail, Context, Result};
13use serde::Serialize;
14use std::collections::BTreeMap;
15use std::io::Read;
16use std::path::Path;
17
18use crate::ir::{Certainty, CheckpointMatch, Structure};
19
20#[derive(Debug, Clone)]
21pub struct TensorInfo {
22    pub shape: Vec<usize>,
23    pub dtype: String,
24}
25
26pub type Header = BTreeMap<String, TensorInfo>;
27const MAX_HEADER_BYTES: u64 = 100_000_000;
28
29/// Read tensor names, shapes and dtypes from a safetensors file without loading any data.
30///
31/// Validates header bounds, per-tensor `shape`/`dtype`/`data_offsets`, element-byte product
32/// against declared offsets, and that offsets fall within the file's data region.
33pub fn read_header(path: &Path) -> Result<Header> {
34    let mut file =
35        std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
36    let file_len = file
37        .metadata()
38        .with_context(|| format!("reading metadata for {}", path.display()))?
39        .len();
40
41    let mut prefix = [0u8; 8];
42    file.read_exact(&mut prefix)
43        .with_context(|| format!("{} is too short to be a safetensors file", path.display()))?;
44    let header_len_u64 = u64::from_le_bytes(prefix);
45    if header_len_u64 > MAX_HEADER_BYTES {
46        bail!(
47            "{} declares a {} byte header, exceeding the {} byte safety limit",
48            path.display(),
49            header_len_u64,
50            MAX_HEADER_BYTES
51        );
52    }
53    let header_end = 8u64
54        .checked_add(header_len_u64)
55        .filter(|end| *end <= file_len)
56        .with_context(|| format!("{} declares a header longer than the file", path.display()))?;
57    let header_len = usize::try_from(header_len_u64)
58        .with_context(|| format!("safetensors header of {} is too large", path.display()))?;
59
60    let mut bytes = vec![0u8; header_len];
61    file.read_exact(&mut bytes)
62        .with_context(|| format!("reading safetensors header of {}", path.display()))?;
63
64    debug_assert_eq!(header_end, 8 + header_len as u64);
65    let json: serde_json::Value = serde_json::from_slice(&bytes)
66        .with_context(|| format!("parsing safetensors header of {}", path.display()))?;
67    let obj = json
68        .as_object()
69        .with_context(|| format!("safetensors header of {} is not an object", path.display()))?;
70
71    let data_len = file_len - header_end;
72    let mut header = Header::new();
73    let mut occupied: Vec<(u64, u64, String)> = Vec::new();
74
75    for (name, value) in obj {
76        if name == "__metadata__" {
77            continue;
78        }
79        let info = parse_tensor_entry(name, value, data_len)
80            .with_context(|| format!("tensor `{name}` in {}", path.display()))?;
81        occupied.push((info.offset_start, info.offset_end, name.clone()));
82        header.insert(
83            name.clone(),
84            TensorInfo {
85                shape: info.shape,
86                dtype: info.dtype,
87            },
88        );
89    }
90
91    occupied.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
92    for window in occupied.windows(2) {
93        let (a_start, a_end, a_name) = &window[0];
94        let (b_start, _b_end, b_name) = &window[1];
95        if *b_start < *a_end {
96            bail!(
97                "overlapping data_offsets in {}: `{}` [{a_start},{a_end}) overlaps `{b_name}` starting at {b_start}",
98                path.display(),
99                a_name
100            );
101        }
102    }
103
104    Ok(header)
105}
106
107struct ParsedTensor {
108    shape: Vec<usize>,
109    dtype: String,
110    offset_start: u64,
111    offset_end: u64,
112}
113
114fn parse_tensor_entry(
115    name: &str,
116    value: &serde_json::Value,
117    data_len: u64,
118) -> Result<ParsedTensor> {
119    let obj = value
120        .as_object()
121        .with_context(|| format!("entry `{name}` is not an object"))?;
122
123    let shape_value = obj
124        .get("shape")
125        .with_context(|| format!("tensor `{name}` is missing shape"))?;
126    let shape_arr = shape_value
127        .as_array()
128        .with_context(|| format!("tensor `{name}` shape must be an array"))?;
129    let mut shape = Vec::with_capacity(shape_arr.len());
130    for (i, dim) in shape_arr.iter().enumerate() {
131        let n = dim.as_u64().with_context(|| {
132            format!("tensor `{name}` shape[{i}] must be a non-negative integer")
133        })?;
134        let n = usize::try_from(n)
135            .with_context(|| format!("tensor `{name}` shape[{i}] = {n} is too large"))?;
136        shape.push(n);
137    }
138
139    let dtype = obj
140        .get("dtype")
141        .and_then(|d| d.as_str())
142        .with_context(|| format!("tensor `{name}` is missing a string dtype"))?
143        .to_string();
144    let element_size = dtype_nbytes(&dtype)
145        .with_context(|| format!("tensor `{name}` has unsupported or unknown dtype `{dtype}`"))?;
146
147    let offsets_value = obj
148        .get("data_offsets")
149        .with_context(|| format!("tensor `{name}` is missing data_offsets"))?;
150    let offsets = offsets_value
151        .as_array()
152        .with_context(|| format!("tensor `{name}` data_offsets must be an array"))?;
153    if offsets.len() != 2 {
154        bail!("tensor `{name}` data_offsets must have exactly two entries");
155    }
156    let offset_start = offsets[0].as_u64().with_context(|| {
157        format!("tensor `{name}` data_offsets[0] must be a non-negative integer")
158    })?;
159    let offset_end = offsets[1].as_u64().with_context(|| {
160        format!("tensor `{name}` data_offsets[1] must be a non-negative integer")
161    })?;
162    if offset_end < offset_start {
163        bail!("tensor `{name}` data_offsets end {offset_end} is before start {offset_start}");
164    }
165    if offset_end > data_len {
166        bail!(
167            "tensor `{name}` data_offsets end {offset_end} exceeds data region length {data_len}"
168        );
169    }
170
171    let declared = offset_end - offset_start;
172    let expected = tensor_nbytes(&shape, element_size)
173        .with_context(|| format!("tensor `{name}` byte size overflows"))?;
174    if declared != expected {
175        bail!(
176            "tensor `{name}` data_offsets span {declared} bytes but shape {:?} dtype {dtype} requires {expected}",
177            shape
178        );
179    }
180
181    Ok(ParsedTensor {
182        shape,
183        dtype,
184        offset_start,
185        offset_end,
186    })
187}
188
189fn dtype_nbytes(dtype: &str) -> Result<u64> {
190    let n = match dtype {
191        "BOOL" | "U8" | "I8" | "F8_E4M3" | "F8_E5M2" => 1u64,
192        "I16" | "U16" | "F16" | "BF16" => 2,
193        "I32" | "U32" | "F32" => 4,
194        "I64" | "U64" | "F64" => 8,
195        _ => bail!("unsupported safetensors dtype `{dtype}`"),
196    };
197    Ok(n)
198}
199
200fn tensor_nbytes(shape: &[usize], element_size: u64) -> Result<u64> {
201    let mut n = element_size;
202    for &dim in shape {
203        let dim = u64::try_from(dim).context("shape dimension does not fit u64")?;
204        n = n
205            .checked_mul(dim)
206            .context("shape × dtype byte size overflows u64")?;
207    }
208    Ok(n)
209}
210
211#[derive(Debug, Default, Serialize)]
212pub struct VerifyReport {
213    /// `VarBuilder` root this checkpoint was compared against.
214    pub root: String,
215    /// Analyzer parameters matched to at least one tensor.
216    pub matched: usize,
217    /// Parameters the analyzer marked *certain* that the checkpoint does not contain. These are
218    /// the actionable ones: either the analyzer is wrong or the checkpoint is.
219    pub missing_certain: Vec<String>,
220    /// Parameters the analyzer already flagged conditional that the checkpoint does not
221    /// contain. Expected — this is the analyzer and the checkpoint agreeing.
222    pub missing_conditional: Vec<String>,
223    /// Checkpoint tensors no analyzer parameter claims.
224    pub unclaimed: Vec<String>,
225    /// Parameters belonging to a different builder root, not comparable against this file.
226    pub skipped_other_root: usize,
227    pub checkpoint_tensors: usize,
228}
229
230/// Match every parameter under `root` against the header, annotating the structure in place.
231///
232/// Scoping to one builder root matters: a model may draw from several `VarBuilder`s — frozen
233/// mmapped base weights alongside a trainable `VarMap` — and each has its own checkpoint file.
234/// Comparing all of them against one file would report every trainable adapter as "missing"
235/// from the frozen base checkpoint, which is noise rather than a finding.
236pub fn verify(structure: &mut Structure, header: &Header, root: &str) -> VerifyReport {
237    let mut report = VerifyReport {
238        checkpoint_tensors: header.len(),
239        root: root.to_string(),
240        ..Default::default()
241    };
242    let mut claimed: Vec<bool> = vec![false; header.len()];
243    let names: Vec<&String> = header.keys().collect();
244
245    for index in 0..structure.params.len() {
246        if structure.params[index].root != root {
247            report.skipped_other_root += 1;
248            continue;
249        }
250        let key = structure.params[index].key.clone();
251
252        if key.is_template() {
253            let hits: Vec<usize> = names
254                .iter()
255                .enumerate()
256                .filter(|(_, name)| key.matches(name))
257                .map(|(i, _)| i)
258                .collect();
259            if hits.is_empty() {
260                structure.params[index].checkpoint = CheckpointMatch::Missing;
261                record_missing(
262                    &mut report,
263                    &structure.params[index].certainty,
264                    key.to_string(),
265                );
266            } else {
267                for hit in &hits {
268                    claimed[*hit] = true;
269                }
270                structure.params[index].checkpoint = CheckpointMatch::FoundMany {
271                    count: hits.len(),
272                    sample: names[hits[0]].to_string(),
273                };
274                report.matched += 1;
275            }
276            continue;
277        }
278
279        let exact = key.to_string();
280        match names.iter().position(|n| **n == exact) {
281            Some(hit) => {
282                claimed[hit] = true;
283                let info = &header[names[hit]];
284                structure.params[index].checkpoint = CheckpointMatch::Found {
285                    name: exact,
286                    shape: info.shape.clone(),
287                    dtype: info.dtype.clone(),
288                };
289                report.matched += 1;
290            }
291            None => {
292                structure.params[index].checkpoint = CheckpointMatch::Missing;
293                record_missing(&mut report, &structure.params[index].certainty, exact);
294            }
295        }
296    }
297
298    for (index, claimed) in claimed.iter().enumerate() {
299        if !claimed {
300            report.unclaimed.push(names[index].to_string());
301        }
302    }
303    report.missing_certain.sort();
304    report.missing_conditional.sort();
305    report.unclaimed.sort();
306    report
307}
308
309/// A missing parameter is only a finding when the analyzer claimed it definitely exists.
310fn record_missing(report: &mut VerifyReport, certainty: &Certainty, key: String) {
311    match certainty {
312        Certainty::Certain => report.missing_certain.push(key),
313        _ => report.missing_conditional.push(key),
314    }
315}