Skip to main content

kime_model/
model.rs

1//! Opening a compat model from a Laya directory or a `.kime` file.
2
3use std::path::{Path, PathBuf};
4
5use kime_tensor::Blob;
6use serde_json::{Map, Value};
7
8use crate::error::{Error, Result};
9use crate::laya::{LayaGraph, LayaSpec};
10use crate::tensors::Tensors;
11use crate::{pack, safetensors};
12
13/// The files of a Laya checkpoint directory that kime reads, beside `model.safetensors`.
14/// `tokenizer_config.json` is optional.
15pub const LAYA_FILES: [&str; 4] = [
16    "rl_agent_config.json",
17    "encoder/config.json",
18    "tokenizer/tokenizer.json",
19    "tokenizer/tokenizer_config.json",
20];
21
22#[derive(Debug)]
23enum Files {
24    Loose(Vec<(String, Vec<u8>)>),
25    Packed(Vec<pack::FileEntry>),
26}
27
28/// A loaded compat model: its spec, its tensors and the graph bound to them.
29#[derive(Debug)]
30pub struct Model {
31    /// Both configs.
32    pub spec: LayaSpec,
33    /// The weights, mapped.
34    pub tensors: Tensors,
35    /// The graph, with every weight bound and checked.
36    pub graph: LayaGraph,
37    /// The `.kime` index, when loaded from one.
38    pub index: Option<pack::Index>,
39    /// `__metadata__` of the source safetensors, kept so unpacking gives the same file back.
40    pub metadata: Option<Map<String, Value>>,
41    files: Files,
42}
43
44impl Model {
45    /// Opens a Laya checkpoint directory or a `.kime` file. Tensor data is mapped, not read, and
46    /// the `.kime` model hash is not checked here, see [`Model::verify`].
47    ///
48    /// # Errors
49    ///
50    /// [`Error::Io`] for a missing file, [`Error::Format`] for a malformed one and
51    /// [`Error::Mismatch`] when the tensors do not match the config.
52    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
53        let path = path.as_ref();
54        if path.is_dir() { Self::open_dir(path) } else { Self::open_kime(path) }
55    }
56
57    fn open_dir(dir: &Path) -> Result<Self> {
58        let mut files = Vec::new();
59        for name in LAYA_FILES {
60            let p = dir.join(name);
61            match std::fs::read(&p) {
62                Ok(b) => files.push((name.to_string(), b)),
63                Err(e)
64                    if e.kind() == std::io::ErrorKind::NotFound
65                        && name.ends_with("_config.json") => {}
66                Err(e) => return Err(Error::Io(p, e)),
67            }
68        }
69        let st = dir.join("model.safetensors");
70        let blob = Blob::map(&st).map_err(|e| Error::Io(st.clone(), e))?;
71        let (tensors, metadata) =
72            safetensors::load(blob).map_err(|e| Error::Format(format!("{}: {e}", st.display())))?;
73        let get = |n: &str| files.iter().find(|(k, _)| k == n).map(|(_, b)| b.as_slice());
74        let spec = spec_from(get("rl_agent_config.json"), get("encoder/config.json"), dir)?;
75        let graph = LayaGraph::bind(&spec, &tensors)?;
76        Ok(Self { spec, tensors, graph, index: None, metadata, files: Files::Loose(files) })
77    }
78
79    fn open_kime(path: &Path) -> Result<Self> {
80        let blob = Blob::map(path).map_err(|e| Error::Io(path.to_path_buf(), e))?;
81        let (index, tensors) =
82            pack::load(blob).map_err(|e| Error::Format(format!("{}: {e}", path.display())))?;
83        if index.family != "laya" {
84            return Err(Error::Format(format!(
85                "{}: family {:?} is not supported by this build",
86                path.display(),
87                index.family
88            )));
89        }
90        let get = |n: &str| {
91            index.files.iter().find(|f| f.name == n).map(|f| &tensors.blob()[f.start..f.end])
92        };
93        let spec = spec_from(get("rl_agent_config.json"), get("encoder/config.json"), path)?;
94        let graph = LayaGraph::bind(&spec, &tensors)?;
95        let metadata = index.json.get("safetensors_metadata").and_then(Value::as_object).cloned();
96        let files = Files::Packed(index.files.clone());
97        Ok(Self { spec, tensors, graph, index: Some(index), metadata, files })
98    }
99
100    /// A carried file, such as `tokenizer/tokenizer.json`.
101    #[must_use]
102    pub fn file(&self, name: &str) -> Option<&[u8]> {
103        match &self.files {
104            Files::Loose(v) => v.iter().find(|(k, _)| k == name).map(|(_, b)| b.as_slice()),
105            Files::Packed(v) => {
106                v.iter().find(|f| f.name == name).map(|f| &self.tensors.blob()[f.start..f.end])
107            }
108        }
109    }
110
111    /// Names of the carried files.
112    #[must_use]
113    pub fn file_names(&self) -> Vec<&str> {
114        match &self.files {
115            Files::Loose(v) => v.iter().map(|(k, _)| k.as_str()).collect(),
116            Files::Packed(v) => v.iter().map(|f| f.name.as_str()).collect(),
117        }
118    }
119
120    /// Checks the `.kime` model hash against the data. A directory has no hash and always passes.
121    ///
122    /// # Errors
123    ///
124    /// [`Error::Format`] if the data has changed since it was packed.
125    pub fn verify(&self) -> Result<()> {
126        match &self.index {
127            Some(index) => pack::verify(self.tensors.blob(), index),
128            None => Ok(()),
129        }
130    }
131
132    /// Packs the model as `.kime` and returns the model hash.
133    ///
134    /// # Errors
135    ///
136    /// Any error from `out`.
137    ///
138    /// # Panics
139    ///
140    /// Never. Every name comes from the model's own file list.
141    pub fn pack(&self, out: &mut impl std::io::Write) -> Result<String> {
142        let mut extra = Map::new();
143        if let Some(m) = &self.metadata {
144            extra.insert("safetensors_metadata".into(), Value::Object(m.clone()));
145        }
146        let files =
147            self.file_names().into_iter().map(|n| (n, self.file(n).expect("listed"))).collect();
148        pack::write(
149            &pack::Contents {
150                family: "laya",
151                id: &self.spec.id,
152                tensors: &self.tensors,
153                files,
154                extra,
155            },
156            out,
157        )
158    }
159
160    /// Writes the model back out as a Laya directory: `model.safetensors` plus the carried files.
161    /// Refuses to write into a directory that already has a `model.safetensors`.
162    ///
163    /// # Errors
164    ///
165    /// [`Error::Io`] on any write failure or an existing checkpoint.
166    ///
167    /// # Panics
168    ///
169    /// Never. Every name comes from the model's own file list.
170    pub fn unpack(&self, dir: &Path) -> Result<()> {
171        let io = |p: PathBuf| move |e| Error::Io(p, e);
172        let st = dir.join("model.safetensors");
173        if st.exists() {
174            return Err(Error::Io(st, std::io::Error::from(std::io::ErrorKind::AlreadyExists)));
175        }
176        for name in self.file_names() {
177            let p = dir.join(name);
178            if let Some(parent) = p.parent() {
179                std::fs::create_dir_all(parent).map_err(io(parent.to_path_buf()))?;
180            }
181            std::fs::write(&p, self.file(name).expect("listed")).map_err(io(p.clone()))?;
182        }
183        let f = std::fs::File::create(&st).map_err(io(st.clone()))?;
184        let mut w = std::io::BufWriter::with_capacity(1 << 20, f);
185        safetensors::write(&self.tensors, self.metadata.as_ref(), &mut w)
186            .map_err(io(st.clone()))?;
187        std::io::Write::flush(&mut w).map_err(io(st))
188    }
189}
190
191fn spec_from(agent: Option<&[u8]>, encoder: Option<&[u8]>, at: &Path) -> Result<LayaSpec> {
192    let parse = |b: Option<&[u8]>, name: &str| -> Result<Value> {
193        let b = b.ok_or_else(|| Error::Format(format!("{}: no {name}", at.display())))?;
194        serde_json::from_slice(b)
195            .map_err(|e| Error::Format(format!("{}: {name}: {e}", at.display())))
196    };
197    LayaSpec::from_json(
198        &parse(agent, "rl_agent_config.json")?,
199        &parse(encoder, "encoder/config.json")?,
200    )
201}