Skip to main content

zsfm_checkpoint/
read.rs

1//! Format-agnostic checkpoint loading.
2//!
3//! Supported inputs (detected by extension, with a magic-byte fallback):
4//! - `.safetensors` — single file or several shard files
5//! - `model.safetensors.index.json` — HF sharded-model index (shards resolved
6//!   relative to the index file)
7//! - `.pt` / `.pth` / `.bin` / `.ckpt` — PyTorch pickle checkpoints (zip-based
8//!   `torch.save` format) via the hardened `pytorch-reader` crate
9//! - `.npy` / `.npz` — NumPy arrays
10//! - `.gguf` — existing GGUF files (tensors dequantized to F32; metadata carried
11//!   through), which makes dtype re-quantization possible
12//!
13//! Every tensor is returned as raw little-endian bytes plus a [`SrcDtype`]
14//! (F32/F16/BF16 preserved exactly; other dtypes — F64, integers — are cast to
15//! F32 with a warning).
16
17use std::collections::HashSet;
18use std::io::BufReader;
19use std::path::{Path, PathBuf};
20
21use anyhow::{bail, Context, Result};
22use safetensors::{Dtype as StDtype, SafeTensors};
23
24use zsfm_gguf::GGUFMetaValue;
25
26use crate::cast::{f32_to_bytes, SrcDtype};
27
28/// One tensor read from a checkpoint: name, row-major (python-order) shape,
29/// source dtype, and raw little-endian bytes.
30pub struct RawTensor {
31    pub name: String,
32    pub shape: Vec<u64>,
33    pub dtype: SrcDtype,
34    pub data: Vec<u8>,
35}
36
37/// A loaded checkpoint: all tensors plus any metadata carried over from the
38/// source (only GGUF inputs have metadata).
39pub struct Checkpoint {
40    pub tensors: Vec<RawTensor>,
41    pub metadata: Vec<(String, GGUFMetaValue)>,
42}
43
44#[derive(Default)]
45pub struct LoadOptions {
46    /// Descend into this top-level dict before collecting tensors (pickle
47    /// inputs only), e.g. `state_dict` for PyTorch Lightning checkpoints.
48    pub pickle_key: Option<String>,
49    /// Strip this prefix from every tensor name that carries it.
50    pub strip_prefix: Option<String>,
51}
52
53/// Load one or more checkpoint files into a single [`Checkpoint`].
54/// Multiple inputs (e.g. safetensors shards) are merged; duplicate tensor
55/// names across inputs are an error.
56pub fn load_checkpoint(inputs: &[PathBuf], opts: &LoadOptions) -> Result<Checkpoint> {
57    anyhow::ensure!(!inputs.is_empty(), "no input files given");
58
59    let mut tensors: Vec<RawTensor> = Vec::new();
60    let mut metadata: Vec<(String, GGUFMetaValue)> = Vec::new();
61
62    for path in inputs {
63        let ckpt =
64            load_one(path, opts).with_context(|| format!("load checkpoint {}", path.display()))?;
65        tensors.extend(ckpt.tensors);
66        metadata.extend(ckpt.metadata);
67    }
68
69    if let Some(prefix) = &opts.strip_prefix {
70        for t in &mut tensors {
71            if let Some(rest) = t.name.strip_prefix(prefix.as_str()) {
72                t.name = rest.to_string();
73            }
74        }
75    }
76
77    let mut seen = HashSet::new();
78    for t in &tensors {
79        if !seen.insert(t.name.as_str()) {
80            bail!("duplicate tensor name across inputs: {}", t.name);
81        }
82    }
83
84    Ok(Checkpoint { tensors, metadata })
85}
86
87/// TensorFlow SavedModel / TF-checkpoint weights use the TensorBundle format,
88/// which has no trustworthy pure-Rust reader — point at the bundled exporter
89/// script instead of failing cryptically.
90const TF_BUNDLE_HELP: &str =
91    "TensorFlow SavedModel / checkpoint weights (TensorBundle format) can't be read \
92     natively. Export them to safetensors first with the bundled script (needs \
93     tensorflow + safetensors installed):\n\n    \
94     python zsfm-rs/tools/export-weights.py <saved_model_dir | ckpt_prefix> weights.safetensors\n\n\
95     then run: zsfm convert weights.safetensors -o model.gguf\n\
96     (Keras .h5 / .keras files ARE supported natively — no export needed.)";
97
98fn load_one(path: &Path, opts: &LoadOptions) -> Result<Checkpoint> {
99    let fname = path
100        .file_name()
101        .and_then(|f| f.to_str())
102        .unwrap_or_default()
103        .to_ascii_lowercase();
104    let ext = path
105        .extension()
106        .and_then(|e| e.to_str())
107        .unwrap_or_default()
108        .to_ascii_lowercase();
109
110    if path.is_dir() {
111        if path.join("saved_model.pb").exists() {
112            bail!(
113                "{} is a TensorFlow SavedModel directory. {TF_BUNDLE_HELP}",
114                path.display()
115            );
116        }
117        bail!(
118            "{} is a directory — pass a checkpoint file (or for sharded safetensors, \
119             the model.safetensors.index.json)",
120            path.display()
121        );
122    }
123    if ext == "index" || fname.contains(".data-00") {
124        bail!(
125            "{} looks like a TensorFlow checkpoint file. {TF_BUNDLE_HELP}",
126            path.display()
127        );
128    }
129
130    if fname.ends_with(".safetensors.index.json") {
131        return load_safetensors_index(path);
132    }
133    match ext.as_str() {
134        "safetensors" => load_safetensors(path),
135        "pt" | "pth" | "bin" | "ckpt" => load_pickle(path, opts),
136        "npz" => load_npz(path),
137        "npy" => load_npy(path),
138        "gguf" => load_gguf(path),
139        "onnx" => crate::onnx::load_onnx(path),
140        "h5" | "hdf5" => crate::hdf5_keras::load_hdf5(path),
141        "keras" => crate::hdf5_keras::load_keras(path),
142        _ => match sniff_format(path)? {
143            Sniffed::SafeTensors => load_safetensors(path),
144            Sniffed::Gguf => load_gguf(path),
145            Sniffed::Hdf5 => crate::hdf5_keras::load_hdf5(path),
146            Sniffed::Unknown => bail!(
147                "unrecognized checkpoint format for {} — supported: .safetensors, \
148                 model.safetensors.index.json, .pt/.pth/.bin/.ckpt (PyTorch pickle), \
149                 .npy/.npz, .onnx, .h5/.hdf5, .keras, .gguf",
150                path.display()
151            ),
152        },
153    }
154}
155
156enum Sniffed {
157    SafeTensors,
158    Gguf,
159    Hdf5,
160    Unknown,
161}
162
163fn sniff_format(path: &Path) -> Result<Sniffed> {
164    use std::io::Read;
165    let mut head = [0u8; 16];
166    let n = std::fs::File::open(path)
167        .with_context(|| format!("open {}", path.display()))?
168        .read(&mut head)?;
169    if n >= 4 && &head[..4] == b"GGUF" {
170        return Ok(Sniffed::Gguf);
171    }
172    if n >= 8 && &head[..8] == b"\x89HDF\r\n\x1a\n" {
173        return Ok(Sniffed::Hdf5);
174    }
175    // safetensors: u64 LE header length followed by a JSON object.
176    if n >= 9 {
177        let header_len = u64::from_le_bytes(head[..8].try_into().unwrap());
178        let file_len = std::fs::metadata(path)?.len();
179        if header_len > 0 && header_len.saturating_add(8) <= file_len && head[8] == b'{' {
180            return Ok(Sniffed::SafeTensors);
181        }
182    }
183    Ok(Sniffed::Unknown)
184}
185
186// ---------------------------------------------------------------------------
187// safetensors
188// ---------------------------------------------------------------------------
189
190fn load_safetensors(path: &Path) -> Result<Checkpoint> {
191    let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
192    let st = SafeTensors::deserialize(&bytes).context("deserialize safetensors")?;
193
194    let mut tensors = Vec::with_capacity(st.len());
195    for (name, view) in st.tensors() {
196        let shape: Vec<u64> = view.shape().iter().map(|&d| d as u64).collect();
197        let (dtype, data) = match view.dtype() {
198            StDtype::F32 => (SrcDtype::F32, view.data().to_vec()),
199            StDtype::F16 => (SrcDtype::F16, view.data().to_vec()),
200            StDtype::BF16 => (SrcDtype::BF16, view.data().to_vec()),
201            other => {
202                eprintln!("note: tensor {name}: casting {other:?} to F32");
203                let n: usize = view.shape().iter().product();
204                let t = safetensors_view_to_f32(&view, other, n)
205                    .with_context(|| format!("tensor {name}: unsupported dtype {other:?}"))?;
206                (SrcDtype::F32, f32_to_bytes(&t))
207            }
208        };
209        tensors.push(RawTensor {
210            name: name.to_string(),
211            shape,
212            dtype,
213            data,
214        });
215    }
216    Ok(Checkpoint {
217        tensors,
218        metadata: Vec::new(),
219    })
220}
221
222fn safetensors_view_to_f32(
223    view: &safetensors::tensor::TensorView,
224    dtype: StDtype,
225    n_elems: usize,
226) -> Result<Vec<f32>> {
227    let data = view.data();
228    let mut out = Vec::with_capacity(n_elems);
229    match dtype {
230        StDtype::F64 => {
231            for c in data.chunks_exact(8) {
232                out.push(f64::from_le_bytes(c.try_into().unwrap()) as f32);
233            }
234        }
235        StDtype::I64 => {
236            for c in data.chunks_exact(8) {
237                out.push(i64::from_le_bytes(c.try_into().unwrap()) as f32);
238            }
239        }
240        StDtype::I32 => {
241            for c in data.chunks_exact(4) {
242                out.push(i32::from_le_bytes(c.try_into().unwrap()) as f32);
243            }
244        }
245        StDtype::I16 => {
246            for c in data.chunks_exact(2) {
247                out.push(i16::from_le_bytes(c.try_into().unwrap()) as f32);
248            }
249        }
250        StDtype::I8 => out.extend(data.iter().map(|&b| b as i8 as f32)),
251        StDtype::U8 => out.extend(data.iter().map(|&b| b as f32)),
252        StDtype::BOOL => out.extend(data.iter().map(|&b| (b != 0) as u8 as f32)),
253        other => bail!("safetensors dtype {other:?} not supported"),
254    }
255    Ok(out)
256}
257
258fn load_safetensors_index(path: &Path) -> Result<Checkpoint> {
259    let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
260    let v: serde_json::Value = serde_json::from_str(&raw).context("parse index json")?;
261    let map = v
262        .get("weight_map")
263        .and_then(|m| m.as_object())
264        .context("index json has no weight_map object")?;
265
266    let mut shard_names: Vec<String> = map
267        .values()
268        .filter_map(|s| s.as_str().map(str::to_string))
269        .collect();
270    shard_names.sort();
271    shard_names.dedup();
272
273    let dir = path.parent().unwrap_or_else(|| Path::new("."));
274    let mut tensors = Vec::new();
275    for name in &shard_names {
276        let shard_path = dir.join(name);
277        let ckpt = load_safetensors(&shard_path)
278            .with_context(|| format!("load shard {}", shard_path.display()))?;
279        tensors.extend(ckpt.tensors);
280    }
281    Ok(Checkpoint {
282        tensors,
283        metadata: Vec::new(),
284    })
285}
286
287// ---------------------------------------------------------------------------
288// PyTorch pickle (via pytorch-reader: hardened limits, no PyTorch needed)
289// ---------------------------------------------------------------------------
290
291fn load_pickle(path: &Path, opts: &LoadOptions) -> Result<Checkpoint> {
292    // Plain `torch.save(model.state_dict())` checkpoints hold tensors at the top
293    // level, but wrapper checkpoints (PyTorch Lightning, training states) nest
294    // them under "state_dict" — retry with the conventional key before giving up.
295    let reader = match open_pickle_reader(path, opts.pickle_key.as_deref()) {
296        Ok(r) if !r.tensors().is_empty() => r,
297        _ if opts.pickle_key.is_none() => {
298            let retried = open_pickle_reader(path, Some("state_dict"))
299                .with_context(|| format!("read PyTorch checkpoint {}", path.display()))?;
300            if !retried.tensors().is_empty() {
301                eprintln!("note: no top-level tensors — using the \"state_dict\" dict instead");
302                retried
303            } else {
304                open_pickle_reader(path, opts.pickle_key.as_deref())
305                    .with_context(|| format!("read PyTorch checkpoint {}", path.display()))?
306            }
307        }
308        Err(e) => return Err(e),
309        Ok(r) => r,
310    };
311    anyhow::ensure!(
312        !reader.tensors().is_empty(),
313        "no tensors found in {} (if the checkpoint nests weights under a custom dict, \
314         pass --pickle-key <KEY>)",
315        path.display()
316    );
317
318    let mut names: Vec<String> = reader.tensors().keys().cloned().collect();
319    names.sort();
320    let mut tensors = Vec::with_capacity(names.len());
321    for name in &names {
322        let t = reader
323            .tensors()
324            .get(name)
325            .with_context(|| format!("tensor {name} vanished"))?;
326        let shape: Vec<u64> = t.shape().iter().map(|&d| d as u64).collect();
327        let raw = t
328            .read()
329            .with_context(|| format!("read tensor {name} bytes"))?;
330        let (dtype, data) = pickle_bytes_to_raw(name, t.dtype(), &raw)?;
331        tensors.push(RawTensor {
332            name: name.clone(),
333            shape,
334            dtype,
335            data,
336        });
337    }
338    Ok(Checkpoint {
339        tensors,
340        metadata: Vec::new(),
341    })
342}
343
344fn open_pickle_reader(path: &Path, key: Option<&str>) -> Result<pytorch_reader::PytorchReader> {
345    let path_str = path.to_string_lossy();
346    match key {
347        Some(k) => pytorch_reader::PytorchReader::with_top_level_key(path_str.as_ref(), k)
348            .with_context(|| format!("read PyTorch checkpoint {}", path.display())),
349        None => pytorch_reader::PytorchReader::new(path_str.as_ref())
350            .with_context(|| format!("read PyTorch checkpoint {}", path.display())),
351    }
352}
353
354/// Map a pickle tensor's bytes to [`RawTensor`] parts. F32/F16/BF16 pass
355/// through exactly; anything else casts to F32 exactly like the safetensors
356/// path above (same rounding, same notes on stderr).
357fn pickle_bytes_to_raw(
358    name: &str,
359    dtype: pytorch_reader::DType,
360    raw: &[u8],
361) -> Result<(SrcDtype, Vec<u8>)> {
362    use pytorch_reader::DType as P;
363    match dtype {
364        P::F32 => Ok((SrcDtype::F32, raw.to_vec())),
365        P::F16 => Ok((SrcDtype::F16, raw.to_vec())),
366        P::BF16 => Ok((SrcDtype::BF16, raw.to_vec())),
367        P::F64 => Ok((
368            SrcDtype::F32,
369            raw.chunks_exact(8)
370                .map(|c| f64::from_le_bytes(c.try_into().unwrap()) as f32)
371                .collect::<Vec<_>>()
372                .iter()
373                .flat_map(|v| v.to_le_bytes())
374                .collect(),
375        )),
376        P::I64 => {
377            eprintln!("note: tensor {name}: casting I64 to F32");
378            Ok((
379                SrcDtype::F32,
380                raw.chunks_exact(8)
381                    .map(|c| i64::from_le_bytes(c.try_into().unwrap()) as f32)
382                    .collect::<Vec<_>>()
383                    .iter()
384                    .flat_map(|v| v.to_le_bytes())
385                    .collect(),
386            ))
387        }
388        P::I32 => {
389            eprintln!("note: tensor {name}: casting I32 to F32");
390            Ok((
391                SrcDtype::F32,
392                raw.chunks_exact(4)
393                    .map(|c| i32::from_le_bytes(c.try_into().unwrap()) as f32)
394                    .collect::<Vec<_>>()
395                    .iter()
396                    .flat_map(|v| v.to_le_bytes())
397                    .collect(),
398            ))
399        }
400        P::I16 => {
401            eprintln!("note: tensor {name}: casting I16 to F32");
402            Ok((
403                SrcDtype::F32,
404                raw.chunks_exact(2)
405                    .map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32)
406                    .collect::<Vec<_>>()
407                    .iter()
408                    .flat_map(|v| v.to_le_bytes())
409                    .collect(),
410            ))
411        }
412        P::I8 => {
413            eprintln!("note: tensor {name}: casting I8 to F32");
414            Ok((
415                SrcDtype::F32,
416                f32_to_bytes(&raw.iter().map(|&b| b as i8 as f32).collect::<Vec<_>>()),
417            ))
418        }
419        P::U64 => {
420            eprintln!("note: tensor {name}: casting U64 to F32");
421            Ok((
422                SrcDtype::F32,
423                raw.chunks_exact(8)
424                    .map(|c| u64::from_le_bytes(c.try_into().unwrap()) as f32)
425                    .collect::<Vec<_>>()
426                    .iter()
427                    .flat_map(|v| v.to_le_bytes())
428                    .collect(),
429            ))
430        }
431        P::U32 => {
432            eprintln!("note: tensor {name}: casting U32 to F32");
433            Ok((
434                SrcDtype::F32,
435                raw.chunks_exact(4)
436                    .map(|c| u32::from_le_bytes(c.try_into().unwrap()) as f32)
437                    .collect::<Vec<_>>()
438                    .iter()
439                    .flat_map(|v| v.to_le_bytes())
440                    .collect(),
441            ))
442        }
443        P::U16 => {
444            eprintln!("note: tensor {name}: casting U16 to F32");
445            Ok((
446                SrcDtype::F32,
447                raw.chunks_exact(2)
448                    .map(|c| u16::from_le_bytes(c.try_into().unwrap()) as f32)
449                    .collect::<Vec<_>>()
450                    .iter()
451                    .flat_map(|v| v.to_le_bytes())
452                    .collect(),
453            ))
454        }
455        P::U8 => {
456            eprintln!("note: tensor {name}: casting U8 to F32");
457            Ok((
458                SrcDtype::F32,
459                f32_to_bytes(&raw.iter().map(|&b| b as f32).collect::<Vec<_>>()),
460            ))
461        }
462        P::Bool => {
463            eprintln!("note: tensor {name}: casting Bool to F32");
464            Ok((
465                SrcDtype::F32,
466                f32_to_bytes(
467                    &raw.iter()
468                        .map(|&b| (b != 0) as u8 as f32)
469                        .collect::<Vec<_>>(),
470                ),
471            ))
472        }
473    }
474}
475
476// ---------------------------------------------------------------------------
477// NumPy (manual .npy parser — no tensor library needed)
478// ---------------------------------------------------------------------------
479
480/// One parsed `.npy` array: row-major little-endian bytes plus dtype/shape.
481struct NpyArray {
482    kind: NpyDtype,
483    shape: Vec<u64>,
484    data: Vec<u8>,
485}
486
487#[derive(Clone, Copy, PartialEq, Eq, Debug)]
488enum NpyDtype {
489    F32,
490    F16,
491    F64,
492    I64,
493    I32,
494    I16,
495    I8,
496    U64,
497    U32,
498    U16,
499    U8,
500    Bool,
501}
502
503fn load_npz(path: &Path) -> Result<Checkpoint> {
504    let file = std::fs::File::open(path).with_context(|| format!("read npz {}", path.display()))?;
505    let mut zip = zip::ZipArchive::new(file).context("open npz zip")?;
506    let mut names: Vec<String> = (0..zip.len())
507        .filter_map(|i| zip.by_index(i).ok().map(|f| f.name().to_string()))
508        .filter(|n| n.ends_with(".npy"))
509        .collect();
510    names.sort();
511    anyhow::ensure!(
512        !names.is_empty(),
513        "npz {} holds no .npy entries",
514        path.display()
515    );
516    let mut tensors = Vec::with_capacity(names.len());
517    for name in &names {
518        let mut f = zip
519            .by_name(name)
520            .with_context(|| format!("npz entry {name} missing"))?;
521        let mut bytes = Vec::new();
522        std::io::Read::read_to_end(&mut f, &mut bytes)?;
523        let arr = parse_npy(&bytes, name)?;
524        tensors.push(npy_to_raw(name.trim_end_matches(".npy"), arr)?);
525    }
526    Ok(Checkpoint {
527        tensors,
528        metadata: Vec::new(),
529    })
530}
531
532fn load_npy(path: &Path) -> Result<Checkpoint> {
533    let bytes = std::fs::read(path).with_context(|| format!("read npy {}", path.display()))?;
534    let name = path
535        .file_stem()
536        .and_then(|s| s.to_str())
537        .unwrap_or("tensor")
538        .to_string();
539    let arr = parse_npy(&bytes, &name)?;
540    Ok(Checkpoint {
541        tensors: vec![npy_to_raw(&name, arr)?],
542        metadata: Vec::new(),
543    })
544}
545
546/// Parse a `.npy` v1/v2 buffer. Supports C-order little-endian numeric
547/// dtypes; Fortran-order arrays are rejected (convert with `np.ascontiguousarray`).
548fn parse_npy(bytes: &[u8], name: &str) -> Result<NpyArray> {
549    anyhow::ensure!(bytes.len() >= 10, "tensor {name}: file too short for .npy");
550    anyhow::ensure!(&bytes[..6] == b"\x93NUMPY", "tensor {name}: bad .npy magic");
551    let (header_len, header_off) = match bytes[6] {
552        1 => {
553            anyhow::ensure!(bytes.len() >= 10, "tensor {name}: truncated .npy header");
554            (u16::from_le_bytes([bytes[8], bytes[9]]) as usize, 10)
555        }
556        2 => {
557            anyhow::ensure!(bytes.len() >= 12, "tensor {name}: truncated .npy header");
558            (
559                u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize,
560                12,
561            )
562        }
563        v => anyhow::bail!("tensor {name}: unsupported .npy version {v}"),
564    };
565    anyhow::ensure!(
566        bytes.len() >= header_off + header_len,
567        "tensor {name}: truncated .npy header"
568    );
569    let header = std::str::from_utf8(&bytes[header_off..header_off + header_len])
570        .context(format!("tensor {name}: .npy header is not UTF-8"))?;
571    let descr = header
572        .split("'descr'")
573        .nth(1)
574        .and_then(|s| s.split('\'').nth(1))
575        .with_context(|| format!("tensor {name}: .npy header lacks 'descr'"))?;
576    let fortran = header.contains("'fortran_order': True");
577    anyhow::ensure!(!fortran, "tensor {name}: Fortran-order .npy not supported");
578    let shape: Vec<u64> = header
579        .split("'shape'")
580        .nth(1)
581        .and_then(|s| s.split(['(', ')']).nth(1))
582        .map(|s| {
583            s.split(',')
584                .filter_map(|n| n.trim().parse::<u64>().ok())
585                .collect()
586        })
587        .unwrap_or_default();
588    let (kind, elem) = match descr {
589        "<f4" => (NpyDtype::F32, 4),
590        "<f2" => (NpyDtype::F16, 2),
591        ">f4" | ">f2" => anyhow::bail!("tensor {name}: big-endian .npy not supported ({descr})"),
592        "<f8" => (NpyDtype::F64, 8),
593        "<i8" => (NpyDtype::I64, 8),
594        "<i4" => (NpyDtype::I32, 4),
595        "<i2" => (NpyDtype::I16, 2),
596        "<i1" | "|i1" => (NpyDtype::I8, 1),
597        "<u8" => (NpyDtype::U64, 8),
598        "<u4" => (NpyDtype::U32, 4),
599        "<u2" => (NpyDtype::U16, 2),
600        "|u1" => (NpyDtype::U8, 1),
601        "|b1" => (NpyDtype::Bool, 1),
602        _ => anyhow::bail!("tensor {name}: unsupported .npy dtype {descr}"),
603    };
604    // BF16 has no NumPy short code; the only 2-byte LE float here is F16.
605    let data = bytes[header_off + header_len..].to_vec();
606    let n_elems: usize = shape.iter().product::<u64>().max(1) as usize;
607    anyhow::ensure!(
608        data.len() == n_elems * elem,
609        "tensor {name}: .npy body is {} bytes, want {}",
610        data.len(),
611        n_elems * elem
612    );
613    Ok(NpyArray { kind, shape, data })
614}
615
616fn npy_to_raw(name: &str, arr: NpyArray) -> Result<RawTensor> {
617    let (dtype, data) = match arr.kind {
618        NpyDtype::F32 => (SrcDtype::F32, arr.data),
619        NpyDtype::F16 => (SrcDtype::F16, arr.data),
620        _ => {
621            eprintln!("note: tensor {name}: casting {:?} to F32", arr.kind);
622            (SrcDtype::F32, npy_cast_to_f32(&arr.data, arr.kind)?)
623        }
624    };
625    Ok(RawTensor {
626        name: name.to_string(),
627        shape: arr.shape,
628        dtype,
629        data,
630    })
631}
632
633/// Cast a non-F32/F16 `.npy` body to F32 LE bytes.
634fn npy_cast_to_f32(data: &[u8], kind: NpyDtype) -> Result<Vec<u8>> {
635    let vals: Vec<f32> = match kind {
636        NpyDtype::F32 | NpyDtype::F16 => {
637            anyhow::bail!("npy_cast_to_f32 called for passthrough dtype")
638        }
639        NpyDtype::F64 => data
640            .chunks_exact(8)
641            .map(|c| f64::from_le_bytes(c.try_into().unwrap()) as f32)
642            .collect(),
643        NpyDtype::I64 => data
644            .chunks_exact(8)
645            .map(|c| i64::from_le_bytes(c.try_into().unwrap()) as f32)
646            .collect(),
647        NpyDtype::I32 => data
648            .chunks_exact(4)
649            .map(|c| i32::from_le_bytes(c.try_into().unwrap()) as f32)
650            .collect(),
651        NpyDtype::I16 => data
652            .chunks_exact(2)
653            .map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32)
654            .collect(),
655        NpyDtype::I8 => data.iter().map(|&b| b as i8 as f32).collect(),
656        NpyDtype::U64 => data
657            .chunks_exact(8)
658            .map(|c| u64::from_le_bytes(c.try_into().unwrap()) as f32)
659            .collect(),
660        NpyDtype::U32 => data
661            .chunks_exact(4)
662            .map(|c| u32::from_le_bytes(c.try_into().unwrap()) as f32)
663            .collect(),
664        NpyDtype::U16 => data
665            .chunks_exact(2)
666            .map(|c| u16::from_le_bytes(c.try_into().unwrap()) as f32)
667            .collect(),
668        NpyDtype::U8 => data.iter().map(|&b| b as f32).collect(),
669        NpyDtype::Bool => data.iter().map(|&b| (b != 0) as u8 as f32).collect(),
670    };
671    Ok(f32_to_bytes(&vals))
672}
673
674// ---------------------------------------------------------------------------
675// GGUF (enables re-quantization)
676// ---------------------------------------------------------------------------
677
678/// GGUF loading uses the in-crate minimal reader, which covers everything the
679/// zsfm writer emits (F32, F16, BF16, Q8_0). Foreign quantization formats
680/// (Q4_K and friends) are rejected with a pointer back to the source
681/// checkpoint — convert from safetensors/pickle instead of re-quantizing
682/// an already-quantized file.
683fn load_gguf(path: &Path) -> Result<Checkpoint> {
684    load_gguf_minimal(path).with_context(|| {
685        format!(
686            "GGUF {} uses a quantization this reader does not support \
687             (supported: F32, F16, BF16, Q8_0) — convert from the source \
688             safetensors/pickle checkpoint instead",
689            path.display()
690        )
691    })
692}
693
694fn load_gguf_minimal(path: &Path) -> Result<Checkpoint> {
695    use zsfm_gguf::GGUFFile;
696    let file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
697    let mut file = BufReader::with_capacity(zsfm_gguf::READ_BUF_CAPACITY, file);
698    let gguf = GGUFFile::read(&mut file)?;
699
700    let mut tensors = Vec::with_capacity(gguf.tensors.len());
701    for info in &gguf.tensors {
702        // Stored dims are GGUF order (reversed row-major) — flip back.
703        let shape: Vec<u64> = info.shape.iter().rev().copied().collect();
704        let (dtype, data) = match info.dtype {
705            zsfm_gguf::GGMLType::F32 => (SrcDtype::F32, gguf.tensor_bytes(&mut file, info)?),
706            zsfm_gguf::GGMLType::F16 => (SrcDtype::F16, gguf.tensor_bytes(&mut file, info)?),
707            zsfm_gguf::GGMLType::BF16 => (SrcDtype::BF16, gguf.tensor_bytes(&mut file, info)?),
708            zsfm_gguf::GGMLType::Q8_0 => (
709                SrcDtype::F32,
710                f32_to_bytes(&gguf.tensor_f32(&mut file, info)?),
711            ),
712        };
713        tensors.push(RawTensor {
714            name: info.name.clone(),
715            shape,
716            dtype,
717            data,
718        });
719    }
720    Ok(Checkpoint {
721        tensors,
722        metadata: gguf.metadata,
723    })
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    fn write_test_safetensors(path: &Path) {
731        // Minimal .safetensors writer: header JSON + raw LE bodies.
732        let a: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
733        let b: Vec<f32> = (0..32).map(|i| i as f32).collect();
734        let a_bytes: Vec<u8> = a.iter().flat_map(|v| v.to_le_bytes()).collect();
735        let b_bytes: Vec<u8> = b.iter().flat_map(|v| v.to_le_bytes()).collect();
736        let header = serde_json::json!({
737            "a": {"dtype": "F32", "shape": [2, 3], "data_offsets": [0, a_bytes.len()]},
738            "b": {"dtype": "F32", "shape": [32], "data_offsets": [a_bytes.len(), a_bytes.len() + b_bytes.len()]},
739        });
740        let header_str = header.to_string();
741        let mut file = vec![0u8; 8];
742        file[..8].copy_from_slice(&(header_str.len() as u64).to_le_bytes());
743        file.extend_from_slice(header_str.as_bytes());
744        file.extend_from_slice(&a_bytes);
745        file.extend_from_slice(&b_bytes);
746        std::fs::write(path, file).unwrap();
747    }
748
749    #[test]
750    fn safetensors_roundtrip_and_shape_order() {
751        let dir = std::env::temp_dir().join("zsfm-checkpoint-test");
752        std::fs::create_dir_all(&dir).unwrap();
753        let path = dir.join("t.safetensors");
754        write_test_safetensors(&path);
755
756        let ckpt = load_checkpoint(&[path], &LoadOptions::default()).unwrap();
757        assert_eq!(ckpt.tensors.len(), 2);
758        let a = ckpt.tensors.iter().find(|t| t.name == "a").unwrap();
759        assert_eq!(a.shape, vec![2, 3]); // python order
760        assert_eq!(a.dtype, SrcDtype::F32);
761        let vals: Vec<f32> = a
762            .data
763            .chunks_exact(4)
764            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
765            .collect();
766        assert_eq!(vals, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
767    }
768
769    #[test]
770    fn duplicate_names_rejected() {
771        let dir = std::env::temp_dir().join("zsfm-checkpoint-test");
772        std::fs::create_dir_all(&dir).unwrap();
773        let path = dir.join("dup.safetensors");
774        write_test_safetensors(&path);
775        let err = match load_checkpoint(&[path.clone(), path], &LoadOptions::default()) {
776            Ok(_) => panic!("expected duplicate-name error"),
777            Err(e) => e,
778        };
779        assert!(err.to_string().contains("duplicate tensor name"));
780    }
781
782    #[test]
783    fn sniffs_safetensors_without_extension() {
784        let dir = std::env::temp_dir().join("zsfm-checkpoint-test");
785        std::fs::create_dir_all(&dir).unwrap();
786        let st = dir.join("noext-src.safetensors");
787        write_test_safetensors(&st);
788        let noext = dir.join("noext_checkpoint");
789        std::fs::copy(&st, &noext).unwrap();
790        let ckpt = load_checkpoint(&[noext], &LoadOptions::default()).unwrap();
791        assert_eq!(ckpt.tensors.len(), 2);
792    }
793}