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