metrale_model_engine/kimi_k3_loader/
dry_run.rs1use std::collections::{BTreeMap, BTreeSet};
11
12use anyhow::{Context, Result, bail};
13use serde_json::Value;
14
15use super::classes::{ClassKind, TEXT_CLASSES, classify};
16
17#[derive(Debug, Clone)]
20pub struct KimiK3DryRun {
21 pub shards: Vec<String>,
22 pub present_text_classes: Vec<String>,
23 pub missing_text_classes: Vec<String>,
24 pub ignored_vision_classes: Vec<String>,
27 pub unknown_keys: Vec<String>,
28}
29
30pub fn dry_run_index_json(index_json: &str, language_model_only: bool) -> Result<KimiK3DryRun> {
32 let raw: Value = serde_json::from_str(index_json).context("invalid safetensors index.json")?;
33 let map = raw
34 .get("weight_map")
35 .and_then(Value::as_object)
36 .context("safetensors index.json missing weight_map")?;
37 let weight_map: BTreeMap<String, String> = map
38 .iter()
39 .map(|(k, v)| {
40 let shard = v
41 .as_str()
42 .with_context(|| format!("weight_map[{k}] is not a string"))?
43 .to_string();
44 Ok((k.clone(), shard))
45 })
46 .collect::<Result<_>>()?;
47 dry_run_weight_map(&weight_map, language_model_only)
48}
49
50pub fn dry_run_weight_map(
51 weight_map: &BTreeMap<String, String>,
52 language_model_only: bool,
53) -> Result<KimiK3DryRun> {
54 let mut shards = BTreeSet::new();
55 let mut present_text = BTreeSet::new();
56 let mut ignored_vision = BTreeSet::new();
57 let mut unknown_keys = Vec::new();
58
59 for (name, shard) in weight_map {
60 shards.insert(shard.clone());
61 match classify(name) {
62 Some((class, ClassKind::Text)) => {
63 present_text.insert(class.to_string());
64 }
65 Some((class, ClassKind::Vision)) => {
66 if language_model_only {
67 ignored_vision.insert(class.to_string());
68 }
69 }
70 None => unknown_keys.push(name.clone()),
71 }
72 }
73
74 let missing_text_classes: Vec<String> = TEXT_CLASSES
75 .iter()
76 .filter(|c| !present_text.contains(**c))
77 .map(|c| (*c).to_string())
78 .collect();
79
80 if !missing_text_classes.is_empty() {
81 bail!(
82 "K3 weight map missing required text classes ({}): {}",
83 missing_text_classes.len(),
84 missing_text_classes.join(", ")
85 );
86 }
87 if !unknown_keys.is_empty() {
88 bail!(
89 "K3 weight map has unrecognised keys ({}): {}",
90 unknown_keys.len(),
91 unknown_keys
92 .iter()
93 .take(8)
94 .cloned()
95 .collect::<Vec<_>>()
96 .join(", ")
97 );
98 }
99
100 Ok(KimiK3DryRun {
101 shards: shards.into_iter().collect(),
102 present_text_classes: present_text.into_iter().collect(),
103 missing_text_classes,
104 ignored_vision_classes: ignored_vision.into_iter().collect(),
105 unknown_keys,
106 })
107}
108
109#[cfg(test)]
112pub fn require_shard_count(report: &KimiK3DryRun, n: usize) -> Result<()> {
113 if report.shards.len() != n {
114 bail!(
115 "K3 weight map expected {n} shards, got {}",
116 report.shards.len()
117 );
118 }
119 Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124 use super::super::classes::example_key;
125 use super::*;
126 use std::collections::BTreeMap;
127
128 fn synthetic_96_shard_map() -> BTreeMap<String, String> {
131 let mut map = BTreeMap::new();
132 let n_shards = 96usize;
133 for (i, class) in TEXT_CLASSES.iter().enumerate() {
134 let shard = format!("model-{:05}-of-00096.safetensors", (i % n_shards) + 1);
135 map.insert(example_key(class), shard);
136 }
137 let pad_class = TEXT_CLASSES[0];
138 for i in TEXT_CLASSES.len()..n_shards {
139 let shard = format!("model-{:05}-of-00096.safetensors", i + 1);
140 let key = pad_class
141 .replacen('*', "0", 1)
142 .replacen('*', &i.to_string(), 1);
143 map.insert(key, shard);
144 }
145 map
146 }
147
148 #[test]
149 fn kimi_k3_weight_map_dry_run_lists_96_shards() {
150 let map = synthetic_96_shard_map();
151 let report = dry_run_weight_map(&map, true).expect("complete text map");
152 assert_eq!(report.shards.len(), 96);
153 assert_eq!(report.present_text_classes.len(), TEXT_CLASSES.len());
154 assert!(report.missing_text_classes.is_empty());
155 assert!(report.unknown_keys.is_empty());
156 assert_eq!(report.shards[0], "model-00001-of-00096.safetensors");
157 assert_eq!(report.shards[95], "model-00096-of-00096.safetensors");
158 }
159
160 #[test]
161 fn kimi_k3_weight_map_dry_run_refuses_missing_text_class() {
162 let mut map = synthetic_96_shard_map();
163 let drop = example_key("language_model.model.output_attn_res_proj.weight");
164 map.remove(&drop);
165 let err = dry_run_weight_map(&map, true).unwrap_err().to_string();
166 assert!(
167 err.contains("missing required text classes"),
168 "unexpected error: {err}"
169 );
170 assert!(err.contains("output_attn_res_proj"), "{err}");
171 }
172
173 #[test]
174 fn kimi_k3_weight_map_dry_run_ignores_vision_when_language_model_only() {
175 let mut map = synthetic_96_shard_map();
176 map.insert(
177 "vision_tower.patch_embed.proj.weight".into(),
178 "model-00001-of-00096.safetensors".into(),
179 );
180 let report = dry_run_weight_map(&map, true).expect("vision ignored");
181 assert!(
182 report
183 .ignored_vision_classes
184 .iter()
185 .any(|c| c == "vision_tower.patch_embed.proj.weight")
186 );
187 }
188
189 #[test]
190 fn kimi_k3_weight_map_dry_run_index_json() {
191 let map = synthetic_96_shard_map();
192 let index = serde_json::json!({ "weight_map": map });
193 let report = dry_run_index_json(&index.to_string(), true).unwrap();
194 assert_eq!(report.shards.len(), 96);
195 require_shard_count(&report, 96).unwrap();
196 }
197
198 #[test]
199 fn kimi_k3_weight_map_dry_run_refuses_95_shards() {
200 let mut map = synthetic_96_shard_map();
201 map.retain(|_, shard| !shard.starts_with("model-00096-of-"));
202 let report = dry_run_weight_map(&map, true).expect("text classes still present");
203 let err = require_shard_count(&report, 96).unwrap_err().to_string();
204 assert!(err.contains("expected 96 shards"), "{err}");
205 assert!(err.contains("got 95"), "{err}");
206 }
207}