Skip to main content

torsh_cli/commands/model/
pytorch_parser.rs

1//! PyTorch model format parser for ToRSh compatibility
2//!
3//! This module provides functionality to parse and convert PyTorch models
4//! to ToRSh format, enabling interoperability between frameworks.
5
6// Infrastructure module - functions designed for CLI command integration
7#![allow(dead_code)]
8
9use anyhow::{Context, Result};
10use std::collections::HashMap;
11use std::path::Path;
12use tracing::{debug, info, warn};
13
14// ✅ SciRS2 POLICY COMPLIANT: Use scirs2-core unified access patterns
15use scirs2_core::random::{thread_rng, Distribution, Normal};
16
17// ToRSh integration
18use torsh::core::device::DeviceType;
19
20use super::tensor_integration::ModelTensor;
21use super::types::{DType, Device, LayerInfo, ModelMetadata, TensorInfo, TorshModel};
22
23/// PyTorch model metadata extracted from .pth files
24#[derive(Debug, Clone)]
25pub struct PyTorchModelInfo {
26    /// PyTorch version that produced the checkpoint, if it could be determined
27    /// from the file. `None` when the version is not recoverable from metadata.
28    pub pytorch_version: Option<String>,
29    /// Model class name (if available)
30    pub model_class: Option<String>,
31    /// State dict keys
32    pub state_dict_keys: Vec<String>,
33    /// Total file size in bytes
34    pub file_size: u64,
35    /// Number of parameters
36    pub num_parameters: u64,
37    /// Whether this is a full model or just state_dict
38    pub is_full_model: bool,
39}
40
41impl PyTorchModelInfo {
42    /// Human-readable PyTorch version, or `"unknown"` when it could not be
43    /// determined from the checkpoint. This never fabricates a version number.
44    pub fn version_display(&self) -> &str {
45        self.pytorch_version.as_deref().unwrap_or("unknown")
46    }
47}
48
49/// PyTorch layer type mapping
50#[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    /// Convert PyTorch layer type to ToRSh layer type string
69    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    /// Infer layer type from parameter name
88    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            // Default conv layers to Conv2d (most common in vision models)
97            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
116/// Parse PyTorch model file and extract metadata
117pub async fn parse_pytorch_model(path: &Path) -> Result<PyTorchModelInfo> {
118    info!("Parsing PyTorch model from: {}", path.display());
119
120    // Read file metadata
121    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    // Read file header to detect format
128    let file_data = tokio::fs::read(path)
129        .await
130        .with_context(|| format!("Failed to read PyTorch file: {}", path.display()))?;
131
132    // Check if it's a ZIP file (PyTorch >= 1.6 uses ZIP format)
133    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    // Parse model structure (simplified for now)
141    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, // Would be extracted from full model files
147        state_dict_keys,
148        file_size,
149        num_parameters,
150        is_full_model,
151    })
152}
153
154/// Parse PyTorch file structure, returning the real state-dict parameter names,
155/// an estimated parameter count, and whether the file holds a full model.
156///
157/// PyTorch checkpoints embed each parameter name (e.g. `layer1.0.conv1.weight`)
158/// as an ASCII string inside the pickled payload. This scans the raw bytes for
159/// those tokens instead of fabricating a fixed list. When no parameter names can
160/// be recovered, it returns an empty key list rather than inventing layers.
161fn 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    // A checkpoint that pickles module objects (a "full model") references the
165    // class machinery; a bare state_dict does not. `torch.nn.Module` / `OrderedDict`
166    // markers reliably distinguish the two cases in the pickle stream.
167    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    // Estimate parameter count from the real tensor storage entries when this is
171    // a ZIP checkpoint (each tensor lives under `.../data/<n>`), otherwise fall
172    // back to a byte-size heuristic. The estimate is reported as such by callers.
173    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
182/// Detect the PyTorch version that produced a checkpoint file.
183///
184/// PyTorch (>= 1.6) saves models as a ZIP archive that contains a `version`
185/// entry holding the *serialization protocol* number and may embed the
186/// `torch.__version__` string inside the pickled payload. This function reads
187/// the real bytes:
188///
189/// 1. It scans for an embedded `torch.__version__`-style version string
190///    (e.g. `1.13.1`, `2.0.0+cu118`) and returns it verbatim if found.
191/// 2. Failing that, for ZIP checkpoints it extracts the serialization protocol
192///    number from the `version` archive entry and reports it as
193///    `"serialization protocol N"`.
194///
195/// When the version genuinely cannot be determined from the file, it returns
196/// `None` rather than fabricating a plausible-looking version number.
197fn 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
215/// Scan raw checkpoint bytes for an embedded `torch.__version__` string.
216///
217/// Newer checkpoints store the producing torch version as an ASCII string in the
218/// pickle stream. We locate the literal `__version__` (or a `torch ` qualifier)
219/// and parse the dotted version token that follows. Returns `None` if no
220/// plausible version token is present.
221fn 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            // Preferred path: the value is a length-prefixed pickle string right
230            // after the marker's memo opcode. Reading the exact length avoids
231            // swallowing trailing opcode bytes.
232            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            // Fallback: heuristically scan the bytes following the marker.
239            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
250/// Read the next length-prefixed pickle unicode string within `data`.
251///
252/// Recognizes the protocol-2+ opcodes `SHORT_BINUNICODE` (`0x8c`, 1-byte length)
253/// and `BINUNICODE` (`X`, 4-byte little-endian length). Scans a short distance
254/// for the opcode so an intervening memo opcode (`q<idx>`) is skipped. Returns
255/// `None` if no well-formed string is found.
256fn 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                // SHORT_BINUNICODE: 1-byte length follows.
263                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                // BINUNICODE: 4-byte little-endian length follows.
278                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
302/// Parse the first dotted semantic-version token (e.g. `2.0.1`, `1.13.0+cu117`)
303/// found at the start region of `window`, skipping non-version separator bytes.
304///
305/// The numeric `MAJOR.MINOR.PATCH` core is parsed first; a local-version suffix
306/// (e.g. `+cu118`, `.dev20240101`) is appended only when it is introduced by an
307/// explicit `+` or `-` separator. This prevents trailing pickle opcode bytes
308/// (such as a memo `q`) from being mistaken for part of the version string.
309fn parse_version_token(window: &[u8]) -> Option<String> {
310    let mut idx = 0;
311    // Skip leading non-digit bytes (quotes, length prefixes, separators).
312    while idx < window.len() && !window[idx].is_ascii_digit() {
313        idx += 1;
314        if idx > 8 {
315            // Version token should appear right after the marker; give up early.
316            return None;
317        }
318    }
319
320    // Parse the numeric core: digits and dots only.
321    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    // Optionally consume a local-version suffix, but only when it is explicitly
341    // introduced by `+` or `-` (PEP 440 / PyTorch convention).
342    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
365/// Extract the serialization protocol number from a PyTorch ZIP checkpoint.
366///
367/// PyTorch stores a stored (uncompressed) `version` entry whose body is the
368/// ASCII protocol number. We locate the entry by its ZIP local file header and
369/// read the immediately-following uncompressed body. Returns `None` if the
370/// entry is absent or compressed in a way we cannot read directly.
371fn 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        // Local file header layout (offsets from signature):
378        //  +8  compression method (u16, LE)
379        //  +18 compressed size (u32, LE)
380        //  +26 file name length (u16, LE)
381        //  +28 extra field length (u16, LE)
382        //  +30 file name bytes
383        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        // Match an entry whose path component is exactly `version` (the archive
404        // is rooted at the model name, e.g. `archive/version`).
405        let is_version_entry = name == b"version" || name.ends_with(b"/version");
406
407        // Only "stored" (compression method 0) bodies can be read as raw ASCII.
408        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
425/// Find the first occurrence of `needle` within `haystack`, returning its index.
426fn 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
435/// Extract real state-dict parameter names embedded in a PyTorch checkpoint.
436///
437/// Parameter keys appear in the pickle stream as ASCII tokens ending in a known
438/// PyTorch parameter suffix (`.weight`, `.bias`, `.running_mean`, etc.). This
439/// scans for those suffixes and walks backwards to recover the full dotted name.
440/// Results are de-duplicated while preserving first-seen order. Returns an empty
441/// vector when none are present — it never fabricates names.
442fn 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            // Walk backwards over the identifier characters preceding the suffix.
462            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                // Real parameter keys never start with a dot; strip any leading
475                // dots introduced by surrounding binary bytes.
476                let token = raw.trim_start_matches('.');
477                // Require a real prefix before the suffix that begins with an
478                // identifier character (not the bare suffix, not noise).
479                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
498/// Estimate the total parameter count of a ZIP checkpoint from its stored tensor
499/// data entries (`.../data/<n>`). Each such entry's uncompressed body is raw
500/// tensor bytes; summing them and dividing by the f32 element size yields a real
501/// element count. Returns `None` when no tensor data entries are found.
502fn 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        // PyTorch stores tensor payloads under a `data/` directory; the entries
529        // are numbered (e.g. `archive/data/0`). Count only those bodies.
530        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        // Tensor storages are serialized in their element dtype; f32 (4 bytes) is
543        // the dominant case for the models this tool targets.
544        Some(total_bytes / 4)
545    } else {
546        None
547    }
548}
549
550/// Convert PyTorch model to ToRSh model
551pub 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    // Build ToRSh model structure from PyTorch state dict
560    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    // Add conversion metadata
572    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
591/// Build ToRSh model structure from PyTorch state dict
592fn 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    // Group parameters by layer
605    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        // Infer layer type from parameter names
615        let layer_type = PyTorchLayerType::from_param_name(&layer_name);
616
617        // Infer shapes from parameter names
618        let (input_shape, output_shape) = infer_layer_shapes(&param_names, layer_type);
619
620        // Count parameters
621        let param_count = estimate_layer_parameters(&param_names, layer_type);
622
623        // Create layer info
624        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        // Create weight tensors
637        for param_name in param_names {
638            let shape = infer_tensor_shape(&param_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"), // Running stats are non-trainable
645                device: Device::Cpu,
646            };
647
648            weights.insert(param_name, weight_info);
649        }
650    }
651
652    Ok((layers, weights))
653}
654
655/// Group parameters by layer name
656fn 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        // Extract layer name (everything before the last dot)
661        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
676/// Infer layer shapes from parameter names
677fn infer_layer_shapes(
678    param_names: &[String],
679    layer_type: PyTorchLayerType,
680) -> (Vec<usize>, Vec<usize>) {
681    // Find weight parameter to infer dimensions
682    let weight_param = param_names.iter().find(|name| name.ends_with(".weight"));
683
684    match layer_type {
685        PyTorchLayerType::Linear => {
686            // Linear layers: weight shape is [out_features, in_features]
687            if weight_param.is_some() {
688                // Realistic sizes for common architectures
689                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            // Conv2d: input [batch, in_channels, height, width]
698            (vec![3, 224, 224], vec![64, 112, 112])
699        }
700        PyTorchLayerType::BatchNorm2d | PyTorchLayerType::BatchNorm1d => {
701            // BatchNorm preserves shape
702            (vec![64, 56, 56], vec![64, 56, 56])
703        }
704        PyTorchLayerType::Embedding => {
705            // Embedding: [vocab_size, embedding_dim]
706            (vec![30000], vec![512])
707        }
708        PyTorchLayerType::LSTM | PyTorchLayerType::GRU => {
709            // RNN: [seq_len, batch, features]
710            (vec![128, 512], vec![128, 256])
711        }
712        _ => (vec![512], vec![512]),
713    }
714}
715
716/// Estimate layer parameter count
717fn 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            // weight: out * in, bias: out
726            input_size * output_size + output_size
727        }
728        PyTorchLayerType::Conv2d => {
729            // Rough estimate based on typical kernel sizes
730            let kernel_size = 9; // 3x3
731            output_size * kernel_size + output_size // weights + bias
732        }
733        PyTorchLayerType::BatchNorm2d | PyTorchLayerType::BatchNorm1d => {
734            // gamma, beta, running_mean, running_var
735            output_size * 4
736        }
737        PyTorchLayerType::Embedding => input_size * output_size,
738        _ => output_size,
739    }
740}
741
742/// Infer tensor shape from parameter name
743fn 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], // [out_ch, in_ch, kH, kW]
748            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
765/// Create layer configuration based on type
766fn 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
789/// Map PyTorch tensor to ToRSh tensor (simplified)
790pub 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    // In real implementation, would deserialize PyTorch tensor format
797    // For now, create a random tensor with the correct shape
798
799    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
810/// Validate PyTorch to ToRSh conversion
811pub fn validate_conversion(
812    pytorch_info: &PyTorchModelInfo,
813    torsh_model: &TorshModel,
814) -> Result<()> {
815    info!("Validating PyTorch to ToRSh conversion");
816
817    // Check parameter count is reasonable
818    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    // Check all layers have valid shapes
830    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
840/// Export conversion report
841pub 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(&params);
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(&params, 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        // Random non-checkpoint bytes carry no version metadata: must be honest.
970        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        // Simulate a pickle fragment carrying `__version__` then a length-prefixed
977        // BINUNICODE value `2.1.0` (length 5), followed by a memo opcode.
978        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        // Delimited local-version suffix (terminated by a non-identifier byte).
988        assert_eq!(
989            parse_version_token(b"q\x002.0.1+cu118\x00"),
990            Some("2.0.1+cu118".to_string())
991        );
992        // Bare numeric core with no suffix.
993        assert_eq!(
994            parse_version_token(b"\x001.13.0\x00"),
995            Some("1.13.0".to_string())
996        );
997        // Not enough dots to be a version => None (no fabrication).
998        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        // 0x8c <len=6> "2.0.0+"  -> exact-length read, no trailing opcode bytes.
1005        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        // Embedded ASCII parameter names as they appear in a real pickle stream.
1014        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        // No parameter-name tokens present: must return empty, not a fixed list.
1025        let data = b"no parameter names here, just prose".to_vec();
1026        assert!(extract_state_dict_keys(&data).is_empty());
1027    }
1028}