1use std::collections::{BTreeMap, HashMap, HashSet};
8use std::fs;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::Result;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ModelDiff {
20 pub left: String,
22 pub right: String,
24 pub size_delta: i64,
26 pub left_size: u64,
28 pub right_size: u64,
30 pub left_format: String,
32 pub right_format: String,
34 pub added_tensors: Vec<TensorInfo>,
36 pub removed_tensors: Vec<TensorInfo>,
38 pub changed_tensors: Vec<TensorChange>,
40 pub unchanged_count: usize,
42 pub param_delta: i64,
44 pub summary: DiffSummary,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TensorInfo {
51 pub name: String,
52 pub shape: Vec<usize>,
53 pub dtype: String,
54 pub param_count: u64,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct TensorChange {
60 pub name: String,
61 pub left_shape: Vec<usize>,
62 pub right_shape: Vec<usize>,
63 pub left_dtype: String,
64 pub right_dtype: String,
65 pub shape_changed: bool,
66 pub dtype_changed: bool,
67 pub param_delta: i64,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DiffSummary {
73 pub total_left_tensors: usize,
74 pub total_right_tensors: usize,
75 pub added: usize,
76 pub removed: usize,
77 pub changed: usize,
78 pub unchanged: usize,
79 pub size_change_pct: f64,
80}
81
82type TensorMap = BTreeMap<String, TensorInfo>;
86
87pub struct ModelDiffer;
91
92impl ModelDiffer {
93 pub fn diff_files(
95 left_path: &Path,
96 right_path: &Path,
97 left_label: &str,
98 right_label: &str,
99 ) -> Result<ModelDiff> {
100 let left_data = fs::read(left_path)?;
101 let right_data = fs::read(right_path)?;
102
103 let left_format = crate::formats::ModelFormat::from_extension(
104 left_path
105 .extension()
106 .and_then(|e| e.to_str())
107 .unwrap_or("bin"),
108 );
109 let right_format = crate::formats::ModelFormat::from_extension(
110 right_path
111 .extension()
112 .and_then(|e| e.to_str())
113 .unwrap_or("bin"),
114 );
115
116 let left_tensors = Self::parse_tensors(&left_data, &left_format);
117 let right_tensors = Self::parse_tensors(&right_data, &right_format);
118
119 Self::compute_diff(
120 &left_tensors,
121 &right_tensors,
122 left_label,
123 right_label,
124 left_data.len() as u64,
125 right_data.len() as u64,
126 &format!("{:?}", left_format),
127 &format!("{:?}", right_format),
128 )
129 }
130
131 pub fn diff_bytes(
133 left: &[u8],
134 right: &[u8],
135 left_label: &str,
136 right_label: &str,
137 left_fmt: &str,
138 right_fmt: &str,
139 ) -> Result<ModelDiff> {
140 let left_format = crate::formats::ModelFormat::from_stored(left_fmt);
142 let right_format = crate::formats::ModelFormat::from_stored(right_fmt);
143
144 let left_tensors = Self::parse_tensors(left, &left_format);
145 let right_tensors = Self::parse_tensors(right, &right_format);
146
147 Self::compute_diff(
148 &left_tensors,
149 &right_tensors,
150 left_label,
151 right_label,
152 left.len() as u64,
153 right.len() as u64,
154 left_fmt,
155 right_fmt,
156 )
157 }
158
159 #[allow(clippy::too_many_arguments)]
160 fn compute_diff(
161 left_tensors: &TensorMap,
162 right_tensors: &TensorMap,
163 left_label: &str,
164 right_label: &str,
165 left_size: u64,
166 right_size: u64,
167 left_format: &str,
168 right_format: &str,
169 ) -> Result<ModelDiff> {
170 let left_names: HashSet<&str> = left_tensors.keys().map(|s| s.as_str()).collect();
171 let right_names: HashSet<&str> = right_tensors.keys().map(|s| s.as_str()).collect();
172
173 let mut added_tensors = Vec::new();
174 let mut removed_tensors = Vec::new();
175 let mut changed_tensors = Vec::new();
176 let mut unchanged_count = 0;
177 let mut param_delta: i64 = 0;
178
179 for name in &right_names {
181 if !left_names.contains(name) {
182 if let Some(t) = right_tensors.get(*name) {
183 param_delta += t.param_count as i64;
184 added_tensors.push(t.clone());
185 }
186 }
187 }
188
189 for name in &left_names {
191 if !right_names.contains(name) {
192 if let Some(t) = left_tensors.get(*name) {
193 param_delta -= t.param_count as i64;
194 removed_tensors.push(t.clone());
195 }
196 }
197 }
198
199 for name in left_names.intersection(&right_names) {
201 let lt = &left_tensors[*name];
202 let rt = &right_tensors[*name];
203
204 let shape_changed = lt.shape != rt.shape;
205 let dtype_changed = lt.dtype != rt.dtype;
206
207 if shape_changed || dtype_changed {
208 let delta = rt.param_count as i64 - lt.param_count as i64;
209 param_delta += delta;
210 changed_tensors.push(TensorChange {
211 name: (*name).to_string(),
212 left_shape: lt.shape.clone(),
213 right_shape: rt.shape.clone(),
214 left_dtype: lt.dtype.clone(),
215 right_dtype: rt.dtype.clone(),
216 shape_changed,
217 dtype_changed,
218 param_delta: delta,
219 });
220 } else {
221 unchanged_count += 1;
222 }
223 }
224
225 let size_change_pct = if left_size > 0 {
226 (right_size as f64 - left_size as f64) / left_size as f64 * 100.0
227 } else {
228 0.0
229 };
230
231 let summary = DiffSummary {
232 total_left_tensors: left_tensors.len(),
233 total_right_tensors: right_tensors.len(),
234 added: added_tensors.len(),
235 removed: removed_tensors.len(),
236 changed: changed_tensors.len(),
237 unchanged: unchanged_count,
238 size_change_pct,
239 };
240
241 Ok(ModelDiff {
242 left: left_label.to_string(),
243 right: right_label.to_string(),
244 size_delta: right_size as i64 - left_size as i64,
245 left_size,
246 right_size,
247 left_format: left_format.to_string(),
248 right_format: right_format.to_string(),
249 added_tensors,
250 removed_tensors,
251 changed_tensors,
252 unchanged_count,
253 param_delta,
254 summary,
255 })
256 }
257
258 fn parse_tensors(data: &[u8], format: &crate::formats::ModelFormat) -> TensorMap {
261 match format {
262 crate::formats::ModelFormat::Safetensors => Self::parse_safetensors_header(data),
263 crate::formats::ModelFormat::GGUF => Self::parse_gguf_header(data),
264 _ => Self::parse_generic(data),
265 }
266 }
267
268 fn parse_safetensors_header(data: &[u8]) -> TensorMap {
272 let mut map = BTreeMap::new();
273
274 if data.len() < 8 {
275 return map;
276 }
277
278 let header_size = u64::from_le_bytes(data[0..8].try_into().unwrap_or_default()) as usize;
279
280 const MAX_HEADER_BYTES: usize = 100_000_000;
287 if header_size > MAX_HEADER_BYTES || data.len() - 8 < header_size {
288 return map;
289 }
290
291 let header_json = &data[8..8 + header_size];
292 if let Ok(header) =
293 serde_json::from_slice::<HashMap<String, serde_json::Value>>(header_json)
294 {
295 for (name, info) in &header {
296 if name == "__metadata__" {
297 continue;
298 }
299 if let Some(obj) = info.as_object() {
300 let dtype = obj
301 .get("dtype")
302 .and_then(|v| v.as_str())
303 .unwrap_or("F32")
304 .to_string();
305 let shape: Vec<usize> = obj
306 .get("shape")
307 .and_then(|v| v.as_array())
308 .map(|arr| {
309 arr.iter()
310 .filter_map(|v| v.as_u64().map(|n| n as usize))
311 .collect()
312 })
313 .unwrap_or_default();
314
315 let param_count: u64 = if shape.is_empty() {
316 1
317 } else {
318 shape.iter().product::<usize>() as u64
319 };
320
321 map.insert(
322 name.clone(),
323 TensorInfo {
324 name: name.clone(),
325 shape,
326 dtype,
327 param_count,
328 },
329 );
330 }
331 }
332 }
333
334 map
335 }
336
337 fn parse_gguf_header(data: &[u8]) -> TensorMap {
344 crate::gguf::tensors(data)
345 .into_iter()
346 .map(|t| {
347 (
348 t.name.clone(),
349 TensorInfo {
350 name: t.name,
351 shape: t.shape,
352 dtype: t.dtype.to_string(),
353 param_count: t.param_count,
354 },
355 )
356 })
357 .collect()
358 }
359
360 fn parse_generic(_data: &[u8]) -> TensorMap {
362 BTreeMap::new()
363 }
364}
365
366impl ModelDiff {
369 pub fn display(&self) -> String {
371 let mut out = String::new();
372
373 out.push_str(&format!("Model Diff: {} ↔ {}\n", self.left, self.right));
374 out.push_str("──────────────────────────────────\n");
375 out.push_str(&format!(
376 "Size: {} → {} ({:+} bytes, {:+.1}%)\n",
377 format_size(self.left_size),
378 format_size(self.right_size),
379 self.size_delta,
380 self.summary.size_change_pct
381 ));
382 out.push_str(&format!(
383 "Format: {} → {}\n",
384 self.left_format, self.right_format
385 ));
386 out.push_str(&format!(
387 "Tensors: {} → {} (added: {}, removed: {}, changed: {}, unchanged: {})\n",
388 self.summary.total_left_tensors,
389 self.summary.total_right_tensors,
390 self.summary.added,
391 self.summary.removed,
392 self.summary.changed,
393 self.summary.unchanged,
394 ));
395
396 if self.param_delta != 0 {
397 out.push_str(&format!("Parameter delta: {:+}\n", self.param_delta));
398 }
399
400 if !self.added_tensors.is_empty() {
401 out.push_str("\n+ Added tensors:\n");
402 for t in &self.added_tensors {
403 out.push_str(&format!(
404 " + {} {:?} {} ({} params)\n",
405 t.name, t.shape, t.dtype, t.param_count
406 ));
407 }
408 }
409
410 if !self.removed_tensors.is_empty() {
411 out.push_str("\n- Removed tensors:\n");
412 for t in &self.removed_tensors {
413 out.push_str(&format!(
414 " - {} {:?} {} ({} params)\n",
415 t.name, t.shape, t.dtype, t.param_count
416 ));
417 }
418 }
419
420 if !self.changed_tensors.is_empty() {
421 out.push_str("\n~ Changed tensors:\n");
422 for t in &self.changed_tensors {
423 if t.shape_changed {
424 out.push_str(&format!(
425 " ~ {} shape: {:?} → {:?}\n",
426 t.name, t.left_shape, t.right_shape
427 ));
428 }
429 if t.dtype_changed {
430 out.push_str(&format!(
431 " ~ {} dtype: {} → {}\n",
432 t.name, t.left_dtype, t.right_dtype
433 ));
434 }
435 }
436 }
437
438 out
439 }
440}
441
442fn format_size(bytes: u64) -> String {
443 if bytes >= 1_073_741_824 {
444 format!("{:.2} GiB", bytes as f64 / 1_073_741_824.0)
445 } else if bytes >= 1_048_576 {
446 format!("{:.2} MiB", bytes as f64 / 1_048_576.0)
447 } else if bytes >= 1024 {
448 format!("{:.2} KiB", bytes as f64 / 1024.0)
449 } else {
450 format!("{} B", bytes)
451 }
452}
453
454#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn make_safetensors_header(tensors: &[(&str, &str, &[usize])]) -> Vec<u8> {
461 let mut header = serde_json::Map::new();
462 for (name, dtype, shape) in tensors {
463 let mut info = serde_json::Map::new();
464 info.insert("dtype".to_string(), serde_json::json!(dtype));
465 info.insert("shape".to_string(), serde_json::json!(shape));
466 info.insert("data_offsets".to_string(), serde_json::json!([0, 0]));
467 header.insert(name.to_string(), serde_json::Value::Object(info));
468 }
469 let json = serde_json::to_vec(&header).unwrap();
470 let mut data = Vec::new();
471 data.extend_from_slice(&(json.len() as u64).to_le_bytes());
472 data.extend_from_slice(&json);
473 data
474 }
475
476 fn make_gguf_header(tensors: &[(&str, &[u64], u32)]) -> Vec<u8> {
479 use crate::gguf::test_support::{build, Meta};
480
481 build(
482 &[
483 ("general.architecture", Meta::Str("llama")),
484 ("tokenizer.ggml.tokens", Meta::StrArray(&["a", "bb"])),
485 ],
486 tensors,
487 )
488 }
489
490 #[test]
491 fn test_gguf_header_yields_real_tensor_metadata() {
492 let data = make_gguf_header(&[
493 ("blk.0.attn_q.weight", &[4096, 4096], 12), ("output_norm.weight", &[4096], 0), ]);
496
497 let map = ModelDiffer::parse_tensors(&data, &crate::formats::ModelFormat::GGUF);
498
499 assert_eq!(map.len(), 2);
500 let q = &map["blk.0.attn_q.weight"];
501 assert_eq!(q.shape, vec![4096, 4096]);
502 assert_eq!(q.dtype, "Q4_K");
503 assert_eq!(q.param_count, 4096 * 4096);
504 let norm = &map["output_norm.weight"];
505 assert_eq!(norm.shape, vec![4096]);
506 assert_eq!(norm.dtype, "F32");
507 }
508
509 #[test]
513 fn test_gguf_diff_detects_changes_at_equal_tensor_count() {
514 let left = make_gguf_header(&[("blk.0.attn_q.weight", &[4096, 4096], 0)]);
515 let right = make_gguf_header(&[("blk.0.attn_q.weight", &[4096, 4096], 12)]);
516
517 let diff = ModelDiffer::diff_bytes(&left, &right, "f32", "q4k", "gguf", "gguf").unwrap();
518
519 assert_eq!(diff.summary.changed, 1);
520 assert_eq!(diff.changed_tensors[0].left_dtype, "F32");
521 assert_eq!(diff.changed_tensors[0].right_dtype, "Q4_K");
522 assert!(diff.changed_tensors[0].dtype_changed);
523 assert!(!diff.changed_tensors[0].shape_changed);
524 }
525
526 #[test]
527 fn test_gguf_renamed_tensor_is_add_plus_remove() {
528 let left = make_gguf_header(&[("old.name", &[8], 0)]);
529 let right = make_gguf_header(&[("new.name", &[8], 0)]);
530
531 let diff = ModelDiffer::diff_bytes(&left, &right, "a", "b", "gguf", "gguf").unwrap();
532
533 assert_eq!(diff.summary.added, 1);
534 assert_eq!(diff.summary.removed, 1);
535 assert_eq!(diff.summary.unchanged, 0);
536 }
537
538 #[test]
539 fn test_gguf_truncated_header_is_not_fatal() {
540 let full = make_gguf_header(&[("a", &[2], 0), ("b", &[2], 0)]);
541 for cut in [4, 12, 24, 40, full.len() - 4] {
542 let map = ModelDiffer::parse_tensors(&full[..cut], &crate::formats::ModelFormat::GGUF);
543 assert!(
544 map.len() < 2,
545 "truncation at {cut} should not yield a full map"
546 );
547 }
548 }
549
550 #[test]
551 fn test_gguf_rejects_bad_magic_and_version() {
552 let mut bad_magic = make_gguf_header(&[("a", &[2], 0)]);
553 bad_magic[0] = b'X';
554 assert!(
555 ModelDiffer::parse_tensors(&bad_magic, &crate::formats::ModelFormat::GGUF).is_empty()
556 );
557
558 let mut bad_version = make_gguf_header(&[("a", &[2], 0)]);
559 bad_version[4..8].copy_from_slice(&99u32.to_le_bytes());
560 assert!(
561 ModelDiffer::parse_tensors(&bad_version, &crate::formats::ModelFormat::GGUF).is_empty()
562 );
563 }
564
565 #[test]
566 fn test_diff_identical() {
567 let data = make_safetensors_header(&[
568 ("layer.weight", "F32", &[768, 768]),
569 ("layer.bias", "F32", &[768]),
570 ]);
571
572 let diff = ModelDiffer::diff_bytes(&data, &data, "v1", "v2", "safetensors", "safetensors")
573 .unwrap();
574
575 assert_eq!(diff.summary.added, 0);
576 assert_eq!(diff.summary.removed, 0);
577 assert_eq!(diff.summary.changed, 0);
578 assert_eq!(diff.summary.unchanged, 2);
579 assert_eq!(diff.size_delta, 0);
580 }
581
582 #[test]
583 fn test_diff_added_tensor() {
584 let left = make_safetensors_header(&[("weight", "F32", &[768, 768])]);
585 let right =
586 make_safetensors_header(&[("weight", "F32", &[768, 768]), ("bias", "F32", &[768])]);
587
588 let diff = ModelDiffer::diff_bytes(&left, &right, "v1", "v2", "safetensors", "safetensors")
589 .unwrap();
590
591 assert_eq!(diff.summary.added, 1);
592 assert_eq!(diff.summary.removed, 0);
593 assert_eq!(diff.added_tensors[0].name, "bias");
594 }
595
596 #[test]
597 fn test_diff_removed_tensor() {
598 let left =
599 make_safetensors_header(&[("weight", "F32", &[768, 768]), ("bias", "F32", &[768])]);
600 let right = make_safetensors_header(&[("weight", "F32", &[768, 768])]);
601
602 let diff = ModelDiffer::diff_bytes(&left, &right, "v1", "v2", "safetensors", "safetensors")
603 .unwrap();
604
605 assert_eq!(diff.summary.removed, 1);
606 assert_eq!(diff.removed_tensors[0].name, "bias");
607 }
608
609 #[test]
610 fn test_diff_changed_shape() {
611 let left = make_safetensors_header(&[("weight", "F32", &[768, 768])]);
612 let right = make_safetensors_header(&[("weight", "F32", &[1024, 768])]);
613
614 let diff = ModelDiffer::diff_bytes(&left, &right, "v1", "v2", "safetensors", "safetensors")
615 .unwrap();
616
617 assert_eq!(diff.summary.changed, 1);
618 assert!(diff.changed_tensors[0].shape_changed);
619 }
620
621 #[test]
622 fn test_diff_display() {
623 let left = make_safetensors_header(&[("weight", "F32", &[768, 768])]);
624 let right = make_safetensors_header(&[
625 ("weight", "F16", &[1024, 768]),
626 ("new_layer", "F32", &[256]),
627 ]);
628
629 let diff = ModelDiffer::diff_bytes(
630 &left,
631 &right,
632 "model@v1",
633 "model@v2",
634 "safetensors",
635 "safetensors",
636 )
637 .unwrap();
638
639 let display = diff.display();
640 assert!(display.contains("model@v1"));
641 assert!(display.contains("model@v2"));
642 }
643 #[test]
651 fn test_safetensors_header_size_near_usize_max_does_not_overflow() {
652 for declared in [u64::MAX, u64::MAX - 7, u64::MAX - 8, i64::MAX as u64] {
653 let mut data = declared.to_le_bytes().to_vec();
654 data.extend_from_slice(br#"{"a":{"dtype":"F32","shape":[1]}}"#);
655
656 let map = ModelDiffer::parse_safetensors_header(&data);
658 assert!(
659 map.is_empty(),
660 "a header of {declared} bytes cannot be present in {} bytes of input",
661 data.len()
662 );
663 }
664 }
665
666 #[test]
669 fn test_safetensors_header_size_bounds_are_enforced() {
670 let mut over_cap = 200_000_000u64.to_le_bytes().to_vec();
672 over_cap.extend_from_slice(b"{}");
673 assert!(ModelDiffer::parse_safetensors_header(&over_cap).is_empty());
674
675 let mut truncated = 4096u64.to_le_bytes().to_vec();
677 truncated.extend_from_slice(b"{}");
678 assert!(ModelDiffer::parse_safetensors_header(&truncated).is_empty());
679 }
680
681 #[test]
684 fn test_safetensors_well_formed_header_still_parses() {
685 let json = br#"{"blk.0.weight":{"dtype":"F32","shape":[2,3]}}"#;
686 let mut data = (json.len() as u64).to_le_bytes().to_vec();
687 data.extend_from_slice(json);
688 data.extend_from_slice(&[0u8; 24]);
689
690 let map = ModelDiffer::parse_safetensors_header(&data);
691 assert_eq!(map.len(), 1, "expected one tensor, got {map:?}");
692 let t = map.get("blk.0.weight").expect("tensor missing");
693 assert_eq!(t.shape, vec![2, 3]);
694 assert_eq!(t.dtype, "F32");
695 }
696}