Skip to main content

ai_model_vault/
conversion.rs

1//! Model format conversion pipeline
2//!
3//! Provides a trait-based conversion architecture with:
4//! - Pluggable converter implementations
5//! - Multi-step conversion via graph search
6//! - Progress reporting callbacks
7//! - Output validation (integrity, accuracy, metadata)
8
9use std::collections::{HashMap, HashSet, VecDeque};
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Result, VaultError};
15use crate::formats::ModelFormat;
16
17// ── Progress reporting ───────────────────────────────────────────────────────
18
19/// Progress update sent during a conversion operation.
20#[derive(Debug, Clone)]
21pub struct ConversionProgress {
22    /// Current step index (0-based).
23    pub step: usize,
24    /// Total number of steps.
25    pub total_steps: usize,
26    /// Bytes processed so far in the current step.
27    pub bytes_processed: u64,
28    /// Total bytes for the current step (0 = unknown).
29    pub bytes_total: u64,
30    /// Human-readable description of the current step.
31    pub message: String,
32}
33
34impl fmt::Display for ConversionProgress {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        if self.bytes_total > 0 {
37            let pct = (self.bytes_processed as f64 / self.bytes_total as f64) * 100.0;
38            write!(
39                f,
40                "[{}/{}] {:.1}% — {}",
41                self.step + 1,
42                self.total_steps,
43                pct,
44                self.message,
45            )
46        } else {
47            write!(
48                f,
49                "[{}/{}] {}",
50                self.step + 1,
51                self.total_steps,
52                self.message,
53            )
54        }
55    }
56}
57
58/// Callback type for progress updates.
59pub type ProgressCallback = Box<dyn Fn(&ConversionProgress) + Send + Sync>;
60
61// ── Conversion options ───────────────────────────────────────────────────────
62
63/// Options that influence a conversion operation.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct ConversionOptions {
66    /// Target quantization (e.g. "q4_0", "q8_0" for GGUF).
67    pub quantization: Option<String>,
68    /// Target opset version for ONNX export.
69    pub opset_version: Option<u32>,
70    /// Preserve training-related metadata.
71    pub preserve_metadata: bool,
72    /// Validate the output after conversion.
73    pub validate: bool,
74    /// Tolerance for numerical accuracy validation.
75    pub tolerance: f64,
76    /// Custom key-value options for format-specific converters.
77    pub extra: HashMap<String, String>,
78}
79
80impl ConversionOptions {
81    /// Create options with validation enabled and a default tolerance.
82    #[must_use]
83    pub fn with_validation() -> Self {
84        Self {
85            validate: true,
86            tolerance: 1e-5,
87            preserve_metadata: true,
88            ..Default::default()
89        }
90    }
91}
92
93// ── Conversion result ────────────────────────────────────────────────────────
94
95/// Metadata collected during a conversion.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ConversionResult {
98    /// The converted model data.
99    ///
100    /// Empty when [`Self::plan`] is set — a plan is not a model file.
101    #[serde(skip)]
102    pub data: Vec<u8>,
103    /// Set when the conversion could not be performed natively and instead
104    /// produced instructions for external tooling.
105    ///
106    /// When this is `Some`, no conversion happened: `data` is empty and the
107    /// caller must run the described steps to obtain the target format.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub plan: Option<serde_json::Value>,
110    /// Source format.
111    pub source_format: ModelFormat,
112    /// Target format.
113    pub target_format: ModelFormat,
114    /// Formats traversed (including source and target).
115    pub conversion_path: Vec<ModelFormat>,
116    /// Size of the input (bytes).
117    pub input_size: u64,
118    /// Size of the output (bytes).
119    pub output_size: u64,
120    /// Validation report (populated if validation was requested).
121    pub validation: Option<ValidationReport>,
122}
123
124impl ConversionResult {
125    /// True when no conversion was performed and [`Self::plan`] holds
126    /// instructions for external tooling instead.
127    ///
128    /// Callers that write `data` to a file must check this first — otherwise
129    /// they produce a file with the target extension and the wrong contents.
130    #[must_use]
131    pub fn is_plan(&self) -> bool {
132        self.plan.is_some()
133    }
134
135    /// Compression ratio (output / input).
136    #[must_use]
137    pub fn compression_ratio(&self) -> f64 {
138        if self.input_size == 0 {
139            return 0.0;
140        }
141        self.output_size as f64 / self.input_size as f64
142    }
143}
144
145// ── Validation ───────────────────────────────────────────────────────────────
146
147/// Result of validating a converted model.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ValidationReport {
150    /// Whether the output passes all checks.
151    pub passed: bool,
152    /// Individual check results.
153    pub checks: Vec<ValidationCheck>,
154}
155
156impl ValidationReport {
157    /// Create a passing report from a list of checks.
158    #[must_use]
159    pub fn from_checks(checks: Vec<ValidationCheck>) -> Self {
160        let passed = checks.iter().all(|c| c.passed);
161        Self { passed, checks }
162    }
163}
164
165/// A single validation check.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct ValidationCheck {
168    /// Name of the check.
169    pub name: String,
170    /// Whether the check passed.
171    pub passed: bool,
172    /// Human-readable detail.
173    pub message: String,
174}
175
176impl ValidationCheck {
177    /// Create a passing check.
178    #[must_use]
179    pub fn pass(name: impl Into<String>, message: impl Into<String>) -> Self {
180        Self {
181            name: name.into(),
182            passed: true,
183            message: message.into(),
184        }
185    }
186
187    /// Create a failing check.
188    #[must_use]
189    pub fn fail(name: impl Into<String>, message: impl Into<String>) -> Self {
190        Self {
191            name: name.into(),
192            passed: false,
193            message: message.into(),
194        }
195    }
196}
197
198// ── Converter trait ──────────────────────────────────────────────────────────
199
200/// A single-step format converter.
201///
202/// Implement this trait to add support for a new format pair.
203pub trait Converter: Send + Sync {
204    /// Human-readable name (e.g. "SafeTensors → PyTorch").
205    fn name(&self) -> &str;
206
207    /// Source format this converter reads.
208    fn source_format(&self) -> ModelFormat;
209
210    /// Target format this converter produces.
211    fn target_format(&self) -> ModelFormat;
212
213    /// Whether this converter emits a JSON *plan* describing how to perform the
214    /// conversion with external tooling, rather than target-format bytes.
215    ///
216    /// Converters that need a Python runtime (PyTorch → ONNX, ONNX → TensorRT,
217    /// ONNX → Core ML, SafeTensors → GGUF) return `true`. Callers must not treat
218    /// their output as a model file — [`ConversionResult::plan`] carries it
219    /// instead, and [`ConversionResult::data`] is left empty.
220    fn produces_plan(&self) -> bool {
221        false
222    }
223
224    /// Perform the conversion.
225    fn convert(
226        &self,
227        data: &[u8],
228        options: &ConversionOptions,
229        progress: Option<&ProgressCallback>,
230    ) -> Result<Vec<u8>>;
231
232    /// Validate the output. Default implementation checks non-empty + magic bytes.
233    fn validate(
234        &self,
235        input: &[u8],
236        output: &[u8],
237        options: &ConversionOptions,
238    ) -> ValidationReport {
239        let mut checks = vec![
240            // Non-empty output
241            if output.is_empty() {
242                ValidationCheck::fail("non_empty", "Output is empty")
243            } else {
244                ValidationCheck::pass("non_empty", format!("Output is {} bytes", output.len()))
245            },
246            // Size sanity (output shouldn't be >100× or <0.001× input unless quantizing)
247            {
248                let ratio = if input.is_empty() {
249                    1.0
250                } else {
251                    output.len() as f64 / input.len() as f64
252                };
253                if ratio > 100.0 && options.quantization.is_none() {
254                    ValidationCheck::fail(
255                        "size_ratio",
256                        format!("Suspicious size ratio: {ratio:.1}×"),
257                    )
258                } else {
259                    ValidationCheck::pass("size_ratio", format!("Size ratio: {ratio:.2}×"))
260                }
261            },
262        ];
263
264        // Format-specific magic bytes check
265        let magic_check = validate_magic_bytes(output, &self.target_format());
266        checks.push(magic_check);
267
268        ValidationReport::from_checks(checks)
269    }
270}
271
272// ── Conversion pipeline ──────────────────────────────────────────────────────
273
274/// Registry of converters with multi-step path finding.
275pub struct ConversionPipeline {
276    converters: Vec<Box<dyn Converter>>,
277    /// Adjacency: (source, target) → index into `converters`.
278    edges: HashMap<(ModelFormat, ModelFormat), usize>,
279}
280
281impl ConversionPipeline {
282    /// Create an empty pipeline.
283    #[must_use]
284    pub fn new() -> Self {
285        Self {
286            converters: Vec::new(),
287            edges: HashMap::new(),
288        }
289    }
290
291    /// Create a pipeline pre-loaded with all built-in converters.
292    #[must_use]
293    pub fn with_builtins() -> Self {
294        let mut p = Self::new();
295        p.register(Box::new(SafeTensorsToRawConverter));
296        p.register(Box::new(RawToSafeTensorsConverter));
297        p.register(Box::new(GgufHeaderParser));
298        p.register(Box::new(OnnxMetadataExtractor));
299        p.register(Box::new(SafeTensorsToPyTorchConverter));
300        p.register(Box::new(PyTorchToSafeTensorsConverter));
301        p.register(Box::new(PyTorchToOnnxConverter));
302        p.register(Box::new(OnnxToTensorRtConverter));
303        p.register(Box::new(OnnxToCoreMLConverter));
304        p.register(Box::new(SafeTensorsToGgufConverter));
305        p
306    }
307
308    /// Register a converter.
309    pub fn register(&mut self, converter: Box<dyn Converter>) {
310        let key = (converter.source_format(), converter.target_format());
311        let idx = self.converters.len();
312        self.converters.push(converter);
313        self.edges.insert(key, idx);
314    }
315
316    /// Check whether a direct conversion is available.
317    #[must_use]
318    pub fn can_convert_direct(&self, from: &ModelFormat, to: &ModelFormat) -> bool {
319        self.edges.contains_key(&(from.clone(), to.clone()))
320    }
321
322    /// Find the shortest conversion path (BFS over the format graph).
323    #[must_use]
324    pub fn find_path(&self, from: &ModelFormat, to: &ModelFormat) -> Option<Vec<ModelFormat>> {
325        if from == to {
326            return Some(vec![from.clone()]);
327        }
328
329        // BFS
330        let mut visited: HashSet<ModelFormat> = HashSet::new();
331        let mut queue: VecDeque<Vec<ModelFormat>> = VecDeque::new();
332        queue.push_back(vec![from.clone()]);
333        visited.insert(from.clone());
334
335        while let Some(path) = queue.pop_front() {
336            let current = path.last().unwrap();
337
338            for (src, dst) in self.edges.keys() {
339                if src == current && !visited.contains(dst) {
340                    let mut new_path = path.clone();
341                    new_path.push(dst.clone());
342                    if dst == to {
343                        return Some(new_path);
344                    }
345                    visited.insert(dst.clone());
346                    queue.push_back(new_path);
347                }
348            }
349        }
350
351        None
352    }
353
354    /// List all supported direct conversions.
355    #[must_use]
356    pub fn supported_conversions(&self) -> Vec<(ModelFormat, ModelFormat, &str)> {
357        self.converters
358            .iter()
359            .map(|c| (c.source_format(), c.target_format(), c.name()))
360            .collect()
361    }
362
363    /// Perform a (possibly multi-step) conversion.
364    pub fn convert(
365        &self,
366        data: &[u8],
367        from: &ModelFormat,
368        to: &ModelFormat,
369        options: &ConversionOptions,
370        progress: Option<&ProgressCallback>,
371    ) -> Result<ConversionResult> {
372        if from == to {
373            return Ok(ConversionResult {
374                data: data.to_vec(),
375                plan: None,
376                source_format: from.clone(),
377                target_format: to.clone(),
378                conversion_path: vec![from.clone()],
379                input_size: data.len() as u64,
380                output_size: data.len() as u64,
381                validation: None,
382            });
383        }
384
385        let path = self.find_path(from, to).ok_or_else(|| {
386            VaultError::ConversionError(format!(
387                "No conversion path from {} to {}",
388                from.name(),
389                to.name(),
390            ))
391        })?;
392
393        let total_steps = path.len() - 1;
394        let mut current_data = data.to_vec();
395        // Set as soon as any step emits a plan; the whole conversion is then a
396        // plan, because later steps have no real bytes to work from.
397        let mut plan: Option<serde_json::Value> = None;
398
399        for (i, window) in path.windows(2).enumerate() {
400            let (src, dst) = (&window[0], &window[1]);
401            let idx = self.edges.get(&(src.clone(), dst.clone())).ok_or_else(|| {
402                VaultError::ConversionError(format!(
403                    "Missing converter for {} -> {}",
404                    src.name(),
405                    dst.name(),
406                ))
407            })?;
408
409            let converter = &self.converters[*idx];
410
411            // Report progress
412            if let Some(cb) = progress {
413                cb(&ConversionProgress {
414                    step: i,
415                    total_steps,
416                    bytes_processed: 0,
417                    bytes_total: current_data.len() as u64,
418                    message: format!("{} → {}", src.name(), dst.name()),
419                });
420            }
421
422            current_data = converter.convert(&current_data, options, progress)?;
423
424            if converter.produces_plan() {
425                plan = Some(
426                    serde_json::from_slice(&current_data)
427                        .unwrap_or_else(|_| serde_json::json!({ "converter": converter.name() })),
428                );
429                // Stop here: downstream steps cannot operate on a plan, and the
430                // caller must run the external tooling before continuing.
431                break;
432            }
433
434            // Intermediate validation
435            if options.validate && i < total_steps - 1 {
436                let report = converter.validate(data, &current_data, options);
437                if !report.passed {
438                    return Err(VaultError::ConversionError(format!(
439                        "Intermediate validation failed at step {} ({}): {:?}",
440                        i + 1,
441                        converter.name(),
442                        report
443                            .checks
444                            .iter()
445                            .filter(|c| !c.passed)
446                            .map(|c| &c.message)
447                            .collect::<Vec<_>>(),
448                    )));
449                }
450            }
451        }
452
453        // A plan is not target-format bytes: report it as such and hand back no
454        // data, so no caller can write it out as a model file.
455        if plan.is_some() {
456            return Ok(ConversionResult {
457                input_size: data.len() as u64,
458                output_size: 0,
459                data: Vec::new(),
460                plan,
461                source_format: from.clone(),
462                target_format: to.clone(),
463                conversion_path: path,
464                validation: None,
465            });
466        }
467
468        // Final validation
469        let validation = if options.validate {
470            let final_idx = self
471                .edges
472                .get(&(path[path.len() - 2].clone(), to.clone()))
473                .unwrap();
474            let report = self.converters[*final_idx].validate(data, &current_data, options);
475            Some(report)
476        } else {
477            None
478        };
479
480        Ok(ConversionResult {
481            input_size: data.len() as u64,
482            output_size: current_data.len() as u64,
483            data: current_data,
484            plan: None,
485            source_format: from.clone(),
486            target_format: to.clone(),
487            conversion_path: path,
488            validation,
489        })
490    }
491}
492
493impl Default for ConversionPipeline {
494    fn default() -> Self {
495        Self::with_builtins()
496    }
497}
498
499// ── Magic byte validation ────────────────────────────────────────────────────
500
501/// Validate that `data` starts with the expected magic bytes for `format`.
502fn validate_magic_bytes(data: &[u8], format: &ModelFormat) -> ValidationCheck {
503    let (expected, label): (Option<&[u8]>, &str) = match format {
504        ModelFormat::GGUF => (Some(b"GGUF"), "GGUF magic"),
505        ModelFormat::ONNX => (Some(&[0x08]), "ONNX protobuf tag"), // field 1 varint
506        ModelFormat::Safetensors => {
507            // SafeTensors starts with a little-endian u64 header length
508            if data.len() >= 8 {
509                let header_len = u64::from_le_bytes(data[..8].try_into().unwrap());
510                if header_len > 0 && header_len < data.len() as u64 {
511                    return ValidationCheck::pass(
512                        "magic_bytes",
513                        format!("Valid SafeTensors header ({header_len} bytes)"),
514                    );
515                }
516                return ValidationCheck::fail("magic_bytes", "Invalid SafeTensors header length");
517            }
518            return ValidationCheck::fail("magic_bytes", "Too small for SafeTensors header");
519        }
520        ModelFormat::PyTorch => {
521            // PyTorch .pt files are ZIP archives (PK magic)
522            if data.len() >= 2 && data[0] == b'P' && data[1] == b'K' {
523                return ValidationCheck::pass("magic_bytes", "Valid PyTorch ZIP archive");
524            }
525            // Older pickle format
526            if data.len() >= 2 && data[0] == 0x80 {
527                return ValidationCheck::pass("magic_bytes", "Valid PyTorch pickle format");
528            }
529            return ValidationCheck::fail("magic_bytes", "Unrecognised PyTorch header");
530        }
531        ModelFormat::TFLite => (Some(b"\x20\x00\x00\x00"), "TFLite FlatBuffer"),
532        _ => (None, ""),
533    };
534
535    match expected {
536        Some(magic) => {
537            if data.len() >= magic.len() && &data[..magic.len()] == magic {
538                ValidationCheck::pass("magic_bytes", format!("{label} OK"))
539            } else {
540                ValidationCheck::fail("magic_bytes", format!("{label} mismatch"))
541            }
542        }
543        None => ValidationCheck::pass(
544            "magic_bytes",
545            format!("No magic-byte check for {}", format.name()),
546        ),
547    }
548}
549
550// ── Built-in converters ──────────────────────────────────────────────────────
551
552// ---------- SafeTensors ↔ raw tensor data ----------
553
554/// Parse SafeTensors format and extract raw tensor data concatenated.
555pub struct SafeTensorsToRawConverter;
556
557impl Converter for SafeTensorsToRawConverter {
558    fn name(&self) -> &str {
559        "SafeTensors → Raw"
560    }
561    fn source_format(&self) -> ModelFormat {
562        ModelFormat::Safetensors
563    }
564    fn target_format(&self) -> ModelFormat {
565        ModelFormat::Custom("raw".into())
566    }
567
568    fn convert(
569        &self,
570        data: &[u8],
571        _options: &ConversionOptions,
572        progress: Option<&ProgressCallback>,
573    ) -> Result<Vec<u8>> {
574        if data.len() < 8 {
575            return Err(VaultError::ConversionError(
576                "Data too small for SafeTensors format".into(),
577            ));
578        }
579        let header_len = u64::from_le_bytes(data[..8].try_into().unwrap()) as usize;
580        // Cap header size at 100 MB to prevent memory exhaustion from crafted inputs
581        const MAX_HEADER: usize = 100 * 1024 * 1024;
582        if header_len > MAX_HEADER {
583            return Err(VaultError::ConversionError(
584                "SafeTensors header too large".into(),
585            ));
586        }
587        if 8 + header_len > data.len() {
588            return Err(VaultError::ConversionError(
589                "SafeTensors header length exceeds data".into(),
590            ));
591        }
592        let raw = data[8 + header_len..].to_vec();
593
594        if let Some(cb) = progress {
595            cb(&ConversionProgress {
596                step: 0,
597                total_steps: 1,
598                bytes_processed: raw.len() as u64,
599                bytes_total: raw.len() as u64,
600                message: format!("Extracted {} bytes of raw tensor data", raw.len()),
601            });
602        }
603        Ok(raw)
604    }
605}
606
607/// Pack raw tensor data into SafeTensors format with a minimal header.
608pub struct RawToSafeTensorsConverter;
609
610impl Converter for RawToSafeTensorsConverter {
611    fn name(&self) -> &str {
612        "Raw → SafeTensors"
613    }
614    fn source_format(&self) -> ModelFormat {
615        ModelFormat::Custom("raw".into())
616    }
617    fn target_format(&self) -> ModelFormat {
618        ModelFormat::Safetensors
619    }
620
621    fn convert(
622        &self,
623        data: &[u8],
624        _options: &ConversionOptions,
625        progress: Option<&ProgressCallback>,
626    ) -> Result<Vec<u8>> {
627        // Minimal safetensors: header = JSON object with one tensor entry
628        let header = format!(
629            r#"{{"__metadata__":{{"format":"raw"}},"tensor_0":{{"dtype":"U8","shape":[{}],"data_offsets":[0,{}]}}}}"#,
630            data.len(),
631            data.len(),
632        );
633        let header_bytes = header.as_bytes();
634        let header_len = header_bytes.len() as u64;
635
636        let mut out = Vec::with_capacity(8 + header_bytes.len() + data.len());
637        out.extend_from_slice(&header_len.to_le_bytes());
638        out.extend_from_slice(header_bytes);
639        out.extend_from_slice(data);
640
641        if let Some(cb) = progress {
642            cb(&ConversionProgress {
643                step: 0,
644                total_steps: 1,
645                bytes_processed: out.len() as u64,
646                bytes_total: out.len() as u64,
647                message: format!(
648                    "Packed {} bytes into SafeTensors ({} byte header)",
649                    data.len(),
650                    header_bytes.len()
651                ),
652            });
653        }
654        Ok(out)
655    }
656}
657
658// ---------- GGUF header parser ----------
659
660/// Parse GGUF metadata. Returns JSON description of the model.
661pub struct GgufHeaderParser;
662
663impl Converter for GgufHeaderParser {
664    fn name(&self) -> &str {
665        "GGUF → Metadata (JSON)"
666    }
667    fn source_format(&self) -> ModelFormat {
668        ModelFormat::GGUF
669    }
670    fn target_format(&self) -> ModelFormat {
671        ModelFormat::Custom("gguf-meta".into())
672    }
673
674    fn convert(
675        &self,
676        data: &[u8],
677        _options: &ConversionOptions,
678        _progress: Option<&ProgressCallback>,
679    ) -> Result<Vec<u8>> {
680        if data.len() < 24 {
681            return Err(VaultError::ConversionError(
682                "Data too small for GGUF format".into(),
683            ));
684        }
685        if &data[..4] != b"GGUF" {
686            return Err(VaultError::ConversionError(
687                "Invalid GGUF magic bytes".into(),
688            ));
689        }
690        let version = u32::from_le_bytes(data[4..8].try_into().unwrap());
691        let tensor_count = u64::from_le_bytes(data[8..16].try_into().unwrap());
692        let kv_count = u64::from_le_bytes(data[16..24].try_into().unwrap());
693
694        let meta = serde_json::json!({
695            "format": "GGUF",
696            "version": version,
697            "tensor_count": tensor_count,
698            "kv_count": kv_count,
699            "file_size": data.len(),
700        });
701
702        serde_json::to_vec_pretty(&meta).map_err(|e| {
703            VaultError::ConversionError(format!("Failed to serialise GGUF metadata: {e}"))
704        })
705    }
706}
707
708// ---------- ONNX metadata extractor ----------
709
710/// Extract basic ONNX model metadata (protobuf top-level fields).
711pub struct OnnxMetadataExtractor;
712
713impl Converter for OnnxMetadataExtractor {
714    fn name(&self) -> &str {
715        "ONNX → Metadata (JSON)"
716    }
717    fn source_format(&self) -> ModelFormat {
718        ModelFormat::ONNX
719    }
720    fn target_format(&self) -> ModelFormat {
721        ModelFormat::Custom("onnx-meta".into())
722    }
723
724    fn convert(
725        &self,
726        data: &[u8],
727        _options: &ConversionOptions,
728        _progress: Option<&ProgressCallback>,
729    ) -> Result<Vec<u8>> {
730        // ONNX files are protobuf ModelProto. Extract top-level string fields
731        // using a minimal protobuf wire-format parser (no .proto compilation).
732        let mut ir_version: u64 = 0;
733        let mut producer: String = String::new();
734        let mut model_version: u64 = 0;
735        let mut doc_string: String = String::new();
736
737        let mut pos = 0;
738        while pos < data.len() {
739            let (field_num, wire_type, new_pos) = match parse_protobuf_tag(data, pos) {
740                Some(v) => v,
741                None => break,
742            };
743            pos = new_pos;
744
745            match (field_num, wire_type) {
746                // ir_version = field 1, varint
747                (1, 0) => {
748                    let (val, np) = parse_varint(data, pos);
749                    ir_version = val;
750                    pos = np;
751                }
752                // producer_name = field 2, length-delimited
753                (2, 2) => {
754                    let (bytes, np) = parse_length_delimited(data, pos)?;
755                    producer = String::from_utf8_lossy(bytes).into_owned();
756                    pos = np;
757                }
758                // model_version = field 5, varint
759                (5, 0) => {
760                    let (val, np) = parse_varint(data, pos);
761                    model_version = val;
762                    pos = np;
763                }
764                // doc_string = field 6, length-delimited
765                (6, 2) => {
766                    let (bytes, np) = parse_length_delimited(data, pos)?;
767                    doc_string = String::from_utf8_lossy(bytes).into_owned();
768                    pos = np;
769                }
770                // Skip other fields
771                (_, 0) => {
772                    let (_, np) = parse_varint(data, pos);
773                    pos = np;
774                }
775                (_, 2) => {
776                    let (_, np) = parse_length_delimited(data, pos)?;
777                    pos = np;
778                }
779                (_, 5) => pos += 4, // 32-bit
780                (_, 1) => pos += 8, // 64-bit
781                _ => break,
782            }
783        }
784
785        let meta = serde_json::json!({
786            "format": "ONNX",
787            "ir_version": ir_version,
788            "producer": producer,
789            "model_version": model_version,
790            "doc_string": doc_string,
791            "file_size": data.len(),
792        });
793
794        serde_json::to_vec_pretty(&meta).map_err(|e| {
795            VaultError::ConversionError(format!("Failed to serialise ONNX metadata: {e}"))
796        })
797    }
798}
799
800// ── Shim converters (require external Python runtime) ────────────────────────
801//
802// ── Real converters (pure Rust, no Python required) ──────────────────────────
803//
804// SafeTensors ↔ PyTorch converters that produce valid binary output.
805// PyTorch .pt files are ZIP archives containing:
806//   - archive/data.pkl  — pickle bytecode describing the state_dict structure
807//   - archive/data/N    — raw tensor storage files (one per tensor)
808//
809// We generate minimal pickle v2 bytecode to reconstruct an OrderedDict of
810// tensors, and write each tensor's raw data into a numbered storage file.
811
812/// Dtype string mapping between SafeTensors names and PyTorch storage types.
813fn safetensors_dtype_to_pytorch(dtype: &str) -> Option<(&'static str, usize)> {
814    // Returns (pytorch_storage_type, element_size_bytes)
815    match dtype {
816        "F64" => Some(("DoubleStorage", 8)),
817        "F32" => Some(("FloatStorage", 4)),
818        "F16" => Some(("HalfStorage", 2)),
819        "BF16" => Some(("BFloat16Storage", 2)),
820        "I64" => Some(("LongStorage", 8)),
821        "I32" => Some(("IntStorage", 4)),
822        "I16" => Some(("ShortStorage", 2)),
823        "I8" => Some(("CharStorage", 1)),
824        "U8" => Some(("ByteStorage", 1)),
825        "BOOL" => Some(("BoolStorage", 1)),
826        _ => None,
827    }
828}
829
830/// Dtype string mapping from PyTorch storage type to SafeTensors dtype.
831fn pytorch_storage_to_safetensors_dtype(storage_type: &str) -> Option<(&'static str, usize)> {
832    // Returns (safetensors_dtype, element_size_bytes)
833    match storage_type {
834        "DoubleStorage" => Some(("F64", 8)),
835        "FloatStorage" => Some(("F32", 4)),
836        "HalfStorage" => Some(("F16", 2)),
837        "BFloat16Storage" => Some(("BF16", 2)),
838        "LongStorage" => Some(("I64", 8)),
839        "IntStorage" => Some(("I32", 4)),
840        "ShortStorage" => Some(("I16", 2)),
841        "CharStorage" => Some(("I8", 1)),
842        "ByteStorage" | "UntypedStorage" => Some(("U8", 1)),
843        "BoolStorage" => Some(("BOOL", 1)),
844        _ => None,
845    }
846}
847
848/// Real: SafeTensors → PyTorch (.pt ZIP archive with pickle bytecode).
849///
850/// Parses the SafeTensors header and tensor data, then produces a valid .pt file
851/// that can be loaded by `torch.load()` without any Python dependencies at
852/// conversion time.
853pub struct SafeTensorsToPyTorchConverter;
854
855impl Converter for SafeTensorsToPyTorchConverter {
856    fn name(&self) -> &str {
857        "SafeTensors → PyTorch"
858    }
859    fn source_format(&self) -> ModelFormat {
860        ModelFormat::Safetensors
861    }
862    fn target_format(&self) -> ModelFormat {
863        ModelFormat::PyTorch
864    }
865
866    fn convert(
867        &self,
868        data: &[u8],
869        _options: &ConversionOptions,
870        progress: Option<&ProgressCallback>,
871    ) -> Result<Vec<u8>> {
872        if data.len() < 8 {
873            return Err(VaultError::ConversionError(
874                "Data too small for SafeTensors format".into(),
875            ));
876        }
877        let header_len = u64::from_le_bytes(data[..8].try_into().unwrap()) as usize;
878        // Cap header size at 100 MB to prevent memory exhaustion from crafted inputs
879        const MAX_HEADER: usize = 100 * 1024 * 1024;
880        if header_len > MAX_HEADER {
881            return Err(VaultError::ConversionError(
882                "SafeTensors header too large".into(),
883            ));
884        }
885        if 8 + header_len > data.len() {
886            return Err(VaultError::ConversionError(
887                "SafeTensors header length exceeds data".into(),
888            ));
889        }
890
891        let header: serde_json::Map<String, serde_json::Value> =
892            serde_json::from_slice(&data[8..8 + header_len]).map_err(|e| {
893                VaultError::ConversionError(format!("Invalid SafeTensors header JSON: {e}"))
894            })?;
895        let tensor_data = &data[8 + header_len..];
896
897        // Collect tensor entries (skip __metadata__)
898        let mut tensors: Vec<ConvTensorEntry> = Vec::new();
899        for (name, info) in &header {
900            if name == "__metadata__" {
901                continue;
902            }
903            let obj = info.as_object().ok_or_else(|| {
904                VaultError::ConversionError(format!("Tensor '{name}' is not an object"))
905            })?;
906            let dtype = obj
907                .get("dtype")
908                .and_then(|v| v.as_str())
909                .ok_or_else(|| {
910                    VaultError::ConversionError(format!("Missing dtype for tensor '{name}'"))
911                })?
912                .to_string();
913            let shape: Vec<i64> = obj
914                .get("shape")
915                .and_then(|v| v.as_array())
916                .ok_or_else(|| {
917                    VaultError::ConversionError(format!("Missing shape for tensor '{name}'"))
918                })?
919                .iter()
920                .filter_map(|v| v.as_i64())
921                .collect();
922            let offsets = obj
923                .get("data_offsets")
924                .and_then(|v| v.as_array())
925                .ok_or_else(|| {
926                    VaultError::ConversionError(format!("Missing data_offsets for tensor '{name}'"))
927                })?;
928            let start = offsets[0].as_u64().unwrap_or(0) as usize;
929            let end = offsets[1].as_u64().unwrap_or(0) as usize;
930
931            tensors.push(ConvTensorEntry {
932                name: name.clone(),
933                dtype,
934                shape,
935                data_start: start,
936                data_end: end,
937            });
938        }
939
940        // Sort by data offset for deterministic output
941        tensors.sort_by_key(|t| t.data_start);
942
943        if let Some(cb) = progress {
944            cb(&ConversionProgress {
945                step: 0,
946                total_steps: 1,
947                bytes_processed: 0,
948                bytes_total: data.len() as u64,
949                message: format!(
950                    "Converting {} tensors from SafeTensors to PyTorch",
951                    tensors.len()
952                ),
953            });
954        }
955
956        // Build a ZIP archive with pickle + tensor data
957        let mut zip_buf = std::io::Cursor::new(Vec::new());
958        {
959            let mut zip = zip::ZipWriter::new(&mut zip_buf);
960            let stored = zip::write::SimpleFileOptions::default()
961                .compression_method(zip::CompressionMethod::Stored);
962
963            // Write each tensor's data as archive/data/N
964            for (i, tensor) in tensors.iter().enumerate() {
965                let entry_name = format!("archive/data/{i}");
966                let slice = tensor_data
967                    .get(tensor.data_start..tensor.data_end)
968                    .ok_or_else(|| {
969                        VaultError::ConversionError(format!(
970                            "Tensor '{}' data offsets [{},{}] out of bounds (data len {})",
971                            tensor.name,
972                            tensor.data_start,
973                            tensor.data_end,
974                            tensor_data.len(),
975                        ))
976                    })?;
977                zip.start_file(&entry_name, stored)
978                    .map_err(|e| VaultError::ConversionError(format!("ZIP write error: {e}")))?;
979                std::io::Write::write_all(&mut zip, slice)
980                    .map_err(|e| VaultError::ConversionError(format!("ZIP write error: {e}")))?;
981            }
982
983            // Build minimal pickle v2 bytecode that constructs an OrderedDict
984            let pickle = build_pytorch_pickle(&tensors)?;
985            zip.start_file("archive/data.pkl", stored)
986                .map_err(|e| VaultError::ConversionError(format!("ZIP write error: {e}")))?;
987            std::io::Write::write_all(&mut zip, &pickle)
988                .map_err(|e| VaultError::ConversionError(format!("ZIP write error: {e}")))?;
989
990            zip.finish()
991                .map_err(|e| VaultError::ConversionError(format!("ZIP finalize error: {e}")))?;
992        }
993
994        let output = zip_buf.into_inner();
995
996        if let Some(cb) = progress {
997            cb(&ConversionProgress {
998                step: 0,
999                total_steps: 1,
1000                bytes_processed: output.len() as u64,
1001                bytes_total: output.len() as u64,
1002                message: format!(
1003                    "Created PyTorch archive: {} bytes ({} tensors)",
1004                    output.len(),
1005                    tensors.len(),
1006                ),
1007            });
1008        }
1009
1010        Ok(output)
1011    }
1012}
1013
1014/// Internal tensor entry used during SafeTensors ↔ PyTorch conversion.
1015struct ConvTensorEntry {
1016    name: String,
1017    dtype: String,
1018    shape: Vec<i64>,
1019    data_start: usize,
1020    data_end: usize,
1021}
1022
1023/// Build minimal pickle v2 bytecode for a PyTorch state_dict.
1024///
1025/// Produces bytecode that `torch.load()` interprets as:
1026/// ```ignore
1027/// OrderedDict([(name, torch._utils._rebuild_tensor_v2(
1028///     PersistentLoad((storage_type, key, device, numel)),
1029///     0, shape, stride
1030/// )) for each tensor])
1031/// ```
1032fn build_pytorch_pickle(tensors: &[ConvTensorEntry]) -> Result<Vec<u8>> {
1033    let mut pkl = Vec::with_capacity(4096);
1034
1035    // Protocol 2
1036    pkl.push(0x80); // PROTO
1037    pkl.push(2);
1038
1039    // Push OrderedDict constructor: collections.OrderedDict
1040    pkl.extend_from_slice(b"c");
1041    pkl.extend_from_slice(b"collections\nOrderedDict\n");
1042    pkl.push(b')'); // EMPTY_TUPLE
1043    pkl.push(b'R'); // REDUCE
1044    pkl.push(b'q'); // BINPUT
1045    pkl.push(0);
1046
1047    // MARK for SETITEMS
1048    pkl.push(b'('); // MARK
1049
1050    for (idx, tensor) in tensors.iter().enumerate() {
1051        let (storage_type, elem_size) =
1052            safetensors_dtype_to_pytorch(&tensor.dtype).ok_or_else(|| {
1053                VaultError::ConversionError(format!(
1054                    "Unsupported dtype '{}' for tensor '{}'",
1055                    tensor.dtype, tensor.name
1056                ))
1057            })?;
1058
1059        let data_len = tensor.data_end - tensor.data_start;
1060        let numel = data_len.checked_div(elem_size).unwrap_or(data_len);
1061
1062        // Key: tensor name
1063        write_short_binunicode(&mut pkl, &tensor.name);
1064
1065        // Value: rebuild_tensor_v2(storage, offset, shape, stride)
1066        pkl.extend_from_slice(b"c");
1067        pkl.extend_from_slice(b"torch._utils\n_rebuild_tensor_v2\n");
1068
1069        // Arguments tuple: (storage, offset, shape, stride, requires_grad)
1070        pkl.push(b'('); // MARK
1071
1072        // storage: PersistentLoad((storage_type_str, key, device, numel))
1073        pkl.push(b'('); // MARK
1074        write_short_binunicode(&mut pkl, "storage");
1075        pkl.extend_from_slice(b"c");
1076        pkl.extend_from_slice(format!("torch\n{storage_type}\n").as_bytes());
1077        write_short_binunicode(&mut pkl, &idx.to_string());
1078        write_short_binunicode(&mut pkl, "cpu");
1079        write_pickle_int(&mut pkl, numel as i64);
1080        pkl.push(b't'); // TUPLE
1081        pkl.push(b'Q'); // BINPERSID
1082
1083        // storage offset: 0
1084        pkl.push(0x4b); // BININT1
1085        pkl.push(0);
1086
1087        // shape tuple
1088        pkl.push(b'('); // MARK
1089        for &dim in &tensor.shape {
1090            write_pickle_int(&mut pkl, dim);
1091        }
1092        pkl.push(b't'); // TUPLE
1093
1094        // stride tuple (row-major)
1095        pkl.push(b'('); // MARK
1096        let mut stride = vec![1i64; tensor.shape.len()];
1097        for i in (0..tensor.shape.len().saturating_sub(1)).rev() {
1098            stride[i] = stride[i + 1] * tensor.shape[i + 1];
1099        }
1100        for s in &stride {
1101            write_pickle_int(&mut pkl, *s);
1102        }
1103        pkl.push(b't'); // TUPLE
1104
1105        // requires_grad = False
1106        pkl.push(0x89); // NEWFALSE
1107
1108        // Close the rebuild args tuple + REDUCE
1109        pkl.push(b't'); // TUPLE
1110        pkl.push(b'R'); // REDUCE
1111
1112        // Memo
1113        let memo_idx = (idx + 1) as u8;
1114        if memo_idx < 255 {
1115            pkl.push(b'q'); // BINPUT
1116            pkl.push(memo_idx);
1117        }
1118    }
1119
1120    pkl.push(b'u'); // SETITEMS
1121    pkl.push(b'.'); // STOP
1122
1123    Ok(pkl)
1124}
1125
1126fn write_short_binunicode(pkl: &mut Vec<u8>, s: &str) {
1127    let bytes = s.as_bytes();
1128    if bytes.len() <= 255 {
1129        pkl.push(0x8c); // SHORT_BINUNICODE
1130        pkl.push(bytes.len() as u8);
1131        pkl.extend_from_slice(bytes);
1132    } else {
1133        pkl.push(0x8d); // BINUNICODE
1134        pkl.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
1135        pkl.extend_from_slice(bytes);
1136    }
1137}
1138
1139fn write_pickle_int(pkl: &mut Vec<u8>, val: i64) {
1140    if (0..=255).contains(&val) {
1141        pkl.push(0x4b); // BININT1
1142        pkl.push(val as u8);
1143    } else if (0..=65535).contains(&val) {
1144        pkl.push(0x4d); // BININT2
1145        pkl.extend_from_slice(&(val as u16).to_le_bytes());
1146    } else if val >= i32::MIN as i64 && val <= i32::MAX as i64 {
1147        pkl.push(0x4a); // BININT
1148        pkl.extend_from_slice(&(val as i32).to_le_bytes());
1149    } else {
1150        // LONG1 for 64-bit values
1151        pkl.push(0x8a); // LONG1
1152        let bytes = val.to_le_bytes();
1153        // Find significant length (strip trailing 0x00 for positive, 0xff for negative)
1154        let mut len = 8;
1155        if val >= 0 {
1156            while len > 1 && bytes[len - 1] == 0 {
1157                len -= 1;
1158            }
1159            // Need extra byte if high bit set (would be interpreted as negative)
1160            if bytes[len - 1] & 0x80 != 0 {
1161                pkl.push((len + 1) as u8);
1162                pkl.extend_from_slice(&bytes[..len]);
1163                pkl.push(0);
1164                return;
1165            }
1166        } else {
1167            while len > 1 && bytes[len - 1] == 0xff {
1168                len -= 1;
1169            }
1170            if bytes[len - 1] & 0x80 == 0 {
1171                pkl.push((len + 1) as u8);
1172                pkl.extend_from_slice(&bytes[..len]);
1173                pkl.push(0xff);
1174                return;
1175            }
1176        }
1177        pkl.push(len as u8);
1178        pkl.extend_from_slice(&bytes[..len]);
1179    }
1180}
1181
1182/// Real: PyTorch → SafeTensors.
1183///
1184/// Reads a PyTorch .pt ZIP archive, extracts tensor storage files and
1185/// reconstructs a valid SafeTensors file. Falls back to a conversion plan
1186/// if the archive structure is unrecognised.
1187///
1188/// # Security — Pickle Deserialization (I-01)
1189///
1190/// PyTorch `.pt` files contain pickled Python objects (`data.pkl`). This
1191/// converter **does not** unpickle or execute pickle bytecodes — it only
1192/// reads the raw binary tensor storage blobs from the ZIP archive.
1193/// Arbitrary code execution through malicious pickle payloads is therefore
1194/// **not possible** via this conversion path. If full pickle support is
1195/// ever added, use a sandboxed deserializer (e.g., `fickling` or
1196/// `safepickle`) and reject `__reduce__` / `__reduce_ex__` opcodes.
1197pub struct PyTorchToSafeTensorsConverter;
1198
1199impl Converter for PyTorchToSafeTensorsConverter {
1200    fn name(&self) -> &str {
1201        "PyTorch → SafeTensors"
1202    }
1203    fn source_format(&self) -> ModelFormat {
1204        ModelFormat::PyTorch
1205    }
1206    fn target_format(&self) -> ModelFormat {
1207        ModelFormat::Safetensors
1208    }
1209
1210    fn convert(
1211        &self,
1212        data: &[u8],
1213        _options: &ConversionOptions,
1214        progress: Option<&ProgressCallback>,
1215    ) -> Result<Vec<u8>> {
1216        // Try to read as ZIP archive
1217        let cursor = std::io::Cursor::new(data);
1218        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| {
1219            VaultError::ConversionError(format!("Not a valid PyTorch ZIP archive: {e}"))
1220        })?;
1221
1222        if let Some(cb) = progress {
1223            cb(&ConversionProgress {
1224                step: 0,
1225                total_steps: 1,
1226                bytes_processed: 0,
1227                bytes_total: data.len() as u64,
1228                message: format!("Parsing PyTorch archive ({} entries)", archive.len()),
1229            });
1230        }
1231
1232        // Scan for data files (archive/data/0, archive/data/1, ...)
1233        // and the pickle file (archive/data.pkl)
1234        let mut storage_files: Vec<(String, Vec<u8>)> = Vec::new();
1235        let mut pkl_data: Option<Vec<u8>> = None;
1236
1237        for i in 0..archive.len() {
1238            let mut file = archive
1239                .by_index(i)
1240                .map_err(|e| VaultError::ConversionError(format!("ZIP read error: {e}")))?;
1241            let name = file.name().to_string();
1242            let mut contents = Vec::new();
1243            std::io::Read::read_to_end(&mut file, &mut contents)
1244                .map_err(|e| VaultError::ConversionError(format!("ZIP read error: {e}")))?;
1245
1246            if name.ends_with(".pkl") || name.ends_with("/data.pkl") {
1247                pkl_data = Some(contents);
1248            } else if name.contains("/data/") {
1249                // Extract the storage index from the path
1250                storage_files.push((name, contents));
1251            }
1252        }
1253
1254        // Sort storage files by their numeric index
1255        storage_files.sort_by(|a, b| {
1256            let idx_a =
1257                a.0.rsplit('/')
1258                    .next()
1259                    .and_then(|s| s.parse::<usize>().ok())
1260                    .unwrap_or(usize::MAX);
1261            let idx_b =
1262                b.0.rsplit('/')
1263                    .next()
1264                    .and_then(|s| s.parse::<usize>().ok())
1265                    .unwrap_or(usize::MAX);
1266            idx_a.cmp(&idx_b)
1267        });
1268
1269        // Try to extract tensor metadata from pickle bytecode
1270        let tensor_infos = if let Some(ref pkl) = pkl_data {
1271            extract_tensor_info_from_pickle(pkl)
1272        } else {
1273            Vec::new()
1274        };
1275
1276        // Build SafeTensors output
1277        let mut header_entries = serde_json::Map::new();
1278        let mut all_tensor_data = Vec::new();
1279
1280        if !tensor_infos.is_empty() && tensor_infos.len() == storage_files.len() {
1281            // We successfully parsed pickle — use tensor names, dtypes, shapes
1282            for (info, (_path, storage_data)) in tensor_infos.iter().zip(storage_files.iter()) {
1283                let offset_start = all_tensor_data.len();
1284                all_tensor_data.extend_from_slice(storage_data);
1285                let offset_end = all_tensor_data.len();
1286
1287                let entry = serde_json::json!({
1288                    "dtype": info.dtype,
1289                    "shape": info.shape,
1290                    "data_offsets": [offset_start, offset_end],
1291                });
1292                header_entries.insert(info.name.clone(), entry);
1293            }
1294        } else {
1295            // Fallback: treat each storage file as a raw U8 tensor
1296            for (path, storage_data) in &storage_files {
1297                let tensor_name = path.rsplit('/').next().unwrap_or("tensor");
1298                let offset_start = all_tensor_data.len();
1299                all_tensor_data.extend_from_slice(storage_data);
1300                let offset_end = all_tensor_data.len();
1301
1302                let entry = serde_json::json!({
1303                    "dtype": "U8",
1304                    "shape": [storage_data.len()],
1305                    "data_offsets": [offset_start, offset_end],
1306                });
1307                header_entries.insert(format!("storage_{tensor_name}"), entry);
1308            }
1309        }
1310
1311        // Serialize SafeTensors
1312        let header_json = serde_json::to_string(&header_entries).map_err(|e| {
1313            VaultError::ConversionError(format!("Failed to serialize SafeTensors header: {e}"))
1314        })?;
1315        let header_bytes = header_json.as_bytes();
1316        let header_len = header_bytes.len() as u64;
1317
1318        let mut output = Vec::with_capacity(8 + header_bytes.len() + all_tensor_data.len());
1319        output.extend_from_slice(&header_len.to_le_bytes());
1320        output.extend_from_slice(header_bytes);
1321        output.extend_from_slice(&all_tensor_data);
1322
1323        if let Some(cb) = progress {
1324            cb(&ConversionProgress {
1325                step: 0,
1326                total_steps: 1,
1327                bytes_processed: output.len() as u64,
1328                bytes_total: output.len() as u64,
1329                message: format!(
1330                    "Created SafeTensors: {} bytes ({} tensors)",
1331                    output.len(),
1332                    header_entries.len(),
1333                ),
1334            });
1335        }
1336
1337        Ok(output)
1338    }
1339}
1340
1341/// Extracted tensor info from pickle bytecode.
1342struct PickleTensorInfo {
1343    name: String,
1344    dtype: String,
1345    shape: Vec<i64>,
1346}
1347
1348/// Minimal pickle bytecode parser to extract tensor metadata from PyTorch files.
1349///
1350/// Looks for patterns matching `_rebuild_tensor_v2` calls and extracts the
1351/// tensor name, storage type, and shape. This doesn't need to fully interpret
1352/// pickle — it scans for the specific opcode sequences PyTorch uses.
1353fn extract_tensor_info_from_pickle(pkl: &[u8]) -> Vec<PickleTensorInfo> {
1354    let mut tensors = Vec::new();
1355    let mut strings: Vec<String> = Vec::new();
1356    let mut pos = 0;
1357
1358    // First pass: extract all string literals (these include tensor names and storage types)
1359    while pos < pkl.len() {
1360        match pkl[pos] {
1361            0x80 => pos += 2, // PROTO
1362            b'c' => {
1363                // GLOBAL opcode: c<module>\n<name>\n
1364                pos += 1;
1365                // Read module name (until \n)
1366                while pos < pkl.len() && pkl[pos] != b'\n' {
1367                    pos += 1;
1368                }
1369                pos += 1; // skip \n
1370                          // Read class name (until \n) — this contains e.g. "FloatStorage"
1371                let start = pos;
1372                while pos < pkl.len() && pkl[pos] != b'\n' {
1373                    pos += 1;
1374                }
1375                if let Ok(s) = std::str::from_utf8(&pkl[start..pos]) {
1376                    if !s.is_empty() {
1377                        strings.push(s.to_string());
1378                    }
1379                }
1380                pos += 1; // skip \n
1381            }
1382            0x8c => {
1383                // SHORT_BINUNICODE
1384                pos += 1;
1385                if pos >= pkl.len() {
1386                    break;
1387                }
1388                let len = pkl[pos] as usize;
1389                pos += 1;
1390                if pos + len <= pkl.len() {
1391                    if let Ok(s) = std::str::from_utf8(&pkl[pos..pos + len]) {
1392                        strings.push(s.to_string());
1393                    }
1394                    pos += len;
1395                } else {
1396                    break;
1397                }
1398            }
1399            0x8d => {
1400                // BINUNICODE
1401                pos += 1;
1402                if pos + 4 > pkl.len() {
1403                    break;
1404                }
1405                let len = u32::from_le_bytes(pkl[pos..pos + 4].try_into().unwrap()) as usize;
1406                pos += 4;
1407                if pos + len <= pkl.len() {
1408                    if let Ok(s) = std::str::from_utf8(&pkl[pos..pos + len]) {
1409                        strings.push(s.to_string());
1410                    }
1411                    pos += len;
1412                } else {
1413                    break;
1414                }
1415            }
1416            _ => pos += 1,
1417        }
1418    }
1419
1420    // Look for patterns: in PyTorch pickle, tensor entries follow the pattern:
1421    // <tensor_name_string> ... "storage" ... <StorageType string> ... <key> ... "cpu" ...
1422    // followed by shape tuple integers
1423    //
1424    // We scan the string list for storage type names and pair them with
1425    // preceding tensor names and following shape data.
1426    let mut i = 0;
1427    while i < strings.len() {
1428        // Look for a string that matches a known storage type
1429        if let Some((dtype, _elem_size)) = pytorch_storage_to_safetensors_dtype(&strings[i]) {
1430            // The tensor name is typically a few positions back
1431            // Search backward for a name that looks like a tensor name (contains . or _ and isn't a keyword)
1432            let mut tensor_name = None;
1433            for j in (0..i).rev() {
1434                let candidate = &strings[j];
1435                if candidate == "storage"
1436                    || candidate == "cpu"
1437                    || candidate == "cuda"
1438                    || candidate.contains("torch")
1439                    || candidate.contains("collections")
1440                    || candidate.contains("OrderedDict")
1441                    || candidate.starts_with("_rebuild")
1442                    || pytorch_storage_to_safetensors_dtype(candidate).is_some()
1443                    || candidate.parse::<usize>().is_ok()
1444                {
1445                    continue;
1446                }
1447                // Looks like a tensor name
1448                tensor_name = Some(candidate.clone());
1449                break;
1450            }
1451
1452            if let Some(name) = tensor_name {
1453                tensors.push(PickleTensorInfo {
1454                    name,
1455                    dtype: dtype.to_string(),
1456                    shape: Vec::new(), // Shape extracted from ints is complex; we set reasonable defaults
1457                });
1458            }
1459        }
1460        i += 1;
1461    }
1462
1463    // Shape extraction from pickle ints is complex (requires full stack emulation).
1464    // For the shapes, we rely on the data sizes and dtype element sizes to infer
1465    // a flat [N] shape. The caller can post-process if needed.
1466    tensors
1467}
1468
1469/// Shim: PyTorch → ONNX (needs `torch` Python package).
1470pub struct PyTorchToOnnxConverter;
1471
1472impl Converter for PyTorchToOnnxConverter {
1473    fn name(&self) -> &str {
1474        "PyTorch → ONNX (shim)"
1475    }
1476
1477    fn produces_plan(&self) -> bool {
1478        true
1479    }
1480    fn source_format(&self) -> ModelFormat {
1481        ModelFormat::PyTorch
1482    }
1483    fn target_format(&self) -> ModelFormat {
1484        ModelFormat::ONNX
1485    }
1486
1487    fn convert(
1488        &self,
1489        _data: &[u8],
1490        options: &ConversionOptions,
1491        _progress: Option<&ProgressCallback>,
1492    ) -> Result<Vec<u8>> {
1493        let opset = options.opset_version.unwrap_or(17);
1494        let plan = serde_json::json!({
1495            "converter": "pytorch_to_onnx",
1496            "requires": ["torch", "onnx"],
1497            "opset_version": opset,
1498            "python": format!(
1499                concat!(
1500                    "import torch, onnx\n",
1501                    "model = torch.load(input_path, map_location='cpu', weights_only=False)\n",
1502                    "model.eval()\n",
1503                    "dummy = torch.randn(1, 3, 224, 224)  # adjust shape as needed\n",
1504                    "torch.onnx.export(model, dummy, output_path, opset_version={})\n",
1505                ),
1506                opset,
1507            ),
1508        });
1509
1510        serde_json::to_vec_pretty(&plan).map_err(|e| {
1511            VaultError::ConversionError(format!("Failed to create conversion plan: {e}"))
1512        })
1513    }
1514}
1515
1516/// Shim: ONNX → TensorRT (needs `tensorrt` Python package or `trtexec`).
1517pub struct OnnxToTensorRtConverter;
1518
1519impl Converter for OnnxToTensorRtConverter {
1520    fn name(&self) -> &str {
1521        "ONNX → TensorRT (shim)"
1522    }
1523
1524    fn produces_plan(&self) -> bool {
1525        true
1526    }
1527    fn source_format(&self) -> ModelFormat {
1528        ModelFormat::ONNX
1529    }
1530    fn target_format(&self) -> ModelFormat {
1531        ModelFormat::TensorRT
1532    }
1533
1534    fn convert(
1535        &self,
1536        _data: &[u8],
1537        _options: &ConversionOptions,
1538        _progress: Option<&ProgressCallback>,
1539    ) -> Result<Vec<u8>> {
1540        let plan = serde_json::json!({
1541            "converter": "onnx_to_tensorrt",
1542            "requires": ["tensorrt"],
1543            "shell": "trtexec --onnx=input_path --saveEngine=output_path",
1544            "python": concat!(
1545                "import tensorrt as trt\n",
1546                "logger = trt.Logger(trt.Logger.WARNING)\n",
1547                "builder = trt.Builder(logger)\n",
1548                "network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))\n",
1549                "parser = trt.OnnxParser(network, logger)\n",
1550                "with open(input_path, 'rb') as f:\n",
1551                "    parser.parse(f.read())\n",
1552                "config = builder.create_builder_config()\n",
1553                "engine = builder.build_serialized_network(network, config)\n",
1554                "with open(output_path, 'wb') as f:\n",
1555                "    f.write(engine)\n",
1556            ),
1557        });
1558
1559        serde_json::to_vec_pretty(&plan).map_err(|e| {
1560            VaultError::ConversionError(format!("Failed to create conversion plan: {e}"))
1561        })
1562    }
1563}
1564
1565/// Shim: ONNX → CoreML (needs `coremltools` Python package).
1566pub struct OnnxToCoreMLConverter;
1567
1568impl Converter for OnnxToCoreMLConverter {
1569    fn name(&self) -> &str {
1570        "ONNX → Core ML (shim)"
1571    }
1572
1573    fn produces_plan(&self) -> bool {
1574        true
1575    }
1576    fn source_format(&self) -> ModelFormat {
1577        ModelFormat::ONNX
1578    }
1579    fn target_format(&self) -> ModelFormat {
1580        ModelFormat::CoreML
1581    }
1582
1583    fn convert(
1584        &self,
1585        _data: &[u8],
1586        _options: &ConversionOptions,
1587        _progress: Option<&ProgressCallback>,
1588    ) -> Result<Vec<u8>> {
1589        let plan = serde_json::json!({
1590            "converter": "onnx_to_coreml",
1591            "requires": ["coremltools", "onnx"],
1592            "python": concat!(
1593                "import coremltools as ct\n",
1594                "import onnx\n",
1595                "model = onnx.load(input_path)\n",
1596                "ml_model = ct.convert(model)\n",
1597                "ml_model.save(output_path)\n",
1598            ),
1599        });
1600
1601        serde_json::to_vec_pretty(&plan).map_err(|e| {
1602            VaultError::ConversionError(format!("Failed to create conversion plan: {e}"))
1603        })
1604    }
1605}
1606
1607/// Shim: SafeTensors → GGUF (needs `llama-cpp-python` or `gguf` Python package).
1608pub struct SafeTensorsToGgufConverter;
1609
1610impl Converter for SafeTensorsToGgufConverter {
1611    fn name(&self) -> &str {
1612        "SafeTensors → GGUF (shim)"
1613    }
1614
1615    fn produces_plan(&self) -> bool {
1616        true
1617    }
1618    fn source_format(&self) -> ModelFormat {
1619        ModelFormat::Safetensors
1620    }
1621    fn target_format(&self) -> ModelFormat {
1622        ModelFormat::GGUF
1623    }
1624
1625    fn convert(
1626        &self,
1627        _data: &[u8],
1628        options: &ConversionOptions,
1629        _progress: Option<&ProgressCallback>,
1630    ) -> Result<Vec<u8>> {
1631        let quant = options.quantization.as_deref().unwrap_or("f16");
1632
1633        let plan = serde_json::json!({
1634            "converter": "safetensors_to_gguf",
1635            "requires": ["gguf", "numpy", "safetensors"],
1636            "quantization": quant,
1637            "shell": format!(
1638                "python -m gguf.convert --src input_path --dst output_path --type {quant}"
1639            ),
1640            "python": format!(
1641                concat!(
1642                    "# Use llama.cpp convert scripts\n",
1643                    "# python convert_hf_to_gguf.py --outtype {} model_dir\n",
1644                ),
1645                quant,
1646            ),
1647        });
1648
1649        serde_json::to_vec_pretty(&plan).map_err(|e| {
1650            VaultError::ConversionError(format!("Failed to create conversion plan: {e}"))
1651        })
1652    }
1653}
1654
1655// ── Protobuf helpers (minimal wire-format parser) ────────────────────────────
1656
1657fn parse_varint(data: &[u8], start: usize) -> (u64, usize) {
1658    let mut result: u64 = 0;
1659    let mut shift = 0u32;
1660    let mut pos = start;
1661    while pos < data.len() {
1662        let byte = data[pos];
1663        result |= ((byte & 0x7F) as u64) << shift;
1664        pos += 1;
1665        if byte & 0x80 == 0 {
1666            break;
1667        }
1668        shift += 7;
1669        if shift >= 64 {
1670            break;
1671        }
1672    }
1673    (result, pos)
1674}
1675
1676fn parse_protobuf_tag(data: &[u8], pos: usize) -> Option<(u64, u8, usize)> {
1677    if pos >= data.len() {
1678        return None;
1679    }
1680    let (tag, new_pos) = parse_varint(data, pos);
1681    let field_num = tag >> 3;
1682    let wire_type = (tag & 0x07) as u8;
1683    Some((field_num, wire_type, new_pos))
1684}
1685
1686fn parse_length_delimited(data: &[u8], pos: usize) -> Result<(&[u8], usize)> {
1687    let (len, new_pos) = parse_varint(data, pos);
1688    let len = len as usize;
1689    if new_pos + len > data.len() {
1690        return Err(VaultError::ConversionError(
1691            "Protobuf length-delimited field exceeds data".into(),
1692        ));
1693    }
1694    Ok((&data[new_pos..new_pos + len], new_pos + len))
1695}
1696
1697// ── Tests ────────────────────────────────────────────────────────────────────
1698
1699#[cfg(test)]
1700mod tests {
1701    use super::*;
1702
1703    #[test]
1704    fn test_pipeline_same_format_passthrough() {
1705        let pipeline = ConversionPipeline::with_builtins();
1706        let data = b"hello world";
1707        let result = pipeline
1708            .convert(
1709                data,
1710                &ModelFormat::PyTorch,
1711                &ModelFormat::PyTorch,
1712                &ConversionOptions::default(),
1713                None,
1714            )
1715            .unwrap();
1716        assert_eq!(result.data, data);
1717        assert_eq!(result.conversion_path, vec![ModelFormat::PyTorch]);
1718    }
1719
1720    #[test]
1721    fn test_pipeline_no_path_error() {
1722        let pipeline = ConversionPipeline::new(); // empty
1723        let err = pipeline
1724            .convert(
1725                b"data",
1726                &ModelFormat::PyTorch,
1727                &ModelFormat::ONNX,
1728                &ConversionOptions::default(),
1729                None,
1730            )
1731            .unwrap_err();
1732        let msg = format!("{err}");
1733        assert!(msg.contains("No conversion path"));
1734    }
1735
1736    #[test]
1737    fn test_safetensors_roundtrip() {
1738        let original = b"test tensor data 1234567890";
1739        let pipeline = ConversionPipeline::with_builtins();
1740
1741        // raw → safetensors
1742        let st = RawToSafeTensorsConverter
1743            .convert(original, &ConversionOptions::default(), None)
1744            .unwrap();
1745        assert!(st.len() > original.len());
1746
1747        // safetensors → raw
1748        let raw = SafeTensorsToRawConverter
1749            .convert(&st, &ConversionOptions::default(), None)
1750            .unwrap();
1751        assert_eq!(raw, original);
1752
1753        // Also test via pipeline path finding
1754        assert!(pipeline.can_convert_direct(
1755            &ModelFormat::Custom("raw".into()),
1756            &ModelFormat::Safetensors,
1757        ));
1758    }
1759
1760    #[test]
1761    fn test_safetensors_to_raw_too_small() {
1762        let err = SafeTensorsToRawConverter
1763            .convert(b"tiny", &ConversionOptions::default(), None)
1764            .unwrap_err();
1765        assert!(format!("{err}").contains("too small"));
1766    }
1767
1768    #[test]
1769    fn test_gguf_header_parser_valid() {
1770        // Construct a minimal valid GGUF header
1771        let mut data = Vec::new();
1772        data.extend_from_slice(b"GGUF"); // magic
1773        data.extend_from_slice(&3u32.to_le_bytes()); // version
1774        data.extend_from_slice(&42u64.to_le_bytes()); // tensor count
1775        data.extend_from_slice(&7u64.to_le_bytes()); // kv count
1776                                                     // Pad to simulate the rest of the file
1777        data.extend_from_slice(&[0u8; 100]);
1778
1779        let result = GgufHeaderParser
1780            .convert(&data, &ConversionOptions::default(), None)
1781            .unwrap();
1782        let meta: serde_json::Value = serde_json::from_slice(&result).unwrap();
1783        assert_eq!(meta["version"], 3);
1784        assert_eq!(meta["tensor_count"], 42);
1785        assert_eq!(meta["kv_count"], 7);
1786    }
1787
1788    #[test]
1789    fn test_gguf_header_parser_invalid_magic() {
1790        let err = GgufHeaderParser
1791            .convert(
1792                b"NOT_GGUF_DATA_HERE__________",
1793                &ConversionOptions::default(),
1794                None,
1795            )
1796            .unwrap_err();
1797        assert!(format!("{err}").contains("Invalid GGUF magic"));
1798    }
1799
1800    #[test]
1801    fn test_validation_report() {
1802        let checks = vec![
1803            ValidationCheck::pass("a", "ok"),
1804            ValidationCheck::fail("b", "bad"),
1805        ];
1806        let report = ValidationReport::from_checks(checks);
1807        assert!(!report.passed);
1808        assert_eq!(report.checks.len(), 2);
1809    }
1810
1811    #[test]
1812    fn test_conversion_options_with_validation() {
1813        let opts = ConversionOptions::with_validation();
1814        assert!(opts.validate);
1815        assert!(opts.preserve_metadata);
1816        assert!((opts.tolerance - 1e-5).abs() < f64::EPSILON);
1817    }
1818
1819    #[test]
1820    fn test_conversion_result_compression_ratio() {
1821        let result = ConversionResult {
1822            data: vec![],
1823            plan: None,
1824            source_format: ModelFormat::PyTorch,
1825            target_format: ModelFormat::Safetensors,
1826            conversion_path: vec![],
1827            input_size: 1000,
1828            output_size: 500,
1829            validation: None,
1830        };
1831        assert!((result.compression_ratio() - 0.5).abs() < f64::EPSILON);
1832    }
1833
1834    #[test]
1835    fn test_find_path_direct() {
1836        let pipeline = ConversionPipeline::with_builtins();
1837        let path = pipeline
1838            .find_path(&ModelFormat::Safetensors, &ModelFormat::PyTorch)
1839            .unwrap();
1840        assert_eq!(path, vec![ModelFormat::Safetensors, ModelFormat::PyTorch]);
1841    }
1842
1843    #[test]
1844    fn test_find_path_multi_step() {
1845        let pipeline = ConversionPipeline::with_builtins();
1846        // PyTorch → ONNX → TensorRT (two steps)
1847        let path = pipeline
1848            .find_path(&ModelFormat::PyTorch, &ModelFormat::TensorRT)
1849            .unwrap();
1850        assert_eq!(
1851            path,
1852            vec![
1853                ModelFormat::PyTorch,
1854                ModelFormat::ONNX,
1855                ModelFormat::TensorRT
1856            ]
1857        );
1858    }
1859
1860    #[test]
1861    fn test_find_path_none() {
1862        let pipeline = ConversionPipeline::with_builtins();
1863        // No path to, say, MXNet
1864        let path = pipeline.find_path(&ModelFormat::PyTorch, &ModelFormat::MXNet);
1865        assert!(path.is_none());
1866    }
1867
1868    #[test]
1869    fn test_supported_conversions_list() {
1870        let pipeline = ConversionPipeline::with_builtins();
1871        let conversions = pipeline.supported_conversions();
1872        assert!(conversions.len() >= 10);
1873    }
1874
1875    #[test]
1876    fn test_progress_display() {
1877        let p = ConversionProgress {
1878            step: 0,
1879            total_steps: 3,
1880            bytes_processed: 500,
1881            bytes_total: 1000,
1882            message: "Converting".into(),
1883        };
1884        let s = format!("{p}");
1885        assert!(s.contains("50.0%"));
1886        assert!(s.contains("[1/3]"));
1887    }
1888
1889    #[test]
1890    fn test_progress_display_unknown_total() {
1891        let p = ConversionProgress {
1892            step: 1,
1893            total_steps: 2,
1894            bytes_processed: 100,
1895            bytes_total: 0,
1896            message: "Working".into(),
1897        };
1898        let s = format!("{p}");
1899        assert!(s.contains("[2/2]"));
1900        assert!(s.contains("Working"));
1901        assert!(!s.contains('%'));
1902    }
1903
1904    #[test]
1905    fn test_shim_converter_produces_plan() {
1906        let converter = PyTorchToOnnxConverter;
1907        let plan_bytes = converter
1908            .convert(b"", &ConversionOptions::default(), None)
1909            .unwrap();
1910        let plan: serde_json::Value = serde_json::from_slice(&plan_bytes).unwrap();
1911        assert_eq!(plan["converter"], "pytorch_to_onnx");
1912        assert!(plan["requires"]
1913            .as_array()
1914            .unwrap()
1915            .contains(&"torch".into()));
1916        assert_eq!(plan["opset_version"], 17);
1917    }
1918
1919    #[test]
1920    fn test_shim_converter_custom_opset() {
1921        let opts = ConversionOptions {
1922            opset_version: Some(13),
1923            ..ConversionOptions::default()
1924        };
1925        let plan_bytes = PyTorchToOnnxConverter.convert(b"", &opts, None).unwrap();
1926        let plan: serde_json::Value = serde_json::from_slice(&plan_bytes).unwrap();
1927        assert_eq!(plan["opset_version"], 13);
1928    }
1929
1930    #[test]
1931    fn test_safetensors_to_gguf_quantization() {
1932        let opts = ConversionOptions {
1933            quantization: Some("q4_k_m".into()),
1934            ..ConversionOptions::default()
1935        };
1936        let plan_bytes = SafeTensorsToGgufConverter
1937            .convert(b"", &opts, None)
1938            .unwrap();
1939        let plan: serde_json::Value = serde_json::from_slice(&plan_bytes).unwrap();
1940        assert_eq!(plan["quantization"], "q4_k_m");
1941    }
1942
1943    #[test]
1944    fn test_validate_magic_bytes_safetensors() {
1945        // Valid safetensors (8-byte header length + JSON)
1946        let header = b"{}";
1947        let mut data = Vec::new();
1948        data.extend_from_slice(&(header.len() as u64).to_le_bytes());
1949        data.extend_from_slice(header);
1950        let check = validate_magic_bytes(&data, &ModelFormat::Safetensors);
1951        assert!(check.passed);
1952    }
1953
1954    #[test]
1955    fn test_validate_magic_bytes_gguf() {
1956        let mut data = b"GGUF".to_vec();
1957        data.extend_from_slice(&[0u8; 20]);
1958        let check = validate_magic_bytes(&data, &ModelFormat::GGUF);
1959        assert!(check.passed);
1960    }
1961
1962    #[test]
1963    fn test_validate_magic_bytes_pytorch_zip() {
1964        let data = b"PK\x03\x04...";
1965        let check = validate_magic_bytes(data, &ModelFormat::PyTorch);
1966        assert!(check.passed);
1967    }
1968
1969    #[test]
1970    fn test_validate_magic_bytes_unknown_format() {
1971        let check = validate_magic_bytes(b"anything", &ModelFormat::Keras);
1972        assert!(check.passed); // no check for Keras → pass
1973    }
1974
1975    #[test]
1976    fn test_pipeline_with_progress_callback() {
1977        let pipeline = ConversionPipeline::with_builtins();
1978        let progress_log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1979        let log_clone = progress_log.clone();
1980        let cb: ProgressCallback = Box::new(move |p| {
1981            log_clone.lock().unwrap().push(p.message.clone());
1982        });
1983
1984        let _ = pipeline.convert(
1985            b"",
1986            &ModelFormat::PyTorch,
1987            &ModelFormat::ONNX,
1988            &ConversionOptions::default(),
1989            Some(&cb),
1990        );
1991
1992        let log = progress_log.lock().unwrap();
1993        assert!(!log.is_empty());
1994    }
1995
1996    // ── Default validate() coverage ──
1997
1998    #[test]
1999    fn test_validate_empty_output() {
2000        // Line 162: output.is_empty() → fail
2001        let converter = PyTorchToOnnxConverter;
2002        let report = converter.validate(b"input", b"", &ConversionOptions::default());
2003        assert!(!report.passed);
2004        assert!(report
2005            .checks
2006            .iter()
2007            .any(|c| c.name == "non_empty" && !c.passed));
2008    }
2009
2010    #[test]
2011    fn test_validate_suspicious_ratio() {
2012        // Lines 170-174: ratio > 100.0 without quantization → fail
2013        let converter = PyTorchToOnnxConverter;
2014        let input = b"x";
2015        let output = vec![0u8; 200];
2016        let report = converter.validate(input, &output, &ConversionOptions::default());
2017        assert!(!report.passed);
2018        assert!(report
2019            .checks
2020            .iter()
2021            .any(|c| c.name == "size_ratio" && !c.passed));
2022    }
2023
2024    #[test]
2025    fn test_validate_ratio_with_quantization_ok() {
2026        let converter = PyTorchToOnnxConverter;
2027        let input = b"x";
2028        let output = vec![0u8; 200];
2029        let opts = ConversionOptions {
2030            quantization: Some("q4_k_m".into()),
2031            ..ConversionOptions::default()
2032        };
2033        let report = converter.validate(input, &output, &opts);
2034        // Should pass because quantization is set
2035        assert!(report
2036            .checks
2037            .iter()
2038            .any(|c| c.name == "size_ratio" && c.passed));
2039    }
2040
2041    #[test]
2042    fn test_validate_empty_input_ratio() {
2043        // Line 164: input.is_empty() → ratio = 1.0
2044        let converter = PyTorchToOnnxConverter;
2045        let output = vec![0u8; 10];
2046        let report = converter.validate(b"", &output, &ConversionOptions::default());
2047        assert!(report
2048            .checks
2049            .iter()
2050            .any(|c| c.name == "size_ratio" && c.passed));
2051    }
2052
2053    // ── Real converter tests ──
2054
2055    #[test]
2056    fn test_safetensors_pytorch_roundtrip() {
2057        // Build a valid SafeTensors buffer with one F32 tensor [2,2]
2058        let header = serde_json::json!({
2059            "weight": { "dtype": "F32", "shape": [2, 2], "data_offsets": [0, 16] }
2060        });
2061        let header_bytes = serde_json::to_vec(&header).unwrap();
2062        let mut st_data = Vec::new();
2063        st_data.extend_from_slice(&(header_bytes.len() as u64).to_le_bytes());
2064        st_data.extend_from_slice(&header_bytes);
2065        // 4 floats × 4 bytes = 16 bytes of tensor data
2066        let floats: [f32; 4] = [1.0, 2.0, 3.0, 4.0];
2067        for f in &floats {
2068            st_data.extend_from_slice(&f.to_le_bytes());
2069        }
2070
2071        // SafeTensors → PyTorch
2072        let pt_bytes = SafeTensorsToPyTorchConverter
2073            .convert(&st_data, &ConversionOptions::default(), None)
2074            .unwrap();
2075        // Output should be a valid ZIP archive
2076        assert!(pt_bytes.len() > 4);
2077        assert_eq!(&pt_bytes[0..2], b"PK"); // ZIP magic
2078
2079        // PyTorch → SafeTensors (roundtrip)
2080        let st2_bytes = PyTorchToSafeTensorsConverter
2081            .convert(&pt_bytes, &ConversionOptions::default(), None)
2082            .unwrap();
2083        // Should start with 8-byte LE header length
2084        assert!(st2_bytes.len() > 8);
2085        let hdr_len = u64::from_le_bytes(st2_bytes[0..8].try_into().unwrap()) as usize;
2086        assert!(hdr_len > 0 && hdr_len < st2_bytes.len());
2087        let hdr: serde_json::Value = serde_json::from_slice(&st2_bytes[8..8 + hdr_len]).unwrap();
2088        // Should contain the "weight" tensor
2089        assert!(
2090            hdr.get("weight").is_some(),
2091            "Header missing 'weight': {hdr}"
2092        );
2093    }
2094
2095    #[test]
2096    fn test_pytorch_to_safetensors_requires_valid_zip() {
2097        let conv = PyTorchToSafeTensorsConverter;
2098        let err = conv
2099            .convert(b"model data", &ConversionOptions::default(), None)
2100            .unwrap_err();
2101        assert!(format!("{err}").contains("ZIP archive"));
2102    }
2103
2104    #[test]
2105    fn test_onnx_to_tensorrt_plan() {
2106        let conv = OnnxToTensorRtConverter;
2107        let plan_bytes = conv
2108            .convert(b"onnx data", &ConversionOptions::default(), None)
2109            .unwrap();
2110        let plan: serde_json::Value = serde_json::from_slice(&plan_bytes).unwrap();
2111        assert_eq!(plan["converter"], "onnx_to_tensorrt");
2112    }
2113
2114    #[test]
2115    fn test_onnx_to_coreml_plan() {
2116        let conv = OnnxToCoreMLConverter;
2117        let plan_bytes = conv
2118            .convert(b"onnx data", &ConversionOptions::default(), None)
2119            .unwrap();
2120        let plan: serde_json::Value = serde_json::from_slice(&plan_bytes).unwrap();
2121        assert_eq!(plan["converter"], "onnx_to_coreml");
2122    }
2123
2124    #[test]
2125    fn test_safetensors_to_gguf_plan_default() {
2126        let conv = SafeTensorsToGgufConverter;
2127        let plan_bytes = conv
2128            .convert(b"", &ConversionOptions::default(), None)
2129            .unwrap();
2130        let plan: serde_json::Value = serde_json::from_slice(&plan_bytes).unwrap();
2131        assert_eq!(plan["converter"], "safetensors_to_gguf");
2132        assert_eq!(plan["quantization"], "f16"); // default when no quantization specified
2133    }
2134
2135    #[test]
2136    fn test_safetensors_to_pytorch_valid_header() {
2137        // Build valid safetensors data
2138        let header = serde_json::json!({
2139            "weight": { "dtype": "F32", "shape": [2, 2], "data_offsets": [0, 16] }
2140        });
2141        let header_bytes = serde_json::to_vec(&header).unwrap();
2142        let mut data = Vec::new();
2143        data.extend_from_slice(&(header_bytes.len() as u64).to_le_bytes());
2144        data.extend_from_slice(&header_bytes);
2145        data.extend_from_slice(&[0u8; 16]); // tensor data
2146
2147        let conv = SafeTensorsToPyTorchConverter;
2148        let pt_bytes = conv
2149            .convert(&data, &ConversionOptions::default(), None)
2150            .unwrap();
2151        // Should produce a valid ZIP archive
2152        assert!(pt_bytes.len() > 4);
2153        assert_eq!(&pt_bytes[0..2], b"PK"); // ZIP magic
2154    }
2155
2156    #[test]
2157    fn test_safetensors_to_pytorch_too_small() {
2158        let conv = SafeTensorsToPyTorchConverter;
2159        let err = conv
2160            .convert(b"tiny", &ConversionOptions::default(), None)
2161            .unwrap_err();
2162        assert!(format!("{err}").contains("too small"));
2163    }
2164
2165    #[test]
2166    fn test_safetensors_to_pytorch_header_exceeds() {
2167        let mut data = Vec::new();
2168        data.extend_from_slice(&(1000u64).to_le_bytes()); // header_len = 1000
2169        data.extend_from_slice(b"short"); // but data is only 5 bytes
2170        let conv = SafeTensorsToPyTorchConverter;
2171        let err = conv
2172            .convert(&data, &ConversionOptions::default(), None)
2173            .unwrap_err();
2174        assert!(format!("{err}").contains("exceeds"));
2175    }
2176
2177    // ── ONNX metadata extractor ──
2178
2179    #[test]
2180    fn test_onnx_metadata_extractor_minimal() {
2181        // Create minimal protobuf-like ONNX data
2182        // Field 1 (ir_version) = varint, field 2 (producer) = length-delimited string
2183        let data = vec![
2184            0x08, 0x07, // Field 1 (tag = 1<<3 | 0 = 0x08), value = 7
2185            0x12, 0x04, b't', b'e', b's',
2186            b't', // Field 2 (tag = 2<<3 | 2 = 0x12), length = 4, "test"
2187        ];
2188
2189        let conv = OnnxMetadataExtractor;
2190        let result = conv
2191            .convert(&data, &ConversionOptions::default(), None)
2192            .unwrap();
2193        let meta: serde_json::Value = serde_json::from_slice(&result).unwrap();
2194        assert_eq!(meta["ir_version"], 7);
2195        assert_eq!(meta["producer"], "test");
2196    }
2197
2198    // ── Supported conversions ──
2199
2200    #[test]
2201    fn test_supported_conversions_non_empty() {
2202        let pipeline = ConversionPipeline::with_builtins();
2203        let list = pipeline.supported_conversions();
2204        assert!(!list.is_empty());
2205        // Each entry should be (name, source, target)
2206        for (source, target, name) in &list {
2207            assert!(!name.is_empty());
2208            assert_ne!(source, target); // no self-loops
2209        }
2210    }
2211
2212    // ── Multi-step conversion ──
2213
2214    #[test]
2215    fn test_multi_step_conversion_executes() {
2216        let pipeline = ConversionPipeline::with_builtins();
2217        // PyTorch → ONNX → TensorRT (2 steps)
2218        let result = pipeline.convert(
2219            b"model data",
2220            &ModelFormat::PyTorch,
2221            &ModelFormat::TensorRT,
2222            &ConversionOptions::default(),
2223            None,
2224        );
2225        assert!(result.is_ok());
2226        let conv = result.unwrap();
2227        assert_eq!(conv.conversion_path.len(), 3);
2228    }
2229
2230    #[test]
2231    fn test_validate_empty_output_fails() {
2232        let converter = RawToSafeTensorsConverter;
2233        let report = converter.validate(b"input data", b"", &ConversionOptions::default());
2234        assert!(!report.passed, "Empty output should fail validation");
2235        assert!(report
2236            .checks
2237            .iter()
2238            .any(|c| !c.passed && c.name == "non_empty"));
2239    }
2240
2241    #[test]
2242    fn test_validate_huge_ratio_without_quantization_fails() {
2243        let converter = RawToSafeTensorsConverter;
2244        let input = b"x";
2245        // output >100x input without quantization should fail size_ratio
2246        let output = vec![0u8; 200];
2247        let report = converter.validate(input, &output, &ConversionOptions::default());
2248        assert!(report
2249            .checks
2250            .iter()
2251            .any(|c| !c.passed && c.name == "size_ratio"));
2252    }
2253
2254    #[test]
2255    fn test_validation_check_pass_and_fail() {
2256        let p = ValidationCheck::pass("ok", "good");
2257        assert!(p.passed);
2258        assert_eq!(p.name, "ok");
2259
2260        let f = ValidationCheck::fail("bad", "nope");
2261        assert!(!f.passed);
2262        assert_eq!(f.name, "bad");
2263    }
2264
2265    #[test]
2266    fn test_compression_ratio_zero_input() {
2267        // Covers L120 — input_size == 0 returns 0.0
2268        let result = ConversionResult {
2269            data: vec![],
2270            plan: None,
2271            source_format: ModelFormat::PyTorch,
2272            target_format: ModelFormat::Safetensors,
2273            conversion_path: vec![],
2274            input_size: 0,
2275            output_size: 100,
2276            validation: None,
2277        };
2278        assert!((result.compression_ratio() - 0.0).abs() < f64::EPSILON);
2279    }
2280
2281    #[test]
2282    fn test_safetensors_to_raw_with_progress() {
2283        // Covers L521-533 — SafeTensorsToRawConverter progress callback
2284        let raw_data = b"tensor bytes for progress test";
2285        // First create valid safetensors data
2286        let st = RawToSafeTensorsConverter
2287            .convert(raw_data, &ConversionOptions::default(), None)
2288            .unwrap();
2289
2290        let progress_called = std::sync::Arc::new(std::sync::Mutex::new(false));
2291        let pc = progress_called.clone();
2292        let cb: ProgressCallback = Box::new(move |_p| {
2293            *pc.lock().unwrap() = true;
2294        });
2295
2296        let result = SafeTensorsToRawConverter
2297            .convert(&st, &ConversionOptions::default(), Some(&cb))
2298            .unwrap();
2299        assert_eq!(result, raw_data);
2300        assert!(*progress_called.lock().unwrap());
2301    }
2302
2303    #[test]
2304    fn test_raw_to_safetensors_with_progress() {
2305        // Covers L575-583 — RawToSafeTensorsConverter progress callback
2306        let progress_called = std::sync::Arc::new(std::sync::Mutex::new(false));
2307        let pc = progress_called.clone();
2308        let cb: ProgressCallback = Box::new(move |_p| {
2309            *pc.lock().unwrap() = true;
2310        });
2311
2312        let result = RawToSafeTensorsConverter
2313            .convert(b"data", &ConversionOptions::default(), Some(&cb))
2314            .unwrap();
2315        assert!(!result.is_empty());
2316        assert!(*progress_called.lock().unwrap());
2317    }
2318
2319    #[test]
2320    fn test_safetensors_to_raw_header_exceeds() {
2321        // Covers L528 — header_len exceeds data length
2322        let mut data = Vec::new();
2323        data.extend_from_slice(&(9999u64).to_le_bytes());
2324        data.extend_from_slice(b"short");
2325        let err = SafeTensorsToRawConverter
2326            .convert(&data, &ConversionOptions::default(), None)
2327            .unwrap_err();
2328        assert!(format!("{err}").contains("exceeds"));
2329    }
2330
2331    #[test]
2332    fn test_shim_pipeline_returns_plan_not_data() {
2333        // PyTorch → ONNX needs an external Python toolchain. The pipeline must
2334        // say so explicitly rather than handing back plan bytes that a caller
2335        // would write out as a .onnx file.
2336        let pipeline = ConversionPipeline::with_builtins();
2337        let conv = pipeline
2338            .convert(
2339                b"model data",
2340                &ModelFormat::PyTorch,
2341                &ModelFormat::ONNX,
2342                &ConversionOptions::with_validation(),
2343                None,
2344            )
2345            .expect("planning a shim conversion is not an error");
2346
2347        assert!(conv.is_plan(), "shim conversion must be reported as a plan");
2348        assert!(
2349            conv.data.is_empty(),
2350            "a plan must not masquerade as model data"
2351        );
2352        assert_eq!(conv.output_size, 0);
2353        assert_eq!(conv.plan.as_ref().unwrap()["converter"], "pytorch_to_onnx");
2354        // Validating a plan against target-format magic bytes is meaningless.
2355        assert!(conv.validation.is_none());
2356    }
2357
2358    #[test]
2359    fn test_native_conversion_is_not_a_plan() {
2360        // The pure-Rust path must stay unaffected: real bytes, no plan.
2361        let header = serde_json::json!({
2362            "weight": { "dtype": "F32", "shape": [2, 2], "data_offsets": [0, 16] }
2363        });
2364        let header_bytes = serde_json::to_vec(&header).unwrap();
2365        let mut safetensors = Vec::new();
2366        safetensors.extend_from_slice(&(header_bytes.len() as u64).to_le_bytes());
2367        safetensors.extend_from_slice(&header_bytes);
2368        for f in &[1.0f32, 2.0, 3.0, 4.0] {
2369            safetensors.extend_from_slice(&f.to_le_bytes());
2370        }
2371
2372        let pipeline = ConversionPipeline::with_builtins();
2373        let conv = pipeline
2374            .convert(
2375                &safetensors,
2376                &ModelFormat::Safetensors,
2377                &ModelFormat::PyTorch,
2378                &ConversionOptions::default(),
2379                None,
2380            )
2381            .expect("native conversion should succeed");
2382
2383        assert!(!conv.is_plan());
2384        assert!(conv.plan.is_none());
2385        assert!(!conv.data.is_empty());
2386    }
2387
2388    #[test]
2389    fn test_multi_step_conversion_with_validation() {
2390        // Covers L392-403 — intermediate validation during multi-step
2391        let pipeline = ConversionPipeline::with_builtins();
2392        let opts = ConversionOptions::with_validation();
2393        // PyTorch → ONNX → TensorRT (2 steps, intermediate validation at step 0)
2394        let result = pipeline.convert(
2395            b"model data",
2396            &ModelFormat::PyTorch,
2397            &ModelFormat::TensorRT,
2398            &opts,
2399            None,
2400        );
2401        // May fail or pass depending on validation checks
2402        let _ = result;
2403    }
2404
2405    #[test]
2406    fn test_onnx_metadata_fields_5_and_6() {
2407        // Covers L693-713 — ONNX protobuf fields 5 (model_version) and 6 (doc_string)
2408        let mut data = vec![
2409            0x08, 0x09, // Field 1, varint: ir_version = 9
2410            0x12, 0x08, // Field 2, length-delimited: producer = "TestProd"
2411        ];
2412        data.extend_from_slice(b"TestProd");
2413        data.extend_from_slice(&[
2414            0x28, 0x2A, // Field 5, varint: model_version = 42
2415            0x32, 0x0C, // Field 6, length-delimited: doc_string = "A test model"
2416        ]);
2417        data.extend_from_slice(b"A test model");
2418
2419        let result = OnnxMetadataExtractor
2420            .convert(&data, &ConversionOptions::default(), None)
2421            .unwrap();
2422        let meta: serde_json::Value = serde_json::from_slice(&result).unwrap();
2423        assert_eq!(meta["ir_version"], 9);
2424        assert_eq!(meta["producer"], "TestProd");
2425        assert_eq!(meta["model_version"], 42);
2426        assert_eq!(meta["doc_string"], "A test model");
2427    }
2428
2429    #[test]
2430    fn test_onnx_metadata_skip_32bit_64bit() {
2431        // Covers L705-706, L709-710 — skipping 32-bit and 64-bit fields
2432        let mut data = Vec::new();
2433        // Field 1, varint (0x08), value 7
2434        data.push(0x08);
2435        data.push(0x07);
2436        // Field 10, 32-bit fixed (tag = 10<<3 | 5 = 0x55)
2437        data.push(0x55);
2438        data.extend_from_slice(&[0u8; 4]);
2439        // Field 11, 64-bit fixed (tag = 11<<3 | 1 = 0x59)
2440        data.push(0x59);
2441        data.extend_from_slice(&[0u8; 8]);
2442
2443        let result = OnnxMetadataExtractor
2444            .convert(&data, &ConversionOptions::default(), None)
2445            .unwrap();
2446        let meta: serde_json::Value = serde_json::from_slice(&result).unwrap();
2447        assert_eq!(meta["ir_version"], 7);
2448    }
2449
2450    #[test]
2451    fn test_validate_magic_bytes_onnx() {
2452        // Covers L466-467 — ONNX protobuf tag
2453        let data = vec![0x08, 0x07, 0x12, 0x04]; // starts with protobuf tag
2454        let check = validate_magic_bytes(&data, &ModelFormat::ONNX);
2455        assert!(check.passed);
2456    }
2457
2458    #[test]
2459    fn test_validate_magic_bytes_tflite() {
2460        // Covers L471 — TFLite FlatBuffer
2461        let mut data = vec![0x20, 0x00, 0x00, 0x00]; // TFLite magic
2462        data.extend_from_slice(&[0u8; 20]);
2463        let check = validate_magic_bytes(&data, &ModelFormat::TFLite);
2464        assert!(check.passed);
2465    }
2466
2467    #[test]
2468    fn test_validate_magic_bytes_pytorch_pickle() {
2469        // Covers L466-467 variations — PyTorch pickle format (0x80)
2470        let data = vec![0x80, 0x02, 0x00, 0x00];
2471        let check = validate_magic_bytes(&data, &ModelFormat::PyTorch);
2472        assert!(check.passed);
2473    }
2474
2475    #[test]
2476    fn test_validate_magic_bytes_pytorch_invalid() {
2477        let data = vec![0x00, 0xFF, 0x00, 0x00]; // Not PK and not 0x80
2478        let check = validate_magic_bytes(&data, &ModelFormat::PyTorch);
2479        assert!(!check.passed);
2480    }
2481
2482    #[test]
2483    fn test_validate_magic_bytes_safetensors_invalid() {
2484        // Invalid header length
2485        let mut data = Vec::new();
2486        data.extend_from_slice(&(99999u64).to_le_bytes()); // header_len > data.len
2487        let check = validate_magic_bytes(&data, &ModelFormat::Safetensors);
2488        assert!(!check.passed);
2489    }
2490
2491    #[test]
2492    fn test_validate_magic_bytes_safetensors_too_small() {
2493        let check = validate_magic_bytes(b"tiny", &ModelFormat::Safetensors);
2494        assert!(!check.passed);
2495    }
2496
2497    #[test]
2498    fn test_validate_magic_bytes_gguf_mismatch() {
2499        let check = validate_magic_bytes(b"NOT_GGUF", &ModelFormat::GGUF);
2500        assert!(!check.passed);
2501    }
2502
2503    #[test]
2504    fn test_validate_magic_bytes_tflite_mismatch() {
2505        let check = validate_magic_bytes(b"\x00\x00\x00\x00extra", &ModelFormat::TFLite);
2506        assert!(!check.passed);
2507    }
2508
2509    #[test]
2510    fn test_validate_magic_bytes_onnx_mismatch() {
2511        let check = validate_magic_bytes(b"\xFF\xFF", &ModelFormat::ONNX);
2512        assert!(!check.passed);
2513    }
2514
2515    #[test]
2516    fn test_gguf_header_parser_too_small() {
2517        // Covers L636 variation — data too small
2518        let err = GgufHeaderParser
2519            .convert(b"tiny_data", &ConversionOptions::default(), None)
2520            .unwrap_err();
2521        assert!(format!("{err}").contains("too small"));
2522    }
2523
2524    #[test]
2525    fn test_safetensors_to_pytorch_invalid_header_json() {
2526        // Covers L778 — invalid JSON in safetensors header
2527        let invalid_json = b"not valid json at all";
2528        let mut data = Vec::new();
2529        data.extend_from_slice(&(invalid_json.len() as u64).to_le_bytes());
2530        data.extend_from_slice(invalid_json);
2531        data.extend_from_slice(&[0u8; 16]);
2532        let err = SafeTensorsToPyTorchConverter
2533            .convert(&data, &ConversionOptions::default(), None)
2534            .unwrap_err();
2535        assert!(format!("{err}").contains("Invalid SafeTensors header JSON"));
2536    }
2537
2538    #[test]
2539    fn test_pipeline_default() {
2540        // Covers ConversionPipeline::default()
2541        let pipeline = ConversionPipeline::default();
2542        let list = pipeline.supported_conversions();
2543        assert!(!list.is_empty());
2544    }
2545
2546    #[test]
2547    fn test_multi_step_halts_at_first_plan_step() {
2548        // PyTorch → ONNX → TensorRT: step 1 needs external tooling, so there are
2549        // no real ONNX bytes for step 2 to consume. The pipeline must stop and
2550        // return that plan rather than feeding a plan into the next converter
2551        // (which previously produced a meaningless plan-of-a-plan).
2552        let pipeline = ConversionPipeline::with_builtins();
2553        let steps_arc = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2554        let sc = steps_arc.clone();
2555        let cb: ProgressCallback = Box::new(move |p| {
2556            sc.lock().unwrap().push(p.step);
2557        });
2558        let conv = pipeline
2559            .convert(
2560                b"model",
2561                &ModelFormat::PyTorch,
2562                &ModelFormat::TensorRT,
2563                &ConversionOptions::default(),
2564                Some(&cb),
2565            )
2566            .expect("planning should not error");
2567
2568        let logged = steps_arc.lock().unwrap();
2569        assert_eq!(logged.len(), 1, "must stop after the first plan step");
2570        assert!(conv.is_plan());
2571        assert_eq!(conv.plan.as_ref().unwrap()["converter"], "pytorch_to_onnx");
2572    }
2573
2574    #[test]
2575    fn test_parse_varint_overflow() {
2576        // Covers L1024-L1025 — shift >= 64 branch in parse_varint
2577        // A varint with all continuation bits set (10 bytes, each 0xFF except last)
2578        // will hit the shift >= 64 guard
2579        let data = vec![0x80u8; 10]; // All continuation, never terminates normally
2580        let (result, pos) = parse_varint(&data, 0);
2581        // Should break out after shift >= 64 (after ~9 bytes)
2582        assert!(pos <= data.len());
2583        let _ = result; // Value is meaningless for overflowed varint
2584    }
2585
2586    #[test]
2587    fn test_parse_protobuf_tag_empty() {
2588        // Covers L1034 — pos >= data.len() returns None
2589        let data: &[u8] = &[];
2590        assert!(parse_protobuf_tag(data, 0).is_none());
2591
2592        // Also test pos beyond bounds
2593        let data2 = &[0x08u8]; // 1 byte
2594        assert!(parse_protobuf_tag(data2, 5).is_none());
2595    }
2596
2597    #[test]
2598    fn test_parse_length_delimited_exceeds() {
2599        // Covers L1046-L1047 — length field exceeds remaining data
2600        // Varint for length=100 followed by only 2 bytes of data
2601        let data = vec![100u8, 0xAA, 0xBB]; // length=100 but only 2 bytes follow
2602        let result = parse_length_delimited(&data, 0);
2603        assert!(result.is_err());
2604        let msg = format!("{}", result.unwrap_err());
2605        assert!(msg.contains("exceeds data"));
2606    }
2607
2608    // ── Additional coverage: ONNX extractor edge cases ──
2609
2610    #[test]
2611    fn test_onnx_metadata_empty_data() {
2612        let result = OnnxMetadataExtractor
2613            .convert(&[], &ConversionOptions::default(), None)
2614            .unwrap();
2615        let meta: serde_json::Value = serde_json::from_slice(&result).unwrap();
2616        assert_eq!(meta["ir_version"], 0);
2617        assert_eq!(meta["producer"], "");
2618    }
2619
2620    #[test]
2621    fn test_onnx_metadata_skip_unknown_wire_type() {
2622        // Wire type 3 or 4 (unknown) should cause break
2623        let mut data = vec![
2624            0x08, 0x05, // field 1 varint = 5
2625        ];
2626        // Wire type 3 (start group), field 20: tag = 20<<3 | 3 = 163 = 0xA3
2627        data.push(0xA3);
2628        data.push(0x01);
2629        // Parser should break on wire type 3, but ir_version should be extracted
2630        let result = OnnxMetadataExtractor
2631            .convert(&data, &ConversionOptions::default(), None)
2632            .unwrap();
2633        let meta: serde_json::Value = serde_json::from_slice(&result).unwrap();
2634        assert_eq!(meta["ir_version"], 5);
2635    }
2636
2637    #[test]
2638    fn test_onnx_metadata_skip_varint_field() {
2639        // Unknown varint field (e.g. field 3) should be skipped
2640        let data = vec![
2641            0x08, 0x03, // field 1: ir_version = 3
2642            0x18, 0x42, // field 3: unknown varint (skipped)
2643            0x28, 0x01, // field 5: model_version = 1
2644        ];
2645        let result = OnnxMetadataExtractor
2646            .convert(&data, &ConversionOptions::default(), None)
2647            .unwrap();
2648        let meta: serde_json::Value = serde_json::from_slice(&result).unwrap();
2649        assert_eq!(meta["ir_version"], 3);
2650        assert_eq!(meta["model_version"], 1);
2651    }
2652
2653    #[test]
2654    fn test_onnx_metadata_skip_length_delimited_field() {
2655        // Unknown length-delimited field (e.g. field 7) should be skipped
2656        let mut data = vec![
2657            0x08, 0x04, // field 1: ir_version = 4
2658            0x3A, 0x03, // field 7: length-delimited, length = 3
2659        ];
2660        data.extend_from_slice(b"abc"); // 3 bytes of payload for field 7
2661        data.extend_from_slice(&[0x28, 0x0A]); // field 5: model_version = 10
2662
2663        let result = OnnxMetadataExtractor
2664            .convert(&data, &ConversionOptions::default(), None)
2665            .unwrap();
2666        let meta: serde_json::Value = serde_json::from_slice(&result).unwrap();
2667        assert_eq!(meta["ir_version"], 4);
2668        assert_eq!(meta["model_version"], 10);
2669    }
2670
2671    // ── Additional coverage: SafeTensors → PyTorch edge cases ──
2672
2673    #[test]
2674    fn test_safetensors_to_pytorch_header_too_large() {
2675        // Covers header > 100MB cap
2676        let mut data = Vec::new();
2677        let huge = 200 * 1024 * 1024u64; // 200 MB
2678        data.extend_from_slice(&huge.to_le_bytes());
2679        data.extend_from_slice(&[0u8; 16]);
2680        let err = SafeTensorsToPyTorchConverter
2681            .convert(&data, &ConversionOptions::default(), None)
2682            .unwrap_err();
2683        assert!(format!("{err}").contains("too large"));
2684    }
2685
2686    #[test]
2687    fn test_safetensors_to_pytorch_missing_dtype() {
2688        let header = serde_json::json!({
2689            "weight": { "shape": [2], "data_offsets": [0, 8] }
2690        });
2691        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2692        let mut data = Vec::new();
2693        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2694        data.extend_from_slice(&hdr_bytes);
2695        data.extend_from_slice(&[0u8; 8]);
2696        let err = SafeTensorsToPyTorchConverter
2697            .convert(&data, &ConversionOptions::default(), None)
2698            .unwrap_err();
2699        assert!(format!("{err}").contains("dtype"));
2700    }
2701
2702    #[test]
2703    fn test_safetensors_to_pytorch_missing_shape() {
2704        let header = serde_json::json!({
2705            "weight": { "dtype": "F32", "data_offsets": [0, 8] }
2706        });
2707        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2708        let mut data = Vec::new();
2709        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2710        data.extend_from_slice(&hdr_bytes);
2711        data.extend_from_slice(&[0u8; 8]);
2712        let err = SafeTensorsToPyTorchConverter
2713            .convert(&data, &ConversionOptions::default(), None)
2714            .unwrap_err();
2715        assert!(format!("{err}").contains("shape"));
2716    }
2717
2718    #[test]
2719    fn test_safetensors_to_pytorch_missing_offsets() {
2720        let header = serde_json::json!({
2721            "weight": { "dtype": "F32", "shape": [2] }
2722        });
2723        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2724        let mut data = Vec::new();
2725        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2726        data.extend_from_slice(&hdr_bytes);
2727        data.extend_from_slice(&[0u8; 8]);
2728        let err = SafeTensorsToPyTorchConverter
2729            .convert(&data, &ConversionOptions::default(), None)
2730            .unwrap_err();
2731        assert!(format!("{err}").contains("data_offsets"));
2732    }
2733
2734    #[test]
2735    fn test_safetensors_to_pytorch_tensor_not_object() {
2736        let header = serde_json::json!({
2737            "weight": "not an object"
2738        });
2739        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2740        let mut data = Vec::new();
2741        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2742        data.extend_from_slice(&hdr_bytes);
2743        let err = SafeTensorsToPyTorchConverter
2744            .convert(&data, &ConversionOptions::default(), None)
2745            .unwrap_err();
2746        assert!(format!("{err}").contains("not an object"));
2747    }
2748
2749    #[test]
2750    fn test_safetensors_to_pytorch_out_of_bounds_offsets() {
2751        let header = serde_json::json!({
2752            "weight": { "dtype": "F32", "shape": [100], "data_offsets": [0, 99999] }
2753        });
2754        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2755        let mut data = Vec::new();
2756        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2757        data.extend_from_slice(&hdr_bytes);
2758        data.extend_from_slice(&[0u8; 16]); // Only 16 bytes of tensor data
2759        let err = SafeTensorsToPyTorchConverter
2760            .convert(&data, &ConversionOptions::default(), None)
2761            .unwrap_err();
2762        assert!(format!("{err}").contains("out of bounds"));
2763    }
2764
2765    #[test]
2766    fn test_safetensors_to_pytorch_unsupported_dtype() {
2767        let header = serde_json::json!({
2768            "weight": { "dtype": "COMPLEX128", "shape": [2], "data_offsets": [0, 16] }
2769        });
2770        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2771        let mut data = Vec::new();
2772        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2773        data.extend_from_slice(&hdr_bytes);
2774        data.extend_from_slice(&[0u8; 16]);
2775        let err = SafeTensorsToPyTorchConverter
2776            .convert(&data, &ConversionOptions::default(), None)
2777            .unwrap_err();
2778        assert!(format!("{err}").contains("Unsupported dtype"));
2779    }
2780
2781    #[test]
2782    fn test_safetensors_to_pytorch_metadata_skipped() {
2783        // __metadata__ key should be skipped (not treated as tensor)
2784        let header = serde_json::json!({
2785            "__metadata__": { "format": "pt" },
2786            "bias": { "dtype": "F32", "shape": [4], "data_offsets": [0, 16] }
2787        });
2788        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2789        let mut data = Vec::new();
2790        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2791        data.extend_from_slice(&hdr_bytes);
2792        data.extend_from_slice(&[0u8; 16]);
2793        let result = SafeTensorsToPyTorchConverter
2794            .convert(&data, &ConversionOptions::default(), None)
2795            .unwrap();
2796        assert_eq!(&result[0..2], b"PK"); // ZIP archive
2797    }
2798
2799    #[test]
2800    fn test_safetensors_to_pytorch_with_progress() {
2801        let header = serde_json::json!({
2802            "w": { "dtype": "F32", "shape": [2], "data_offsets": [0, 8] }
2803        });
2804        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2805        let mut data = Vec::new();
2806        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2807        data.extend_from_slice(&hdr_bytes);
2808        data.extend_from_slice(&[0u8; 8]);
2809
2810        let msgs = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2811        let mc = msgs.clone();
2812        let cb: ProgressCallback = Box::new(move |p| {
2813            mc.lock().unwrap().push(p.message.clone());
2814        });
2815        let result = SafeTensorsToPyTorchConverter
2816            .convert(&data, &ConversionOptions::default(), Some(&cb))
2817            .unwrap();
2818        assert!(!result.is_empty());
2819        assert!(msgs.lock().unwrap().len() >= 2);
2820    }
2821
2822    // ── Additional coverage: PyTorch → SafeTensors edge cases ──
2823
2824    #[test]
2825    fn test_pytorch_to_safetensors_with_progress() {
2826        // Create a valid SafeTensors → PyTorch → SafeTensors round trip with progress
2827        let header = serde_json::json!({
2828            "w": { "dtype": "F32", "shape": [2], "data_offsets": [0, 8] }
2829        });
2830        let hdr_bytes = serde_json::to_vec(&header).unwrap();
2831        let mut st_data = Vec::new();
2832        st_data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
2833        st_data.extend_from_slice(&hdr_bytes);
2834        st_data.extend_from_slice(&[0u8; 8]);
2835
2836        let pt_bytes = SafeTensorsToPyTorchConverter
2837            .convert(&st_data, &ConversionOptions::default(), None)
2838            .unwrap();
2839
2840        let msgs = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2841        let mc = msgs.clone();
2842        let cb: ProgressCallback = Box::new(move |p| {
2843            mc.lock().unwrap().push(p.message.clone());
2844        });
2845        let result = PyTorchToSafeTensorsConverter
2846            .convert(&pt_bytes, &ConversionOptions::default(), Some(&cb))
2847            .unwrap();
2848        assert!(!result.is_empty());
2849        assert!(!msgs.lock().unwrap().is_empty());
2850    }
2851
2852    #[test]
2853    fn test_pytorch_to_safetensors_fallback_no_pickle() {
2854        // Create a ZIP without data.pkl — exercises fallback path
2855        let mut buf = std::io::Cursor::new(Vec::new());
2856        {
2857            let mut zip = zip::ZipWriter::new(&mut buf);
2858            let opts = zip::write::SimpleFileOptions::default()
2859                .compression_method(zip::CompressionMethod::Stored);
2860            zip.start_file("archive/data/0", opts).unwrap();
2861            std::io::Write::write_all(&mut zip, &[1u8, 2, 3, 4]).unwrap();
2862            zip.finish().unwrap();
2863        }
2864        let pt_bytes = buf.into_inner();
2865        let result = PyTorchToSafeTensorsConverter
2866            .convert(&pt_bytes, &ConversionOptions::default(), None)
2867            .unwrap();
2868        let hdr_len = u64::from_le_bytes(result[0..8].try_into().unwrap()) as usize;
2869        let hdr: serde_json::Value = serde_json::from_slice(&result[8..8 + hdr_len]).unwrap();
2870        // Fallback should create storage_0 with U8 dtype
2871        assert!(hdr.get("storage_0").is_some());
2872        let entry = &hdr["storage_0"];
2873        assert_eq!(entry["dtype"], "U8");
2874    }
2875
2876    // ── Additional coverage: dtype mapping ──
2877
2878    #[test]
2879    fn test_safetensors_dtype_to_pytorch_all_types() {
2880        assert_eq!(
2881            safetensors_dtype_to_pytorch("F64"),
2882            Some(("DoubleStorage", 8))
2883        );
2884        assert_eq!(
2885            safetensors_dtype_to_pytorch("F32"),
2886            Some(("FloatStorage", 4))
2887        );
2888        assert_eq!(
2889            safetensors_dtype_to_pytorch("F16"),
2890            Some(("HalfStorage", 2))
2891        );
2892        assert_eq!(
2893            safetensors_dtype_to_pytorch("BF16"),
2894            Some(("BFloat16Storage", 2))
2895        );
2896        assert_eq!(
2897            safetensors_dtype_to_pytorch("I64"),
2898            Some(("LongStorage", 8))
2899        );
2900        assert_eq!(safetensors_dtype_to_pytorch("I32"), Some(("IntStorage", 4)));
2901        assert_eq!(
2902            safetensors_dtype_to_pytorch("I16"),
2903            Some(("ShortStorage", 2))
2904        );
2905        assert_eq!(safetensors_dtype_to_pytorch("I8"), Some(("CharStorage", 1)));
2906        assert_eq!(safetensors_dtype_to_pytorch("U8"), Some(("ByteStorage", 1)));
2907        assert_eq!(
2908            safetensors_dtype_to_pytorch("BOOL"),
2909            Some(("BoolStorage", 1))
2910        );
2911        assert_eq!(safetensors_dtype_to_pytorch("UNKNOWN"), None);
2912    }
2913
2914    #[test]
2915    fn test_pytorch_storage_to_safetensors_dtype_all_types() {
2916        assert_eq!(
2917            pytorch_storage_to_safetensors_dtype("DoubleStorage"),
2918            Some(("F64", 8))
2919        );
2920        assert_eq!(
2921            pytorch_storage_to_safetensors_dtype("FloatStorage"),
2922            Some(("F32", 4))
2923        );
2924        assert_eq!(
2925            pytorch_storage_to_safetensors_dtype("HalfStorage"),
2926            Some(("F16", 2))
2927        );
2928        assert_eq!(
2929            pytorch_storage_to_safetensors_dtype("BFloat16Storage"),
2930            Some(("BF16", 2))
2931        );
2932        assert_eq!(
2933            pytorch_storage_to_safetensors_dtype("LongStorage"),
2934            Some(("I64", 8))
2935        );
2936        assert_eq!(
2937            pytorch_storage_to_safetensors_dtype("IntStorage"),
2938            Some(("I32", 4))
2939        );
2940        assert_eq!(
2941            pytorch_storage_to_safetensors_dtype("ShortStorage"),
2942            Some(("I16", 2))
2943        );
2944        assert_eq!(
2945            pytorch_storage_to_safetensors_dtype("CharStorage"),
2946            Some(("I8", 1))
2947        );
2948        assert_eq!(
2949            pytorch_storage_to_safetensors_dtype("ByteStorage"),
2950            Some(("U8", 1))
2951        );
2952        assert_eq!(
2953            pytorch_storage_to_safetensors_dtype("UntypedStorage"),
2954            Some(("U8", 1))
2955        );
2956        assert_eq!(
2957            pytorch_storage_to_safetensors_dtype("BoolStorage"),
2958            Some(("BOOL", 1))
2959        );
2960        assert_eq!(pytorch_storage_to_safetensors_dtype("UnknownStorage"), None);
2961    }
2962
2963    // ── Additional coverage: pickle helpers ──
2964
2965    #[test]
2966    fn test_write_short_binunicode_short() {
2967        let mut pkl = Vec::new();
2968        write_short_binunicode(&mut pkl, "abc");
2969        assert_eq!(pkl[0], 0x8c); // SHORT_BINUNICODE
2970        assert_eq!(pkl[1], 3);
2971        assert_eq!(&pkl[2..5], b"abc");
2972    }
2973
2974    #[test]
2975    fn test_write_short_binunicode_long() {
2976        let mut pkl = Vec::new();
2977        let s = "x".repeat(300);
2978        write_short_binunicode(&mut pkl, &s);
2979        assert_eq!(pkl[0], 0x8d); // BINUNICODE
2980        let len = u32::from_le_bytes(pkl[1..5].try_into().unwrap());
2981        assert_eq!(len, 300);
2982    }
2983
2984    #[test]
2985    fn test_write_pickle_int_ranges() {
2986        // BININT1: 0 - 255
2987        let mut pkl = Vec::new();
2988        write_pickle_int(&mut pkl, 42);
2989        assert_eq!(pkl[0], 0x4b);
2990        assert_eq!(pkl[1], 42);
2991
2992        // BININT2: 256 - 65535
2993        pkl.clear();
2994        write_pickle_int(&mut pkl, 1000);
2995        assert_eq!(pkl[0], 0x4d);
2996        assert_eq!(u16::from_le_bytes(pkl[1..3].try_into().unwrap()), 1000);
2997
2998        // BININT: i32 range
2999        pkl.clear();
3000        write_pickle_int(&mut pkl, 100_000);
3001        assert_eq!(pkl[0], 0x4a);
3002        assert_eq!(i32::from_le_bytes(pkl[1..5].try_into().unwrap()), 100_000);
3003
3004        // LONG1: > i32 range
3005        pkl.clear();
3006        write_pickle_int(&mut pkl, i64::MAX);
3007        assert_eq!(pkl[0], 0x8a); // LONG1
3008    }
3009
3010    #[test]
3011    fn test_write_pickle_int_negative() {
3012        let mut pkl = Vec::new();
3013        write_pickle_int(&mut pkl, -100);
3014        assert_eq!(pkl[0], 0x4a); // BININT (i32 range)
3015        assert_eq!(i32::from_le_bytes(pkl[1..5].try_into().unwrap()), -100);
3016
3017        // Large negative (< i32::MIN) — LONG1
3018        pkl.clear();
3019        write_pickle_int(&mut pkl, i64::MIN);
3020        assert_eq!(pkl[0], 0x8a); // LONG1
3021    }
3022
3023    #[test]
3024    fn test_extract_tensor_info_from_pickle_empty() {
3025        let infos = extract_tensor_info_from_pickle(&[]);
3026        assert!(infos.is_empty());
3027    }
3028
3029    #[test]
3030    fn test_extract_tensor_info_from_pickle_roundtrip() {
3031        // Build a pickle via build_pytorch_pickle, then extract info back
3032        let tensors = vec![ConvTensorEntry {
3033            name: "model.weight".to_string(),
3034            dtype: "F32".to_string(),
3035            shape: vec![3, 4],
3036            data_start: 0,
3037            data_end: 48,
3038        }];
3039        let pkl = build_pytorch_pickle(&tensors).unwrap();
3040        let infos = extract_tensor_info_from_pickle(&pkl);
3041        assert!(!infos.is_empty());
3042        assert_eq!(infos[0].name, "model.weight");
3043        assert_eq!(infos[0].dtype, "F32");
3044    }
3045
3046    #[test]
3047    fn test_safetensors_to_pytorch_multiple_dtypes() {
3048        // Multi-tensor with different dtypes
3049        let header = serde_json::json!({
3050            "w1": { "dtype": "F32", "shape": [2], "data_offsets": [0, 8] },
3051            "w2": { "dtype": "F16", "shape": [4], "data_offsets": [8, 16] },
3052            "w3": { "dtype": "I64", "shape": [1], "data_offsets": [16, 24] }
3053        });
3054        let hdr_bytes = serde_json::to_vec(&header).unwrap();
3055        let mut data = Vec::new();
3056        data.extend_from_slice(&(hdr_bytes.len() as u64).to_le_bytes());
3057        data.extend_from_slice(&hdr_bytes);
3058        data.extend_from_slice(&[0u8; 24]);
3059        let result = SafeTensorsToPyTorchConverter
3060            .convert(&data, &ConversionOptions::default(), None)
3061            .unwrap();
3062        assert_eq!(&result[0..2], b"PK");
3063
3064        // Roundtrip back
3065        let st2 = PyTorchToSafeTensorsConverter
3066            .convert(&result, &ConversionOptions::default(), None)
3067            .unwrap();
3068        let hdr_len = u64::from_le_bytes(st2[0..8].try_into().unwrap()) as usize;
3069        let hdr: serde_json::Value = serde_json::from_slice(&st2[8..8 + hdr_len]).unwrap();
3070        assert!(hdr.as_object().unwrap().len() >= 3);
3071    }
3072
3073    #[test]
3074    fn test_pipeline_convert_no_path() {
3075        // Covers the error path in convert() when find_path returns None
3076        let pipeline = ConversionPipeline::with_builtins();
3077        let err = pipeline
3078            .convert(
3079                b"data",
3080                &ModelFormat::MXNet,
3081                &ModelFormat::Darknet,
3082                &ConversionOptions::default(),
3083                None,
3084            )
3085            .unwrap_err();
3086        assert!(format!("{err}").contains("No conversion path"));
3087    }
3088
3089    #[test]
3090    fn test_validate_magic_bytes_onnx_short_data() {
3091        // ONNX needs at least 2 bytes; short data should fail
3092        let check = validate_magic_bytes(b"", &ModelFormat::ONNX);
3093        assert!(!check.passed);
3094    }
3095}