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// ToRSh integration
15use torsh::core::device::DeviceType;
16
17use super::tensor_integration::ModelTensor;
18use super::types::{DType, Device, LayerInfo, ModelMetadata, TensorInfo, TorshModel};
19
20/// PyTorch model metadata extracted from .pth files
21#[derive(Debug, Clone)]
22pub struct PyTorchModelInfo {
23    /// PyTorch version that produced the checkpoint, if it could be determined
24    /// from the file. `None` when the version is not recoverable from metadata.
25    pub pytorch_version: Option<String>,
26    /// Model class name (if available)
27    pub model_class: Option<String>,
28    /// State dict keys
29    pub state_dict_keys: Vec<String>,
30    /// Total file size in bytes
31    pub file_size: u64,
32    /// Number of parameters
33    pub num_parameters: u64,
34    /// Whether this is a full model or just state_dict
35    pub is_full_model: bool,
36}
37
38impl PyTorchModelInfo {
39    /// Human-readable PyTorch version, or `"unknown"` when it could not be
40    /// determined from the checkpoint. This never fabricates a version number.
41    pub fn version_display(&self) -> &str {
42        self.pytorch_version.as_deref().unwrap_or("unknown")
43    }
44}
45
46/// PyTorch layer type mapping
47#[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    /// Convert PyTorch layer type to ToRSh layer type string
66    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    /// Infer layer type from parameter name
85    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            // Default conv layers to Conv2d (most common in vision models)
94            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
113/// Parse PyTorch model file and extract metadata
114pub async fn parse_pytorch_model(path: &Path) -> Result<PyTorchModelInfo> {
115    info!("Parsing PyTorch model from: {}", path.display());
116
117    // Read file metadata
118    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    // Read file header to detect format
125    let file_data = tokio::fs::read(path)
126        .await
127        .with_context(|| format!("Failed to read PyTorch file: {}", path.display()))?;
128
129    // Check if it's a ZIP file (PyTorch >= 1.6 uses ZIP format)
130    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    // Recover real state-dict parameter names and a real parameter count from
138    // the checkpoint bytes (see `parse_pytorch_structure`). For full tensor
139    // reconstruction (values, shapes, strides) use `pytorch_reader`.
140    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, // Would be extracted from full model files
146        state_dict_keys,
147        file_size,
148        num_parameters,
149        is_full_model,
150    })
151}
152
153/// Parse PyTorch file structure, returning the real state-dict parameter names,
154/// an estimated parameter count, and whether the file holds a full model.
155///
156/// PyTorch checkpoints embed each parameter name (e.g. `layer1.0.conv1.weight`)
157/// as an ASCII string inside the pickled payload. This scans the raw bytes for
158/// those tokens instead of fabricating a fixed list. When no parameter names can
159/// be recovered, it returns an empty key list rather than inventing layers.
160fn 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    // A checkpoint that pickles module objects (a "full model") references the
164    // class machinery; a bare state_dict does not. `torch.nn.Module` / `OrderedDict`
165    // markers reliably distinguish the two cases in the pickle stream.
166    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    // Estimate parameter count from the real tensor storage entries when this is
170    // a ZIP checkpoint (each tensor lives under `.../data/<n>`), otherwise fall
171    // back to a byte-size heuristic. The estimate is reported as such by callers.
172    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
181/// Detect the PyTorch version that produced a checkpoint file.
182///
183/// PyTorch (>= 1.6) saves models as a ZIP archive that contains a `version`
184/// entry holding the *serialization protocol* number and may embed the
185/// `torch.__version__` string inside the pickled payload. This function reads
186/// the real bytes:
187///
188/// 1. It scans for an embedded `torch.__version__`-style version string
189///    (e.g. `1.13.1`, `2.0.0+cu118`) and returns it verbatim if found.
190/// 2. Failing that, for ZIP checkpoints it extracts the serialization protocol
191///    number from the `version` archive entry and reports it as
192///    `"serialization protocol N"`.
193///
194/// When the version genuinely cannot be determined from the file, it returns
195/// `None` rather than fabricating a plausible-looking version number.
196fn 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
214/// Scan raw checkpoint bytes for an embedded `torch.__version__` string.
215///
216/// Newer checkpoints store the producing torch version as an ASCII string in the
217/// pickle stream. We locate the literal `__version__` (or a `torch ` qualifier)
218/// and parse the dotted version token that follows. Returns `None` if no
219/// plausible version token is present.
220fn 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            // Preferred path: the value is a length-prefixed pickle string right
229            // after the marker's memo opcode. Reading the exact length avoids
230            // swallowing trailing opcode bytes.
231            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            // Fallback: heuristically scan the bytes following the marker.
238            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
249/// Read the next length-prefixed pickle unicode string within `data`.
250///
251/// Recognizes the protocol-2+ opcodes `SHORT_BINUNICODE` (`0x8c`, 1-byte length)
252/// and `BINUNICODE` (`X`, 4-byte little-endian length). Scans a short distance
253/// for the opcode so an intervening memo opcode (`q<idx>`) is skipped. Returns
254/// `None` if no well-formed string is found.
255fn 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                // SHORT_BINUNICODE: 1-byte length follows.
262                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                // BINUNICODE: 4-byte little-endian length follows.
277                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
301/// Parse the first dotted semantic-version token (e.g. `2.0.1`, `1.13.0+cu117`)
302/// found at the start region of `window`, skipping non-version separator bytes.
303///
304/// The numeric `MAJOR.MINOR.PATCH` core is parsed first; a local-version suffix
305/// (e.g. `+cu118`, `.dev20240101`) is appended only when it is introduced by an
306/// explicit `+` or `-` separator. This prevents trailing pickle opcode bytes
307/// (such as a memo `q`) from being mistaken for part of the version string.
308fn parse_version_token(window: &[u8]) -> Option<String> {
309    let mut idx = 0;
310    // Skip leading non-digit bytes (quotes, length prefixes, separators).
311    while idx < window.len() && !window[idx].is_ascii_digit() {
312        idx += 1;
313        if idx > 8 {
314            // Version token should appear right after the marker; give up early.
315            return None;
316        }
317    }
318
319    // Parse the numeric core: digits and dots only.
320    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    // Optionally consume a local-version suffix, but only when it is explicitly
340    // introduced by `+` or `-` (PEP 440 / PyTorch convention).
341    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
364/// Extract the serialization protocol number from a PyTorch ZIP checkpoint.
365///
366/// PyTorch stores a stored (uncompressed) `version` entry whose body is the
367/// ASCII protocol number. We locate the entry by its ZIP local file header and
368/// read the immediately-following uncompressed body. Returns `None` if the
369/// entry is absent or compressed in a way we cannot read directly.
370fn 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        // Local file header layout (offsets from signature):
377        //  +8  compression method (u16, LE)
378        //  +18 compressed size (u32, LE)
379        //  +26 file name length (u16, LE)
380        //  +28 extra field length (u16, LE)
381        //  +30 file name bytes
382        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        // Match an entry whose path component is exactly `version` (the archive
403        // is rooted at the model name, e.g. `archive/version`).
404        let is_version_entry = name == b"version" || name.ends_with(b"/version");
405
406        // Only "stored" (compression method 0) bodies can be read as raw ASCII.
407        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
424/// Find the first occurrence of `needle` within `haystack`, returning its index.
425fn 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
434/// Extract real state-dict parameter names embedded in a PyTorch checkpoint.
435///
436/// Parameter keys appear in the pickle stream as ASCII tokens ending in a known
437/// PyTorch parameter suffix (`.weight`, `.bias`, `.running_mean`, etc.). This
438/// scans for those suffixes and walks backwards to recover the full dotted name.
439/// Results are de-duplicated while preserving first-seen order. Returns an empty
440/// vector when none are present — it never fabricates names.
441fn 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            // Walk backwards over the identifier characters preceding the suffix.
461            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                // Real parameter keys never start with a dot; strip any leading
474                // dots introduced by surrounding binary bytes.
475                let token = raw.trim_start_matches('.');
476                // Require a real prefix before the suffix that begins with an
477                // identifier character (not the bare suffix, not noise).
478                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
497/// Estimate the total parameter count of a ZIP checkpoint from its stored tensor
498/// data entries (`.../data/<n>`). Each such entry's uncompressed body is raw
499/// tensor bytes; summing them and dividing by the f32 element size yields a real
500/// element count. Returns `None` when no tensor data entries are found.
501fn 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        // PyTorch stores tensor payloads under a `data/` directory; the entries
528        // are numbered (e.g. `archive/data/0`). Count only those bodies.
529        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        // Tensor storages are serialized in their element dtype; f32 (4 bytes) is
542        // the dominant case for the models this tool targets.
543        Some(total_bytes / 4)
544    } else {
545        None
546    }
547}
548
549/// Convert PyTorch model to ToRSh model
550pub 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    // Prefer the real reader: reconstruct genuine tensor shapes/dtypes from the
559    // checkpoint. Fall back to name-based inference only when the file cannot be
560    // read (e.g. legacy pure-pickle format), and say so honestly.
561    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    // Add conversion metadata
590    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
609/// Build a ToRSh structure from **real** reconstructed tensors (genuine shapes
610/// and dtypes), grouping parameters into layers by their dotted name prefix.
611fn 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        // Use the largest-rank parameter (typically the weight) for I/O shapes.
660        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
686/// Build ToRSh model structure from PyTorch state dict
687fn 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    // Group parameters by layer
700    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        // Infer layer type from parameter names
710        let layer_type = PyTorchLayerType::from_param_name(&layer_name);
711
712        // Infer shapes from parameter names
713        let (input_shape, output_shape) = infer_layer_shapes(&param_names, layer_type);
714
715        // Count parameters
716        let param_count = estimate_layer_parameters(&param_names, layer_type);
717
718        // Create layer info
719        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        // Create weight tensors
732        for param_name in param_names {
733            let shape = infer_tensor_shape(&param_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"), // Running stats are non-trainable
740                device: Device::Cpu,
741            };
742
743            weights.insert(param_name, weight_info);
744        }
745    }
746
747    Ok((layers, weights))
748}
749
750/// Group parameters by layer name
751fn 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        // Extract layer name (everything before the last dot)
756        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
771/// Infer layer shapes from parameter names
772fn infer_layer_shapes(
773    param_names: &[String],
774    layer_type: PyTorchLayerType,
775) -> (Vec<usize>, Vec<usize>) {
776    // Find weight parameter to infer dimensions
777    let weight_param = param_names.iter().find(|name| name.ends_with(".weight"));
778
779    match layer_type {
780        PyTorchLayerType::Linear => {
781            // Linear layers: weight shape is [out_features, in_features]
782            if weight_param.is_some() {
783                // Realistic sizes for common architectures
784                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            // Conv2d: input [batch, in_channels, height, width]
793            (vec![3, 224, 224], vec![64, 112, 112])
794        }
795        PyTorchLayerType::BatchNorm2d | PyTorchLayerType::BatchNorm1d => {
796            // BatchNorm preserves shape
797            (vec![64, 56, 56], vec![64, 56, 56])
798        }
799        PyTorchLayerType::Embedding => {
800            // Embedding: [vocab_size, embedding_dim]
801            (vec![30000], vec![512])
802        }
803        PyTorchLayerType::LSTM | PyTorchLayerType::GRU => {
804            // RNN: [seq_len, batch, features]
805            (vec![128, 512], vec![128, 256])
806        }
807        _ => (vec![512], vec![512]),
808    }
809}
810
811/// Estimate layer parameter count
812fn 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            // weight: out * in, bias: out
821            input_size * output_size + output_size
822        }
823        PyTorchLayerType::Conv2d => {
824            // Rough estimate based on typical kernel sizes
825            let kernel_size = 9; // 3x3
826            output_size * kernel_size + output_size // weights + bias
827        }
828        PyTorchLayerType::BatchNorm2d | PyTorchLayerType::BatchNorm1d => {
829            // gamma, beta, running_mean, running_var
830            output_size * 4
831        }
832        PyTorchLayerType::Embedding => input_size * output_size,
833        _ => output_size,
834    }
835}
836
837/// Infer tensor shape from parameter name
838fn 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], // [out_ch, in_ch, kH, kW]
843            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
860/// Create layer configuration based on type
861fn 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
884/// Deserialize a real PyTorch tensor storage (little-endian `f32`) into a
885/// [`ModelTensor`] of the given shape.
886///
887/// This performs a genuine deserialization of the raw storage bytes — it never
888/// fabricates values. The buffer must contain exactly `shape.product()`
889/// little-endian `f32` elements; other dtypes are handled by the full
890/// [`super::pytorch_reader`] reader.
891pub 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
916/// Validate PyTorch to ToRSh conversion
917pub fn validate_conversion(
918    pytorch_info: &PyTorchModelInfo,
919    torsh_model: &TorshModel,
920) -> Result<()> {
921    info!("Validating PyTorch to ToRSh conversion");
922
923    // Check parameter count is reasonable
924    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    // Check all layers have valid shapes
936    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
946/// Export conversion report
947pub 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(&params);
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(&params, 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        // Random non-checkpoint bytes carry no version metadata: must be honest.
1076        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        // Simulate a pickle fragment carrying `__version__` then a length-prefixed
1083        // BINUNICODE value `2.1.0` (length 5), followed by a memo opcode.
1084        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        // Delimited local-version suffix (terminated by a non-identifier byte).
1094        assert_eq!(
1095            parse_version_token(b"q\x002.0.1+cu118\x00"),
1096            Some("2.0.1+cu118".to_string())
1097        );
1098        // Bare numeric core with no suffix.
1099        assert_eq!(
1100            parse_version_token(b"\x001.13.0\x00"),
1101            Some("1.13.0".to_string())
1102        );
1103        // Not enough dots to be a version => None (no fabrication).
1104        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        // 0x8c <len=6> "2.0.0+"  -> exact-length read, no trailing opcode bytes.
1111        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        // Embedded ASCII parameter names as they appear in a real pickle stream.
1120        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        // No parameter-name tokens present: must return empty, not a fixed list.
1131        let data = b"no parameter names here, just prose".to_vec();
1132        assert!(extract_state_dict_keys(&data).is_empty());
1133    }
1134}