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