1use 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
28pub struct RawTensor {
31 pub name: String,
32 pub shape: Vec<u64>,
33 pub dtype: SrcDtype,
34 pub data: Vec<u8>,
35}
36
37pub struct Checkpoint {
40 pub tensors: Vec<RawTensor>,
41 pub metadata: Vec<(String, GGUFMetaValue)>,
42}
43
44#[derive(Default)]
45pub struct LoadOptions {
46 pub pickle_key: Option<String>,
49 pub strip_prefix: Option<String>,
51}
52
53pub 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
87const 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 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
186fn 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
287fn load_pickle(path: &Path, opts: &LoadOptions) -> Result<Checkpoint> {
292 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
354fn 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
476struct 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
546fn 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 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
633fn 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
674fn 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 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 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]); 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}