1#![allow(dead_code)]
8
9use anyhow::{Context, Result};
10use std::collections::HashMap;
11use std::path::Path;
12use tracing::{debug, info, warn};
13
14use torsh::core::device::DeviceType;
16
17use super::tensor_integration::ModelTensor;
18use super::types::{DType, Device, LayerInfo, ModelMetadata, TensorInfo, TorshModel};
19
20#[derive(Debug, Clone)]
22pub struct PyTorchModelInfo {
23 pub pytorch_version: Option<String>,
26 pub model_class: Option<String>,
28 pub state_dict_keys: Vec<String>,
30 pub file_size: u64,
32 pub num_parameters: u64,
34 pub is_full_model: bool,
36}
37
38impl PyTorchModelInfo {
39 pub fn version_display(&self) -> &str {
42 self.pytorch_version.as_deref().unwrap_or("unknown")
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum PyTorchLayerType {
49 Linear,
50 Conv2d,
51 Conv1d,
52 Conv3d,
53 BatchNorm2d,
54 BatchNorm1d,
55 LayerNorm,
56 Dropout,
57 Embedding,
58 LSTM,
59 GRU,
60 Attention,
61 Unknown,
62}
63
64impl PyTorchLayerType {
65 pub fn to_torsh_type(&self) -> &'static str {
67 match self {
68 PyTorchLayerType::Linear => "Linear",
69 PyTorchLayerType::Conv2d => "Conv2d",
70 PyTorchLayerType::Conv1d => "Conv1d",
71 PyTorchLayerType::Conv3d => "Conv3d",
72 PyTorchLayerType::BatchNorm2d => "BatchNorm2d",
73 PyTorchLayerType::BatchNorm1d => "BatchNorm1d",
74 PyTorchLayerType::LayerNorm => "LayerNorm",
75 PyTorchLayerType::Dropout => "Dropout",
76 PyTorchLayerType::Embedding => "Embedding",
77 PyTorchLayerType::LSTM => "LSTM",
78 PyTorchLayerType::GRU => "GRU",
79 PyTorchLayerType::Attention => "Attention",
80 PyTorchLayerType::Unknown => "Unknown",
81 }
82 }
83
84 pub fn from_param_name(param_name: &str) -> Self {
86 if param_name.contains("linear") || param_name.contains("fc") {
87 PyTorchLayerType::Linear
88 } else if param_name.contains("conv3d") {
89 PyTorchLayerType::Conv3d
90 } else if param_name.contains("conv1d") {
91 PyTorchLayerType::Conv1d
92 } else if param_name.contains("conv2d") || param_name.contains("conv") {
93 PyTorchLayerType::Conv2d
95 } else if param_name.contains("bn") || param_name.contains("batch_norm") {
96 PyTorchLayerType::BatchNorm2d
97 } else if param_name.contains("layer_norm") || param_name.contains("ln") {
98 PyTorchLayerType::LayerNorm
99 } else if param_name.contains("embed") {
100 PyTorchLayerType::Embedding
101 } else if param_name.contains("lstm") {
102 PyTorchLayerType::LSTM
103 } else if param_name.contains("gru") {
104 PyTorchLayerType::GRU
105 } else if param_name.contains("attn") || param_name.contains("attention") {
106 PyTorchLayerType::Attention
107 } else {
108 PyTorchLayerType::Unknown
109 }
110 }
111}
112
113pub async fn parse_pytorch_model(path: &Path) -> Result<PyTorchModelInfo> {
115 info!("Parsing PyTorch model from: {}", path.display());
116
117 let metadata = tokio::fs::metadata(path)
119 .await
120 .with_context(|| format!("Failed to read file metadata: {}", path.display()))?;
121
122 let file_size = metadata.len();
123
124 let file_data = tokio::fs::read(path)
126 .await
127 .with_context(|| format!("Failed to read PyTorch file: {}", path.display()))?;
128
129 let is_zip = file_data.len() >= 4 && &file_data[0..4] == b"PK\x03\x04";
131
132 debug!(
133 "PyTorch model format: {}",
134 if is_zip { "ZIP" } else { "Pickle" }
135 );
136
137 let (state_dict_keys, num_parameters, is_full_model) =
141 parse_pytorch_structure(&file_data, is_zip)?;
142
143 Ok(PyTorchModelInfo {
144 pytorch_version: detect_pytorch_version(&file_data),
145 model_class: None, state_dict_keys,
147 file_size,
148 num_parameters,
149 is_full_model,
150 })
151}
152
153fn parse_pytorch_structure(file_data: &[u8], is_zip: bool) -> Result<(Vec<String>, u64, bool)> {
161 let state_dict_keys = extract_state_dict_keys(file_data);
162
163 let is_full_model = find_subslice(file_data, b"torch.nn.modules").is_some()
167 || find_subslice(file_data, b"torch\nModule").is_some();
168
169 let num_parameters = if is_zip {
173 estimate_parameters_from_zip(file_data).unwrap_or_else(|| (file_data.len() / 4) as u64)
174 } else {
175 (file_data.len() / 4) as u64
176 };
177
178 Ok((state_dict_keys, num_parameters, is_full_model))
179}
180
181fn detect_pytorch_version(file_data: &[u8]) -> Option<String> {
197 if let Some(version) = scan_embedded_torch_version(file_data) {
198 debug!("Detected embedded torch version string: {}", version);
199 return Some(version);
200 }
201
202 let is_zip = file_data.len() >= 4 && &file_data[0..4] == b"PK\x03\x04";
203 if is_zip {
204 if let Some(protocol) = read_zip_serialization_protocol(file_data) {
205 debug!("Detected serialization protocol: {}", protocol);
206 return Some(format!("serialization protocol {}", protocol));
207 }
208 }
209
210 debug!("PyTorch version could not be determined from file metadata");
211 None
212}
213
214fn scan_embedded_torch_version(data: &[u8]) -> Option<String> {
221 const MARKERS: [&[u8]; 2] = [b"__version__", b"torch_version"];
222
223 for marker in MARKERS {
224 let mut search_start = 0;
225 while let Some(rel) = find_subslice(&data[search_start..], marker) {
226 let after = search_start + rel + marker.len();
227
228 if let Some(value) = read_pickle_string_after(&data[after..]) {
232 if let Some(version) = parse_version_token(value.as_bytes()) {
233 return Some(version);
234 }
235 }
236
237 let window_end = after.saturating_add(64).min(data.len()).max(after);
239 if let Some(version) = parse_version_token(&data[after..window_end]) {
240 return Some(version);
241 }
242
243 search_start = after;
244 }
245 }
246 None
247}
248
249fn read_pickle_string_after(data: &[u8]) -> Option<String> {
256 let scan_limit = data.len().min(16);
257 let mut i = 0;
258 while i < scan_limit {
259 match data[i] {
260 0x8c => {
261 let len_pos = i + 1;
263 let body_start = len_pos + 1;
264 if len_pos < data.len() {
265 let len = data[len_pos] as usize;
266 let body_end = body_start + len;
267 if body_end <= data.len() {
268 return Some(
269 String::from_utf8_lossy(&data[body_start..body_end]).into_owned(),
270 );
271 }
272 }
273 return None;
274 }
275 b'X' => {
276 let len_pos = i + 1;
278 let body_start = len_pos + 4;
279 if body_start <= data.len() {
280 let len = u32::from_le_bytes([
281 data[len_pos],
282 data[len_pos + 1],
283 data[len_pos + 2],
284 data[len_pos + 3],
285 ]) as usize;
286 let body_end = body_start + len;
287 if body_end <= data.len() && len <= 256 {
288 return Some(
289 String::from_utf8_lossy(&data[body_start..body_end]).into_owned(),
290 );
291 }
292 }
293 return None;
294 }
295 _ => i += 1,
296 }
297 }
298 None
299}
300
301fn parse_version_token(window: &[u8]) -> Option<String> {
309 let mut idx = 0;
310 while idx < window.len() && !window[idx].is_ascii_digit() {
312 idx += 1;
313 if idx > 8 {
314 return None;
316 }
317 }
318
319 let core_start = idx;
321 let mut dot_count = 0;
322 while idx < window.len() {
323 let byte = window[idx];
324 if byte.is_ascii_digit() {
325 idx += 1;
326 } else if byte == b'.' {
327 dot_count += 1;
328 idx += 1;
329 } else {
330 break;
331 }
332 }
333 let core_end = idx;
334
335 if dot_count < 2 || core_end == core_start {
336 return None;
337 }
338
339 let mut suffix_end = core_end;
342 if idx < window.len() && (window[idx] == b'+' || window[idx] == b'-') {
343 idx += 1;
344 while idx < window.len() {
345 let byte = window[idx];
346 if byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_' {
347 idx += 1;
348 } else {
349 break;
350 }
351 }
352 suffix_end = idx;
353 }
354
355 let token = String::from_utf8_lossy(&window[core_start..suffix_end]);
356 let trimmed = token.trim_end_matches(['.', '+', '-', '_']);
357 if trimmed.is_empty() {
358 None
359 } else {
360 Some(trimmed.to_string())
361 }
362}
363
364fn read_zip_serialization_protocol(data: &[u8]) -> Option<u32> {
371 const LOCAL_HEADER_SIG: &[u8] = b"PK\x03\x04";
372 let mut cursor = 0;
373
374 while let Some(rel) = find_subslice(&data[cursor..], LOCAL_HEADER_SIG) {
375 let header = cursor + rel;
376 if header + 30 > data.len() {
383 break;
384 }
385 let compression = u16::from_le_bytes([data[header + 8], data[header + 9]]);
386 let compressed_size = u32::from_le_bytes([
387 data[header + 18],
388 data[header + 19],
389 data[header + 20],
390 data[header + 21],
391 ]) as usize;
392 let name_len = u16::from_le_bytes([data[header + 26], data[header + 27]]) as usize;
393 let extra_len = u16::from_le_bytes([data[header + 28], data[header + 29]]) as usize;
394
395 let name_start = header + 30;
396 let name_end = name_start + name_len;
397 if name_end > data.len() {
398 break;
399 }
400 let name = &data[name_start..name_end];
401
402 let is_version_entry = name == b"version" || name.ends_with(b"/version");
405
406 if is_version_entry && compression == 0 {
408 let body_start = name_end + extra_len;
409 let body_end = body_start + compressed_size;
410 if body_end <= data.len() {
411 let body = String::from_utf8_lossy(&data[body_start..body_end]);
412 if let Ok(protocol) = body.trim().parse::<u32>() {
413 return Some(protocol);
414 }
415 }
416 }
417
418 cursor = header + 4;
419 }
420
421 None
422}
423
424fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
426 if needle.is_empty() || needle.len() > haystack.len() {
427 return None;
428 }
429 haystack
430 .windows(needle.len())
431 .position(|window| window == needle)
432}
433
434fn extract_state_dict_keys(data: &[u8]) -> Vec<String> {
442 const SUFFIXES: [&[u8]; 6] = [
443 b".weight",
444 b".bias",
445 b".running_mean",
446 b".running_var",
447 b".num_batches_tracked",
448 b".in_proj_weight",
449 ];
450
451 let mut keys: Vec<String> = Vec::new();
452 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
453
454 for suffix in SUFFIXES {
455 let mut search_start = 0;
456 while let Some(rel) = find_subslice(&data[search_start..], suffix) {
457 let suffix_pos = search_start + rel;
458 let name_end = suffix_pos + suffix.len();
459
460 let mut name_start = suffix_pos;
462 while name_start > 0 {
463 let candidate = data[name_start - 1];
464 if candidate.is_ascii_alphanumeric() || candidate == b'_' || candidate == b'.' {
465 name_start -= 1;
466 } else {
467 break;
468 }
469 }
470
471 if name_start < name_end {
472 let raw = String::from_utf8_lossy(&data[name_start..name_end]);
473 let token = raw.trim_start_matches('.');
476 let starts_clean = token
479 .chars()
480 .next()
481 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
482 if starts_clean && token.len() > suffix.len() {
483 let owned = token.to_string();
484 if seen.insert(owned.clone()) {
485 keys.push(owned);
486 }
487 }
488 }
489
490 search_start = name_end;
491 }
492 }
493
494 keys
495}
496
497fn estimate_parameters_from_zip(data: &[u8]) -> Option<u64> {
502 const LOCAL_HEADER_SIG: &[u8] = b"PK\x03\x04";
503 let mut cursor = 0;
504 let mut total_bytes: u64 = 0;
505 let mut found_any = false;
506
507 while let Some(rel) = find_subslice(&data[cursor..], LOCAL_HEADER_SIG) {
508 let header = cursor + rel;
509 if header + 30 > data.len() {
510 break;
511 }
512 let compressed_size = u32::from_le_bytes([
513 data[header + 18],
514 data[header + 19],
515 data[header + 20],
516 data[header + 21],
517 ]) as u64;
518 let name_len = u16::from_le_bytes([data[header + 26], data[header + 27]]) as usize;
519
520 let name_start = header + 30;
521 let name_end = name_start + name_len;
522 if name_end > data.len() {
523 break;
524 }
525 let name = &data[name_start..name_end];
526
527 if let Some(data_dir) = find_subslice(name, b"/data/") {
530 let tail = &name[data_dir + b"/data/".len()..];
531 if !tail.is_empty() && tail.iter().all(|b| b.is_ascii_digit()) {
532 total_bytes += compressed_size;
533 found_any = true;
534 }
535 }
536
537 cursor = header + 4;
538 }
539
540 if found_any {
541 Some(total_bytes / 4)
544 } else {
545 None
546 }
547}
548
549pub async fn convert_pytorch_to_torsh(
551 pytorch_path: &Path,
552 device: DeviceType,
553) -> Result<TorshModel> {
554 info!("Converting PyTorch model to ToRSh format");
555
556 let pytorch_info = parse_pytorch_model(pytorch_path).await?;
557
558 let file_data = tokio::fs::read(pytorch_path).await?;
562 let (layers, weights) = match super::pytorch_reader::read_state_dict(&file_data) {
563 Ok(tensors) => {
564 let total_elements: usize = tensors.iter().map(|t| t.element_count()).sum();
565 info!(
566 "Reconstructed {} real tensors ({} elements) from the checkpoint",
567 tensors.len(),
568 total_elements
569 );
570 build_structure_from_tensors(&tensors)
571 }
572 Err(e) => {
573 warn!(
574 "could not fully deserialize tensors ({e}); falling back to name-based shape inference"
575 );
576 build_torsh_structure(&pytorch_info, device)?
577 }
578 };
579
580 let mut metadata = ModelMetadata::default();
581 metadata.format = "torsh".to_string();
582 metadata.framework = "pytorch".to_string();
583 metadata.description = Some(format!(
584 "Converted from PyTorch {} model",
585 pytorch_info.version_display()
586 ));
587 metadata.tags = vec!["converted".to_string(), "pytorch".to_string()];
588
589 metadata
591 .custom
592 .insert("original_format".to_string(), serde_json::json!("pytorch"));
593 metadata.custom.insert(
594 "pytorch_version".to_string(),
595 serde_json::json!(pytorch_info.pytorch_version),
596 );
597 metadata.custom.insert(
598 "original_file_size".to_string(),
599 serde_json::json!(pytorch_info.file_size),
600 );
601
602 Ok(TorshModel {
603 layers,
604 weights,
605 metadata,
606 })
607}
608
609fn build_structure_from_tensors(
612 tensors: &[super::pytorch_reader::PytorchTensor],
613) -> (Vec<LayerInfo>, HashMap<String, TensorInfo>) {
614 use super::pytorch_reader::TensorDType;
615
616 let mut weights = HashMap::new();
617 let mut layer_order: Vec<String> = Vec::new();
618 let mut layer_params: HashMap<String, Vec<Vec<usize>>> = HashMap::new();
619
620 for t in tensors {
621 let dtype = match t.dtype {
622 TensorDType::F32 => DType::F32,
623 TensorDType::F64 => DType::F64,
624 TensorDType::I64 => DType::I64,
625 };
626 weights.insert(
627 t.name.clone(),
628 TensorInfo {
629 name: t.name.clone(),
630 shape: t.shape.clone(),
631 dtype,
632 requires_grad: t.requires_grad
633 && !t.name.contains("running")
634 && !t.name.contains("num_batches_tracked"),
635 device: Device::Cpu,
636 },
637 );
638
639 let layer_name = match t.name.rfind('.') {
640 Some(pos) => t.name[..pos].to_string(),
641 None => t.name.clone(),
642 };
643 if !layer_params.contains_key(&layer_name) {
644 layer_order.push(layer_name.clone());
645 }
646 layer_params
647 .entry(layer_name)
648 .or_default()
649 .push(t.shape.clone());
650 }
651
652 let mut layers = Vec::with_capacity(layer_order.len());
653 for layer_name in layer_order {
654 let shapes = layer_params.get(&layer_name).cloned().unwrap_or_default();
655 let params: u64 = shapes
656 .iter()
657 .map(|s| s.iter().product::<usize>() as u64)
658 .sum();
659 let repr_shape = shapes
661 .iter()
662 .max_by_key(|s| s.len())
663 .cloned()
664 .unwrap_or_else(|| vec![params.max(1) as usize]);
665 let (input_shape, output_shape) = if repr_shape.len() >= 2 {
666 (vec![repr_shape[1]], vec![repr_shape[0]])
667 } else {
668 (repr_shape.clone(), repr_shape.clone())
669 };
670 let layer_type = PyTorchLayerType::from_param_name(&layer_name);
671
672 layers.push(LayerInfo {
673 name: layer_name,
674 layer_type: layer_type.to_torsh_type().to_string(),
675 input_shape,
676 output_shape,
677 parameters: params,
678 trainable: true,
679 config: create_layer_config(layer_type),
680 });
681 }
682
683 (layers, weights)
684}
685
686fn build_torsh_structure(
688 pytorch_info: &PyTorchModelInfo,
689 _device: DeviceType,
690) -> Result<(Vec<LayerInfo>, HashMap<String, TensorInfo>)> {
691 debug!(
692 "Building ToRSh structure from {} parameters",
693 pytorch_info.num_parameters
694 );
695
696 let mut layers = Vec::new();
697 let mut weights = HashMap::new();
698
699 let layer_groups = group_parameters_by_layer(&pytorch_info.state_dict_keys);
701
702 for (layer_name, param_names) in layer_groups {
703 debug!(
704 "Processing layer: {} with {} parameters",
705 layer_name,
706 param_names.len()
707 );
708
709 let layer_type = PyTorchLayerType::from_param_name(&layer_name);
711
712 let (input_shape, output_shape) = infer_layer_shapes(¶m_names, layer_type);
714
715 let param_count = estimate_layer_parameters(¶m_names, layer_type);
717
718 let layer = LayerInfo {
720 name: layer_name.clone(),
721 layer_type: layer_type.to_torsh_type().to_string(),
722 input_shape,
723 output_shape,
724 parameters: param_count,
725 trainable: true,
726 config: create_layer_config(layer_type),
727 };
728
729 layers.push(layer);
730
731 for param_name in param_names {
733 let shape = infer_tensor_shape(¶m_name, layer_type);
734
735 let weight_info = TensorInfo {
736 name: param_name.clone(),
737 shape,
738 dtype: DType::F32,
739 requires_grad: !param_name.contains("running"), device: Device::Cpu,
741 };
742
743 weights.insert(param_name, weight_info);
744 }
745 }
746
747 Ok((layers, weights))
748}
749
750fn group_parameters_by_layer(param_names: &[String]) -> HashMap<String, Vec<String>> {
752 let mut groups: HashMap<String, Vec<String>> = HashMap::new();
753
754 for param_name in param_names {
755 let layer_name = if let Some(pos) = param_name.rfind('.') {
757 param_name[..pos].to_string()
758 } else {
759 param_name.clone()
760 };
761
762 groups
763 .entry(layer_name)
764 .or_insert_with(Vec::new)
765 .push(param_name.clone());
766 }
767
768 groups
769}
770
771fn infer_layer_shapes(
773 param_names: &[String],
774 layer_type: PyTorchLayerType,
775) -> (Vec<usize>, Vec<usize>) {
776 let weight_param = param_names.iter().find(|name| name.ends_with(".weight"));
778
779 match layer_type {
780 PyTorchLayerType::Linear => {
781 if weight_param.is_some() {
783 let input_dim = 512;
785 let output_dim = 256;
786 (vec![input_dim], vec![output_dim])
787 } else {
788 (vec![512], vec![256])
789 }
790 }
791 PyTorchLayerType::Conv2d => {
792 (vec![3, 224, 224], vec![64, 112, 112])
794 }
795 PyTorchLayerType::BatchNorm2d | PyTorchLayerType::BatchNorm1d => {
796 (vec![64, 56, 56], vec![64, 56, 56])
798 }
799 PyTorchLayerType::Embedding => {
800 (vec![30000], vec![512])
802 }
803 PyTorchLayerType::LSTM | PyTorchLayerType::GRU => {
804 (vec![128, 512], vec![128, 256])
806 }
807 _ => (vec![512], vec![512]),
808 }
809}
810
811fn estimate_layer_parameters(param_names: &[String], layer_type: PyTorchLayerType) -> u64 {
813 let (input_shape, output_shape) = infer_layer_shapes(param_names, layer_type);
814
815 let input_size: u64 = input_shape.iter().map(|&x| x as u64).product();
816 let output_size: u64 = output_shape.iter().map(|&x| x as u64).product();
817
818 match layer_type {
819 PyTorchLayerType::Linear => {
820 input_size * output_size + output_size
822 }
823 PyTorchLayerType::Conv2d => {
824 let kernel_size = 9; output_size * kernel_size + output_size }
828 PyTorchLayerType::BatchNorm2d | PyTorchLayerType::BatchNorm1d => {
829 output_size * 4
831 }
832 PyTorchLayerType::Embedding => input_size * output_size,
833 _ => output_size,
834 }
835}
836
837fn infer_tensor_shape(param_name: &str, layer_type: PyTorchLayerType) -> Vec<usize> {
839 if param_name.ends_with(".weight") {
840 match layer_type {
841 PyTorchLayerType::Linear => vec![256, 512],
842 PyTorchLayerType::Conv2d => vec![64, 3, 3, 3], PyTorchLayerType::BatchNorm2d => vec![64],
844 PyTorchLayerType::Embedding => vec![30000, 512],
845 _ => vec![512, 512],
846 }
847 } else if param_name.ends_with(".bias") {
848 match layer_type {
849 PyTorchLayerType::Linear => vec![256],
850 PyTorchLayerType::Conv2d => vec![64],
851 _ => vec![512],
852 }
853 } else if param_name.contains("running_mean") || param_name.contains("running_var") {
854 vec![64]
855 } else {
856 vec![512]
857 }
858}
859
860fn create_layer_config(layer_type: PyTorchLayerType) -> HashMap<String, serde_json::Value> {
862 let mut config = HashMap::new();
863
864 match layer_type {
865 PyTorchLayerType::Conv2d => {
866 config.insert("kernel_size".to_string(), serde_json::json!(3));
867 config.insert("stride".to_string(), serde_json::json!(1));
868 config.insert("padding".to_string(), serde_json::json!(1));
869 }
870 PyTorchLayerType::Dropout => {
871 config.insert("p".to_string(), serde_json::json!(0.5));
872 }
873 PyTorchLayerType::LSTM | PyTorchLayerType::GRU => {
874 config.insert("hidden_size".to_string(), serde_json::json!(256));
875 config.insert("num_layers".to_string(), serde_json::json!(2));
876 config.insert("bidirectional".to_string(), serde_json::json!(false));
877 }
878 _ => {}
879 }
880
881 config
882}
883
884pub fn map_pytorch_tensor_to_torsh(
892 pytorch_tensor: &[u8],
893 shape: Vec<usize>,
894 requires_grad: bool,
895 device: DeviceType,
896) -> Result<ModelTensor> {
897 let num_elements: usize = shape.iter().product();
898 let expected_bytes = num_elements * std::mem::size_of::<f32>();
899 if pytorch_tensor.len() != expected_bytes {
900 anyhow::bail!(
901 "raw tensor storage is {} bytes but shape {:?} needs {} f32 bytes",
902 pytorch_tensor.len(),
903 shape,
904 expected_bytes
905 );
906 }
907
908 let data: Vec<f32> = pytorch_tensor
909 .chunks_exact(4)
910 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
911 .collect();
912
913 ModelTensor::from_data("converted".to_string(), data, shape, requires_grad, device)
914}
915
916pub fn validate_conversion(
918 pytorch_info: &PyTorchModelInfo,
919 torsh_model: &TorshModel,
920) -> Result<()> {
921 info!("Validating PyTorch to ToRSh conversion");
922
923 let torsh_params: u64 = torsh_model.layers.iter().map(|l| l.parameters).sum();
925
926 let param_ratio = torsh_params as f64 / pytorch_info.num_parameters as f64;
927
928 if param_ratio < 0.5 || param_ratio > 2.0 {
929 warn!(
930 "Parameter count mismatch: PyTorch {} vs ToRSh {} (ratio: {:.2})",
931 pytorch_info.num_parameters, torsh_params, param_ratio
932 );
933 }
934
935 for layer in &torsh_model.layers {
937 if layer.input_shape.is_empty() || layer.output_shape.is_empty() {
938 anyhow::bail!("Layer {} has invalid shape", layer.name);
939 }
940 }
941
942 info!("Conversion validation passed");
943 Ok(())
944}
945
946pub fn generate_conversion_report(
948 pytorch_info: &PyTorchModelInfo,
949 torsh_model: &TorshModel,
950) -> String {
951 let mut report = String::new();
952
953 report.push_str("╔═══════════════════════════════════════════════════════════════════════╗\n");
954 report.push_str("║ PYTORCH → TORSH CONVERSION REPORT ║\n");
955 report
956 .push_str("╚═══════════════════════════════════════════════════════════════════════╝\n\n");
957
958 report.push_str("📦 Source Model (PyTorch)\n");
959 report.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
960 report.push_str(&format!(
961 " PyTorch Version: {}\n",
962 pytorch_info.version_display()
963 ));
964 report.push_str(&format!(
965 " File Size: {:.2} MB\n",
966 pytorch_info.file_size as f64 / (1024.0 * 1024.0)
967 ));
968 report.push_str(&format!(
969 " Parameters: {}\n",
970 pytorch_info.num_parameters
971 ));
972 report.push_str(&format!(
973 " State Dict Keys: {}\n",
974 pytorch_info.state_dict_keys.len()
975 ));
976 report.push_str("\n");
977
978 report.push_str("🎯 Target Model (ToRSh)\n");
979 report.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
980 let torsh_params: u64 = torsh_model.layers.iter().map(|l| l.parameters).sum();
981 report.push_str(&format!(
982 " ToRSh Version: {}\n",
983 torsh_model.metadata.version
984 ));
985 report.push_str(&format!(
986 " Layers: {}\n",
987 torsh_model.layers.len()
988 ));
989 report.push_str(&format!(" Parameters: {}\n", torsh_params));
990 report.push_str(&format!(
991 " Tensors: {}\n",
992 torsh_model.weights.len()
993 ));
994 report.push_str("\n");
995
996 report.push_str("📊 Conversion Statistics\n");
997 report.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
998 let param_ratio = torsh_params as f64 / pytorch_info.num_parameters as f64;
999 report.push_str(&format!(" Parameter Ratio: {:.2}\n", param_ratio));
1000 report.push_str(&format!(
1001 " Layers Created: {}\n",
1002 torsh_model.layers.len()
1003 ));
1004
1005 report.push_str("\n");
1006 report
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 #[test]
1014 fn test_layer_type_inference() {
1015 assert_eq!(
1016 PyTorchLayerType::from_param_name("model.fc1.weight"),
1017 PyTorchLayerType::Linear
1018 );
1019 assert_eq!(
1020 PyTorchLayerType::from_param_name("conv1.weight"),
1021 PyTorchLayerType::Conv2d
1022 );
1023 assert_eq!(
1024 PyTorchLayerType::from_param_name("bn1.running_mean"),
1025 PyTorchLayerType::BatchNorm2d
1026 );
1027 }
1028
1029 #[test]
1030 fn test_parameter_grouping() {
1031 let params = vec![
1032 "layer1.weight".to_string(),
1033 "layer1.bias".to_string(),
1034 "layer2.weight".to_string(),
1035 "layer2.bias".to_string(),
1036 ];
1037
1038 let groups = group_parameters_by_layer(¶ms);
1039 assert_eq!(groups.len(), 2);
1040 assert_eq!(
1041 groups
1042 .get("layer1")
1043 .expect("element retrieval should succeed for valid index")
1044 .len(),
1045 2
1046 );
1047 assert_eq!(
1048 groups
1049 .get("layer2")
1050 .expect("element retrieval should succeed for valid index")
1051 .len(),
1052 2
1053 );
1054 }
1055
1056 #[test]
1057 fn test_shape_inference() {
1058 let params = vec!["fc.weight".to_string(), "fc.bias".to_string()];
1059 let (input, output) = infer_layer_shapes(¶ms, PyTorchLayerType::Linear);
1060
1061 assert!(!input.is_empty());
1062 assert!(!output.is_empty());
1063 }
1064
1065 #[test]
1066 fn test_layer_config_creation() {
1067 let config = create_layer_config(PyTorchLayerType::Conv2d);
1068 assert!(config.contains_key("kernel_size"));
1069 assert!(config.contains_key("stride"));
1070 assert!(config.contains_key("padding"));
1071 }
1072
1073 #[test]
1074 fn test_detect_version_returns_none_on_unknown() {
1075 let junk = vec![0x00u8, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77];
1077 assert_eq!(detect_pytorch_version(&junk), None);
1078 }
1079
1080 #[test]
1081 fn test_scan_embedded_torch_version() {
1082 let mut data = Vec::new();
1085 data.extend_from_slice(b"\x80\x02}q\x00X\x0b\x00\x00\x00__version__q\x01");
1086 data.extend_from_slice(b"X\x05\x00\x00\x002.1.0q\x02");
1087 let version = scan_embedded_torch_version(&data);
1088 assert_eq!(version.as_deref(), Some("2.1.0"));
1089 }
1090
1091 #[test]
1092 fn test_parse_version_token_local_suffix() {
1093 assert_eq!(
1095 parse_version_token(b"q\x002.0.1+cu118\x00"),
1096 Some("2.0.1+cu118".to_string())
1097 );
1098 assert_eq!(
1100 parse_version_token(b"\x001.13.0\x00"),
1101 Some("1.13.0".to_string())
1102 );
1103 assert_eq!(parse_version_token(b"abc"), None);
1105 assert_eq!(parse_version_token(b"12"), None);
1106 }
1107
1108 #[test]
1109 fn test_read_pickle_string_short_binunicode() {
1110 let mut data = vec![0x71, 0x01, 0x8c, 0x05];
1112 data.extend_from_slice(b"2.0.0");
1113 data.extend_from_slice(b"q\x02");
1114 assert_eq!(read_pickle_string_after(&data).as_deref(), Some("2.0.0"));
1115 }
1116
1117 #[test]
1118 fn test_extract_state_dict_keys_real_names() {
1119 let mut data = Vec::new();
1121 data.extend_from_slice(b"...q\x00conv1.weightq\x01....fc.biasq\x02....bn.running_meanq");
1122 let keys = extract_state_dict_keys(&data);
1123 assert!(keys.contains(&"conv1.weight".to_string()));
1124 assert!(keys.contains(&"fc.bias".to_string()));
1125 assert!(keys.contains(&"bn.running_mean".to_string()));
1126 }
1127
1128 #[test]
1129 fn test_extract_state_dict_keys_empty_when_absent() {
1130 let data = b"no parameter names here, just prose".to_vec();
1132 assert!(extract_state_dict_keys(&data).is_empty());
1133 }
1134}