kopitiam_loader/model.rs
1//! The format-agnostic result of loading a model file, and the trait that
2//! produces it.
3
4use std::io::Read;
5use std::path::Path;
6
7use indexmap::IndexMap;
8use kopitiam_core::{DType, Error, Result, Shape};
9
10use crate::byte_source::ByteSource;
11use crate::gguf::GgufLoader;
12use crate::metadata::ModelMetadata;
13use crate::safetensors::SafeTensorsLoader;
14
15/// One tensor's identity and location, without its bytes.
16///
17/// Deliberately does *not* hold a `&[u8]` or a `Vec<u8>` directly. Doing so
18/// would tie every `TensorEntry` to the lifetime of (or force a copy out
19/// of) the [`LoadedModel`] that owns the backing storage, for no benefit —
20/// nothing in this crate ever needs a tensor's bytes without also having
21/// the `LoadedModel` at hand. Storing an offset and length instead, and
22/// serving bytes through [`LoadedModel::tensor_bytes`], keeps `TensorEntry`
23/// cheap to clone and keeps the bounds check in exactly one place
24/// ([`ByteSource::slice`](crate::byte_source::ByteSource::slice)).
25#[derive(Debug, Clone, PartialEq)]
26pub struct TensorEntry {
27 /// The tensor's name as it appears in the file, e.g. `"blk.0.attn_q.weight"`.
28 pub name: String,
29 pub dtype: DType,
30 /// Dimensions in [`kopitiam_core::Shape`]'s convention: outermost
31 /// first, row-major, last dimension contiguous. See the crate-level
32 /// docs and [`crate::gguf`] for why GGUF's on-disk dimension order has
33 /// to be reversed to produce this.
34 pub shape: Shape,
35 pub(crate) offset: usize,
36 pub(crate) len: usize,
37}
38
39/// A parsed model file: metadata plus a directory of tensors, backed by one
40/// open file.
41///
42/// This type intentionally never constructs a `Tensor`. `kopitiam-tensor`
43/// owns that type and is being developed independently of this crate; a
44/// loader that returned tensors would have to depend on (and track the
45/// in-flux API of) a crate it has no need to know about. Instead,
46/// `LoadedModel` hands out exactly what a tensor needs to be built —
47/// dtype, shape, and raw bytes — and lets whoever owns `Tensor` do the
48/// building.
49pub struct LoadedModel {
50 pub(crate) metadata: ModelMetadata,
51 pub(crate) tensors: IndexMap<String, TensorEntry>,
52 pub(crate) source: ByteSource,
53 pub(crate) format: &'static str,
54}
55
56impl std::fmt::Debug for LoadedModel {
57 /// Deliberately hand-written rather than `#[derive]`d: the backing
58 /// storage can be a multi-gigabyte `mmap`, and a derived `Debug` would
59 /// either fail to compile (`memmap2::Mmap` is not `Debug`) or, worse,
60 /// succeed and dump gigabytes of tensor bytes into a log line. This
61 /// prints only what is useful for diagnostics: the format, tensor
62 /// count and names, and the metadata.
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("LoadedModel")
65 .field("format", &self.format)
66 .field("metadata", &self.metadata)
67 .field("tensors", &self.tensors.values().collect::<Vec<_>>())
68 .finish()
69 }
70}
71
72impl LoadedModel {
73 /// The format-agnostic hyperparameters and raw metadata bag.
74 pub fn metadata(&self) -> &ModelMetadata {
75 &self.metadata
76 }
77
78 /// `"gguf"` or `"safetensors"` — matches the `format` field of any
79 /// [`Error::MalformedModel`] this model's loader could have produced.
80 pub fn format(&self) -> &'static str {
81 self.format
82 }
83
84 /// Number of tensors in the file.
85 pub fn tensor_count(&self) -> usize {
86 self.tensors.len()
87 }
88
89 /// Tensor names, in file order.
90 pub fn tensor_names(&self) -> impl Iterator<Item = &str> {
91 self.tensors.keys().map(String::as_str)
92 }
93
94 /// Looks up a tensor's name/dtype/shape by name, without touching its
95 /// bytes.
96 pub fn tensor(&self, name: &str) -> Option<&TensorEntry> {
97 self.tensors.get(name)
98 }
99
100 /// All tensor entries, in file order.
101 pub fn tensors(&self) -> impl Iterator<Item = &TensorEntry> {
102 self.tensors.values()
103 }
104
105 /// The raw bytes of the named tensor.
106 ///
107 /// This is the loader/tensor boundary: whoever builds a
108 /// `kopitiam_tensor::Tensor` from a loaded model calls this to get the
109 /// bytes, combines them with [`TensorEntry::dtype`] and
110 /// [`TensorEntry::shape`] (available via [`LoadedModel::tensor`]), and
111 /// constructs the tensor on their side of the boundary. The bytes
112 /// returned are exactly the on-disk encoding for `dtype` — still
113 /// block-quantized if `dtype` is quantized, still `f16`/`bf16` if the
114 /// file stored it that way. This loader does not dequantize or convert
115 /// anything.
116 pub fn tensor_bytes(&self, name: &str) -> Result<&[u8]> {
117 let entry = self
118 .tensors
119 .get(name)
120 .ok_or_else(|| Error::MissingTensor { name: name.to_string() })?;
121 self.source.slice(self.format, entry.offset, entry.len)
122 }
123}
124
125/// A parser for one on-disk model format.
126///
127/// Implemented by [`crate::GgufLoader`] and [`crate::SafeTensorsLoader`].
128/// The trait exists (rather than two unrelated `load` free functions) so
129/// that callers who *do* know their format up front can hold a
130/// `Box<dyn ModelLoader>` chosen once, and so that [`load_model`] itself is
131/// just "try each known loader" instead of hand-rolled dispatch logic
132/// duplicated at every call site that needs to support both formats.
133pub trait ModelLoader {
134 /// A short, lowercase name for the format this loader parses — always
135 /// one of the `format` strings this loader's errors carry.
136 fn format_name(&self) -> &'static str;
137
138 /// Cheap, non-destructive sniff: does `bytes` (the start of the file)
139 /// look like this loader's format? Used by [`load_model`] to pick a
140 /// loader by content rather than by file extension.
141 ///
142 /// A `true` result is not a promise the file will load successfully —
143 /// only that it is worth trying. A `false` result *is* a promise this
144 /// loader will refuse the file, so [`load_model`] can skip straight to
145 /// the next candidate without wasting a full parse attempt.
146 fn probe(&self, bytes: &[u8]) -> bool;
147
148 /// Parses the file at `path` into a [`LoadedModel`].
149 fn load(&self, path: &Path) -> Result<LoadedModel>;
150}
151
152/// Loads a model file, choosing GGUF or SafeTensors by sniffing its
153/// content.
154///
155/// # Why content, not extension
156///
157/// GGUF files always start with the four-byte magic `b"GGUF"`
158/// ([`GgufLoader::probe`](crate::GgufLoader)), so sniffing it is exact.
159/// SafeTensors has no magic number at all — a file starting with anything
160/// other than the GGUF magic is *assumed* to be SafeTensors and handed to
161/// [`SafeTensorsLoader`], which will itself fail with
162/// [`Error::MalformedModel`] if that assumption is wrong. This is still
163/// preferable to trusting a `.gguf`/`.safetensors` extension: an extension
164/// is metadata about the file, supplied by whoever named it, not a fact
165/// about the bytes — sniffing means a renamed or extension-less file still
166/// loads correctly, and a genuinely unrecognized file still fails loudly
167/// rather than silently mis-parsing.
168pub fn load_model(path: impl AsRef<Path>) -> Result<LoadedModel> {
169 let path = path.as_ref();
170
171 // Read only the handful of bytes probing needs — not the whole file.
172 // A file shorter than 8 bytes legitimately yields fewer; that is not an
173 // I/O error, it just means every probe below sees a shorter (possibly
174 // empty) slice and correctly reports "not this format". `take(8)` plus
175 // `read_to_end` (rather than one `read` call into a fixed buffer) is
176 // used because a single `read` is allowed to return short even when
177 // more bytes are available.
178 let file = std::fs::File::open(path)?;
179 let mut probe_bytes = Vec::with_capacity(8);
180 file.take(8).read_to_end(&mut probe_bytes)?;
181
182 let gguf = GgufLoader;
183 if gguf.probe(&probe_bytes) {
184 return gguf.load(path);
185 }
186
187 SafeTensorsLoader.load(path)
188}