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