## Coverage Report

#### Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)

    /home/noah/src/realizar/src/convert/mod.rs

    Line
    Count
    Source
    1
    //! GGUF to APR Transformer Converter
    2
    //!
    3
    //! Converts GGUF models to APR Transformer format for fair comparison.
    4
    //! All weights are dequantized to F32 for WASM compatibility.
    5
    //!
    6
    //! ## Example
    7
    //!
    8
    //! ```rust,ignore
    9
    //! use realizar::convert::GgufToAprConverter;
    10
    //!
    11
    //! let gguf_data = std::fs::read("model.gguf")?;
    12
    //! let apr_transformer = GgufToAprConverter::convert(&gguf_data)?;
    13
    //!
    14
    //! // Save to APR format
    15
    //! let apr_bytes = apr_transformer.to_apr_bytes()?;
    16
    //! std::fs::write("model.apr_transformer", apr_bytes)?;
    17
    //! ```
    18
    19
    use crate::apr::{AprHeader, TensorEntry, ALIGNMENT, HEADER_SIZE, MAGIC};
    20
    use crate::apr_transformer::{AprTransformer, AprTransformerConfig, AprTransformerLayer};
    21
    use crate::error::{RealizarError, Result};
    22
    use crate::gguf::{GGUFModel, GGUFTransformer};
    23
    24
    /// GGUF to APR Transformer converter
    25
    ///
    26
    /// Converts GGUF models with quantized weights to APR format with F32 weights.
    27
    /// This enables fair comparison between GGUF and APR serving performance.
    28
    pub struct GgufToAprConverter;
    29
    30
    impl GgufToAprConverter {
    31
        /// Convert GGUF file bytes to APR Transformer
    32
        ///
    33
        /// # Arguments
    34
        ///
    35
        /// * `gguf_data` - Raw GGUF file bytes
    36
        ///
    37
        /// # Returns
    38
        ///
    39
        /// `AprTransformer` with dequantized F32 weights
    40
        ///
    41
        /// # Errors
    42
        ///
    43
        /// Returns error if GGUF parsing or conversion fails
    44
    0
        pub fn convert(gguf_data: &[u8]) -> Result<AprTransformer> {
    45
            // Parse GGUF model
    46
    0
            let gguf_model = GGUFModel::from_bytes(gguf_data)?;
    47
    48
            // Load transformer weights (dequantizes to F32)
    49
    0
            let gguf_transformer = GGUFTransformer::from_gguf(&gguf_model, gguf_data)?;
    50
    51
            // Convert to APR format
    52
    0
            Ok(Self::from_gguf_transformer(&gguf_transformer))
    53
    0
        }
    54
    55
        /// Convert from existing `GGUFTransformer` to `AprTransformer`
    56
        ///
    57
        /// # Arguments
    58
        ///
    59
        /// * `gguf` - Loaded GGUF transformer with dequantized weights
    60
        ///
    61
        /// # Returns
    62
        ///
    63
        /// `AprTransformer` with the same weights
    64
    18
        pub fn from_gguf_transformer(gguf: &GGUFTransformer) -> AprTransformer {
    65
    18
            let config = AprTransformerConfig {
    66
    18
                architecture: gguf.config.architecture.clone(),
    67
    18
                hidden_dim: gguf.config.hidden_dim,
    68
    18
                num_layers: gguf.config.num_layers,
    69
    18
                num_heads: gguf.config.num_heads,
    70
    18
                num_kv_heads: gguf.config.num_kv_heads,
    71
    18
                vocab_size: gguf.config.vocab_size,
    72
    18
                intermediate_dim: gguf.config.intermediate_dim,
    73
    18
                context_length: gguf.config.context_length,
    74
    18
                rope_theta: gguf.config.rope_theta,
    75
    18
                eps: gguf.config.eps,
    76
    18
            };
    77
    78
    18
            let layers = gguf
    79
    18
                .layers
    80
    18
                .iter()
    81
    18
                .map(|l| AprTransformerLayer {
    82
    36
                    attn_norm_weight: l.attn_norm_weight.clone(),
    83
    36
                    attn_norm_bias: l.attn_norm_bias.clone(),
    84
    36
                    qkv_weight: l.qkv_weight.clone(),
    85
    36
                    qkv_bias: l.qkv_bias.clone(),
    86
    36
                    attn_output_weight: l.attn_output_weight.clone(),
    87
    36
                    attn_output_bias: l.attn_output_bias.clone(),
    88
    36
                    ffn_gate_weight: l.ffn_gate_weight.clone(),
    89
    36
                    ffn_gate_bias: l.ffn_gate_bias.clone(),
    90
    36
                    ffn_up_weight: l.ffn_up_weight.clone(),
    91
    36
                    ffn_up_bias: l.ffn_up_bias.clone(),
    92
    36
                    ffn_down_weight: l.ffn_down_weight.clone(),
    93
    36
                    ffn_down_bias: l.ffn_down_bias.clone(),
    94
    36
                    ffn_norm_weight: l.ffn_norm_weight.clone(),
    95
    36
                    ffn_norm_bias: l.ffn_norm_bias.clone(),
    96
    36
                })
    97
    18
                .collect();
    98
    99
    18
            AprTransformer {
    100
    18
                config,
    101
    18
                token_embedding: gguf.token_embedding.clone(),
    102
    18
                layers,
    103
    18
                output_norm_weight: gguf.output_norm_weight.clone(),
    104
    18
                output_norm_bias: gguf.output_norm_bias.clone(),
    105
    18
                lm_head_weight: gguf.lm_head_weight.clone(),
    106
    18
                lm_head_bias: gguf.lm_head_bias.clone(),
    107
    18
                q4k_layers: None,
    108
    18
                lm_head_weight_q6k: None,
    109
    18
                lm_head_weight_q4k: None,
    110
    18
            }
    111
    18
        }
    112
    113
        /// Convert APR Transformer to serialized APR v2 bytes
    114
        ///
    115
        /// Creates a valid .apr v2 file with:
    116
        /// - APR v2 header (64 bytes)
    117
        /// - JSON metadata (padded to 64-byte boundary)
    118
        /// - Tensor index (JSON array)
    119
        /// - Tensor data (each 64-byte aligned)
    120
        ///
    121
        /// # Arguments
    122
        ///
    123
        /// * `transformer` - APR Transformer to serialize
    124
        ///
    125
        /// # Returns
    126
        ///
    127
        /// Raw bytes in APR v2 format
    128
        ///
    129
        /// # Errors
    130
        ///
    131
        /// Returns error if serialization fails
    132
        #[allow(clippy::cast_possible_truncation)]
    133
    14
        pub fn to_apr_bytes(transformer: &AprTransformer) -> Result<Vec<u8>> {
    134
            // Serialize metadata
    135
    14
            let metadata = serde_json::json!({
    136
    14
                "model_type": "transformer_lm",
    137
    14
                "architecture": transformer.config.architecture,
    138
    14
                "hidden_size": transformer.config.hidden_dim,
    139
    14
                "num_layers": transformer.config.num_layers,
    140
    14
                "num_heads": transformer.config.num_heads,
    141
    14
                "num_kv_heads": transformer.config.num_kv_heads,
    142
    14
                "vocab_size": transformer.config.vocab_size,
    143
    14
                "intermediate_dim": transformer.config.intermediate_dim,
    144
    14
                "context_length": transformer.config.context_length,
    145
    14
                "rope_theta": transformer.config.rope_theta,
    146
    14
                "eps": transformer.config.eps,
    147
            });
    148
    14
            let metadata_bytes =
    149
    14
                serde_json::to_vec(&metadata).map_err(|e| RealizarError::FormatError {
    150
    0
                    reason: format!("Failed to serialize metadata: {e}"),
    151
    0
                })?;
    152
    153
            // Pad metadata to 64-byte boundary
    154
    14
            let metadata_padded_len = metadata_bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT;
    155
    156
            // Serialize weights as single tensor (JSON payload for now)
    157
    14
            let payload_bytes =
    158
    14
                serde_json::to_vec(transformer).map_err(|e| RealizarError::FormatError {
    159
    0
                    reason: format!("Failed to serialize weights: {e}"),
    160
    0
                })?;
    161
    162
            // Create tensor index with single entry for the full payload
    163
    14
            let tensor_entries = vec![TensorEntry {
    164
    14
                name: "weights".to_string(),
    165
    14
                dtype: "json".to_string(),
    166
    14
                shape: vec![payload_bytes.len()],
    167
    14
                offset: 0,
    168
    14
                size: payload_bytes.len() as u64,
    169
    14
            }];
    170
    14
            let tensor_index_bytes =
    171
    14
                serde_json::to_vec(&tensor_entries).map_err(|e| RealizarError::FormatError {
    172
    0
                    reason: format!("Failed to serialize tensor index: {e}"),
    173
    0
                })?;
    174
    175
            // Calculate offsets
    176
    14
            let metadata_offset = HEADER_SIZE as u64;
    177
    14
            let tensor_index_offset = metadata_offset + metadata_padded_len as u64;
    178
    14
            let data_offset = tensor_index_offset + tensor_index_bytes.len() as u64;
    179
    180
            // Build APR v2 header (64 bytes)
    181
    14
            let mut header = vec![0u8; HEADER_SIZE];
    182
    14
            header[0..4].copy_from_slice(&MAGIC);
    183
    14
            header[4] = 2; // version major
    184
    14
            header[5] = 0; // version minor
    185
    14
            header[6..8].copy_from_slice(&0u16.to_le_bytes()); // flags
    186
    14
            header[8..12].copy_from_slice(&1u32.to_le_bytes()); // tensor_count
    187
    14
            header[12..20].copy_from_slice(&metadata_offset.to_le_bytes());
    188
    14
            header[20..24].copy_from_slice(&(metadata_bytes.len() as u32).to_le_bytes());
    189
    14
            header[24..32].copy_from_slice(&tensor_index_offset.to_le_bytes());
    190
    14
            header[32..40].copy_from_slice(&data_offset.to_le_bytes());
    191
    14
            header[40..44].copy_from_slice(&0u32.to_le_bytes()); // checksum: reserved for future use
    192
                                                                 // bytes 44-63 reserved
    193
    194
            // Combine all parts
    195
    14
            let total_size =
    196
    14
                HEADER_SIZE + metadata_padded_len + tensor_index_bytes.len() + payload_bytes.len();
    197
    14
            let mut result = Vec::with_capacity(total_size);
    198
    14
            result.extend_from_slice(&header);
    199
    14
            result.extend_from_slice(&metadata_bytes);
    200
    14
            result.resize(HEADER_SIZE + metadata_padded_len, 0); // pad metadata
    201
    14
            result.extend_from_slice(&tensor_index_bytes);
    202
    14
            result.extend_from_slice(&payload_bytes);
    203
    204
    14
            Ok(result)
    205
    14
        }
    206
    207
        /// Load APR Transformer from APR v2 bytes
    208
        ///
    209
        /// # Arguments
    210
        ///
    211
        /// * `data` - Raw APR v2 file bytes
    212
        ///
    213
        /// # Returns
    214
        ///
    215
        /// Loaded `AprTransformer`
    216
        ///
    217
        /// # Errors
    218
        ///
    219
        /// Returns error if parsing fails
    220
    19
        pub fn from_apr_bytes(data: &[u8]) -> Result<AprTransformer> {
    221
            // Parse header
    222
    19
            let header15 = AprHeader::from_bytes(data)?4;
    223
    224
            // Get tensor index to find the weights tensor
    225
    15
            let index_start = header.tensor_index_offset as usize;
    226
    15
            let index_end = header.data_offset as usize;
    227
    228
    15
            if data.len() < index_end {
    229
    2
                return Err(RealizarError::FormatError {
    230
    2
                    reason: format!(
    231
    2
                        "APR file truncated: expected {} bytes for tensor index, got {}",
    232
    2
                        index_end,
    233
    2
                        data.len()
    234
    2
                    ),
    235
    2
                });
    236
    13
            }
    237
    238
    4
            let tensor_entries: Vec<TensorEntry> =
    239
    13
                serde_json::from_slice(&data[index_start..index_end]).map_err(|e| {9
    240
    9
                    RealizarError::FormatError {
    241
    9
                        reason: format!("Failed to parse tensor index: {e}"),
    242
    9
                    }
    243
    9
                })?;
    244
    245
            // Find the weights tensor
    246
    4
            let weights_entry3 = tensor_entries
    247
    4
                .iter()
    248
    4
                .find(|e| e.name3 == "weights"3)
    249
    4
                .ok_or_else(|| RealizarError::FormatError {
    250
    1
                    reason: "No 'weights' tensor found in APR file".to_string(),
    251
    1
                })?;
    252
    253
            // Extract weights data
    254
    3
            let data_start = header.data_offset as usize + weights_entry.offset as usize;
    255
    3
            let data_end = data_start + weights_entry.size as usize;
    256
    257
    3
            if data.len() < data_end {
    258
    0
                return Err(RealizarError::FormatError {
    259
    0
                    reason: format!(
    260
    0
                        "APR file truncated: expected {} bytes for tensor data, got {}",
    261
    0
                        data_end,
    262
    0
                        data.len()
    263
    0
                    ),
    264
    0
                });
    265
    3
            }
    266
    267
    3
            let payload_bytes = &data[data_start..data_end];
    268
    269
            // Deserialize transformer
    270
    3
            let transformer: AprTransformer =
    271
    3
                serde_json::from_slice(payload_bytes).map_err(|e| RealizarError::FormatError {
    272
    0
                    reason: format!("Failed to deserialize transformer: {e}"),
    273
    0
                })?;
    274
    275
    3
            Ok(transformer)
    276
    19
        }
    277
    278
        /// Get conversion statistics
    279
        ///
    280
        /// # Arguments
    281
        ///
    282
        /// * `transformer` - APR Transformer to analyze
    283
        ///
    284
        /// # Returns
    285
        ///
    286
        /// Statistics about the conversion
    287
    6
        pub fn stats(transformer: &AprTransformer) -> ConversionStats {
    288
    6
            let params = transformer.num_parameters();
    289
    6
            let memory_bytes = transformer.memory_size();
    290
    291
    6
            ConversionStats {
    292
    6
                total_parameters: params,
    293
    6
                memory_bytes_f32: memory_bytes,
    294
    6
                num_layers: transformer.config.num_layers,
    295
    6
                hidden_dim: transformer.config.hidden_dim,
    296
    6
                vocab_size: transformer.config.vocab_size,
    297
    6
                architecture: transformer.config.architecture.clone(),
    298
    6
            }
    299
    6
        }
    300
    }
    301
    302
    /// Statistics about a converted model
    303
    #[derive(Debug, Clone)]
    304
    pub struct ConversionStats {
    305
        /// Total number of parameters
    306
        pub total_parameters: usize,
    307
        /// Memory size in bytes (F32)
    308
        pub memory_bytes_f32: usize,
    309
        /// Number of transformer layers
    310
        pub num_layers: usize,
    311
        /// Hidden dimension
    312
        pub hidden_dim: usize,
    313
        /// Vocabulary size
    314
        pub vocab_size: usize,
    315
        /// Model architecture name
    316
        pub architecture: String,
    317
    }
    318
    319
    impl ConversionStats {
    320
        /// Memory size in MB
    321
        #[must_use]
    322
    7
        pub fn memory_mb(&self) -> f64 {
    323
    7
            self.memory_bytes_f32 as f64 / (1024.0 * 1024.0)
    324
    7
        }
    325
    326
        /// Memory size in GB
    327
        #[must_use]
    328
    7
        pub fn memory_gb(&self) -> f64 {
    329
    7
            self.memory_bytes_f32 as f64 / (1024.0 * 1024.0 * 1024.0)
    330
    7
        }
    331
    332
        /// Parameters in millions
    333
        #[must_use]
    334
    6
        pub fn parameters_m(&self) -> f64 {
    335
    6
            self.total_parameters as f64 / 1_000_000.0
    336
    6
        }
    337
    338
        /// Parameters in billions
    339
        #[must_use]
    340
    8
        pub fn parameters_b(&self) -> f64 {
    341
    8
            self.total_parameters as f64 / 1_000_000_000.0
    342
    8
        }
    343
    }
    344
    345
    // =============================================================================
    346
    // Q4K APR Converter (preserves GGUF quantization for GPU inference)
    347
    // =============================================================================
    348
    349
    /// Raw tensor with preserved quantization
    350
    #[derive(Debug, Clone)]
    351
    pub struct RawTensor {
    352
        /// Tensor name
    353
        pub name: String,
    354
        /// Raw bytes (Q4K super-blocks or F32/F16 data)
    355
        pub data: Vec<u8>,
    356
        /// Tensor shape (logical elements, not bytes)
    357
        pub shape: Vec<usize>,
    358
        /// GGML dtype: 0=F32, 1=F16, 6=Q4_K, 7=Q5_K, 8=Q8_0, 12=Q6_K
    359
        pub dtype: u32,
    360
    }
    361
    362
    /// GGUF to APR Q4K converter (preserves quantization)
    363
    ///
    364
    /// Unlike `GgufToAprConverter` which dequantizes to F32, this converter
    365
    /// preserves Q4K/Q6K quantization for GPU inference with batched GEMV.
    366
    ///
    367
    /// This is essential for achieving 2X Ollama performance.
    368
    pub struct GgufToAprQ4KConverter;
    369
    370
    impl GgufToAprQ4KConverter {
    371
        /// Helper to extract string from GGUF metadata
    372
    9
        fn get_string(
    373
    9
            metadata: &std::collections::HashMap<String, crate::gguf::GGUFValue>,
    374
    9
            key: &str,
    375
    9
        ) -> Option<String> {
    376
    9
            match metadata.get(key) {
    377
    3
                Some(crate::gguf::GGUFValue::String(s)) => Some(s.clone()),
    378
    6
                _ => None,
    379
            }
    380
    9
        }
    381
    382
        /// Helper to extract u32 from GGUF metadata
    383
    17
        fn get_u32(
    384
    17
            metadata: &std::collections::HashMap<String, crate::gguf::GGUFValue>,
    385
    17
            key: &str,
    386
    17
        ) -> Option<u32> {
    387
    17
            match metadata.get(key) {
    388
    3
                Some(crate::gguf::GGUFValue::UInt32(v)) => Some(*v),
    389
    4
                Some(crate::gguf::GGUFValue::Int32(v)) => Some(*v as u32),
    390
    4
                Some(crate::gguf::GGUFValue::UInt64(v)) => Some(*v as u32),
    391
    6
                _ => None,
    392
            }
    393
    17
        }
    394
    395
        /// Helper to extract f32 from GGUF metadata
    396
    13
        fn get_f32(
    397
    13
            metadata: &std::collections::HashMap<String, crate::gguf::GGUFValue>,
    398
    13
            key: &str,
    399
    13
        ) -> Option<f32> {
    400
    13
            match metadata.get(key) {
    401
    3
                Some(crate::gguf::GGUFValue::Float32(v)) => Some(*v),
    402
    4
                Some(crate::gguf::GGUFValue::Float64(v)) => Some(*v as f32),
    403
    6
                _ => None,
    404
            }
    405
    13
        }
    406
    407
        /// Convert GGUF file to APR v2 with preserved Q4K quantization
    408
        ///
    409
        /// # Arguments
    410
        ///
    411
        /// * `gguf_path` - Path to GGUF file
    412
        /// * `output_path` - Path to write APR v2 file
    413
        ///
    414
        /// # Returns
    415
        ///
    416
        /// Statistics about the conversion
    417
        #[allow(clippy::cast_possible_truncation)]
    418
    0
        pub fn convert(
    419
    0
            gguf_path: &std::path::Path,
    420
    0
            output_path: &std::path::Path,
    421
    0
        ) -> Result<Q4KConversionStats> {
    422
            use std::io::Write;
    423
    424
            // Load GGUF with raw quantized tensors
    425
    0
            let gguf_data = std::fs::read(gguf_path).map_err(|e| RealizarError::IoError {
    426
    0
                message: format!("Failed to read GGUF: {e}"),
    427
    0
            })?;
    428
    429
    0
            let gguf_model = crate::gguf::GGUFModel::from_bytes(&gguf_data)?;
    430
    431
            // Extract model config from metadata
    432
    0
            let architecture = Self::get_string(&gguf_model.metadata, "general.architecture")
    433
    0
                .unwrap_or_else(|| "unknown".to_string());
    434
    0
            let hidden_size = Self::get_u32(
    435
    0
                &gguf_model.metadata,
    436
    0
                &format!("{architecture}.embedding_length"),
    437
            )
    438
    0
            .unwrap_or(0);
    439
    0
            let num_layers =
    440
    0
                Self::get_u32(&gguf_model.metadata, &format!("{architecture}.block_count"))
    441
    0
                    .unwrap_or(0);
    442
    0
            let num_heads = Self::get_u32(
    443
    0
                &gguf_model.metadata,
    444
    0
                &format!("{architecture}.attention.head_count"),
    445
            )
    446
    0
            .unwrap_or(0);
    447
    0
            let num_kv_heads = Self::get_u32(
    448
    0
                &gguf_model.metadata,
    449
    0
                &format!("{architecture}.attention.head_count_kv"),
    450
            )
    451
    0
            .unwrap_or(num_heads);
    452
    0
            let vocab_size = Self::get_u32(&gguf_model.metadata, &format!("{architecture}.vocab_size"))
    453
    0
                .or_else(|| Self::get_u32(&gguf_model.metadata, "tokenizer.ggml.vocab_size"))
    454
    0
                .unwrap_or_else(|| {
    455
                    // Infer from embedding tensor shape if metadata not available
    456
    0
                    gguf_model
    457
    0
                        .tensors
    458
    0
                        .iter()
    459
    0
                        .find(|t| {
    460
    0
                            t.name.contains("token_embd")
    461
    0
                                || t.name.contains("embed_tokens")
    462
    0
                                || t.name.contains("tok_embeddings")
    463
    0
                        })
    464
    0
                        .and_then(|t| t.dims.first().copied().map(|d| d as u32))
    465
    0
                        .unwrap_or(0)
    466
    0
                }) as usize;
    467
    0
            let intermediate_size = Self::get_u32(
    468
    0
                &gguf_model.metadata,
    469
    0
                &format!("{architecture}.feed_forward_length"),
    470
            )
    471
    0
            .unwrap_or(0);
    472
    0
            let context_length = Self::get_u32(
    473
    0
                &gguf_model.metadata,
    474
    0
                &format!("{architecture}.context_length"),
    475
            )
    476
    0
            .unwrap_or(2048);
    477
    0
            let rope_theta = Self::get_f32(
    478
    0
                &gguf_model.metadata,
    479
    0
                &format!("{architecture}.rope.freq_base"),
    480
            )
    481
    0
            .unwrap_or(10000.0);
    482
    0
            let eps = Self::get_f32(
    483
    0
                &gguf_model.metadata,
    484
    0
                &format!("{architecture}.attention.layer_norm_rms_epsilon"),
    485
            )
    486
    0
            .unwrap_or(1e-5);
    487
    488
            // Build metadata JSON
    489
    0
            let metadata = serde_json::json!({
    490
    0
                "model_type": "transformer_lm_q4k",
    491
    0
                "architecture": architecture,
    492
    0
                "hidden_size": hidden_size,
    493
    0
                "num_layers": num_layers,
    494
    0
                "num_heads": num_heads,
    495
    0
                "num_kv_heads": num_kv_heads,
    496
    0
                "vocab_size": vocab_size,
    497
    0
                "intermediate_dim": intermediate_size,
    498
    0
                "context_length": context_length,
    499
    0
                "rope_theta": rope_theta,
    500
    0
                "eps": eps,
    501
    0
                "quantization": "Q4_K_M",
    502
            });
    503
    0
            let metadata_bytes =
    504
    0
                serde_json::to_vec(&metadata).map_err(|e| RealizarError::FormatError {
    505
    0
                    reason: format!("Failed to serialize metadata: {e}"),
    506
    0
                })?;
    507
    0
            let metadata_padded_len = metadata_bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT;
    508
    509
            // Extract raw tensors from GGUF
    510
    0
            let mut raw_tensors: Vec<RawTensor> = Vec::new();
    511
    0
            let mut q4k_count = 0usize;
    512
    0
            let mut total_bytes = 0usize;
    513
    514
    0
            for tensor_meta in &gguf_model.tensors {
    515
    0
                let name = tensor_meta.name.clone();
    516
    0
                let shape: Vec<usize> = tensor_meta.dims.iter().map(|&d| d as usize).collect();
    517
    0
                let num_elements: usize = shape.iter().product();
    518
    0
                let qtype = tensor_meta.qtype;
    519
    520
                // Calculate byte size based on qtype (GGML dtype)
    521
                // GGML types: 0=F32, 1=F16, 8=Q8_0, 12=Q4_K, 13=Q5_K, 14=Q6_K
    522
    0
                let byte_size = match qtype {
    523
    0
                    0 => num_elements * 4,            // F32
    524
    0
                    1 => num_elements * 2,            // F16
    525
    0
                    8 => (num_elements / 32) * 34,    // Q8_0: 32 elements = 2 (scale) + 32 (quants)
    526
    0
                    12 => (num_elements / 256) * 144, // Q4_K: 256 elements = 144 bytes
    527
    0
                    13 => (num_elements / 256) * 176, // Q5_K: 256 elements = 176 bytes
    528
    0
                    14 => (num_elements / 256) * 210, // Q6_K: 256 elements = 210 bytes
    529
    0
                    _ => num_elements * 4,            // Default to F32
    530
                };
    531
    532
                // Extract raw bytes
    533
    0
                let tensor_start = gguf_model.tensor_data_start + tensor_meta.offset as usize;
    534
    0
                if tensor_start + byte_size > gguf_data.len() {
    535
    0
                    return Err(RealizarError::FormatError {
    536
    0
                        reason: format!(
    537
    0
                            "Tensor '{}' exceeds file bounds (start={}, size={}, file_len={})",
    538
    0
                            name,
    539
    0
                            tensor_start,
    540
    0
                            byte_size,
    541
    0
                            gguf_data.len()
    542
    0
                        ),
    543
    0
                    });
    544
    0
                }
    545
    546
    0
                let data = gguf_data[tensor_start..tensor_start + byte_size].to_vec();
    547
    548
                // Q4_K is GGML type 12
    549
    0
                if qtype == 12 {
    550
    0
                    q4k_count += 1;
    551
    0
                }
    552
    0
                total_bytes += byte_size;
    553
    554
    0
                raw_tensors.push(RawTensor {
    555
    0
                    name,
    556
    0
                    data,
    557
    0
                    shape,
    558
    0
                    dtype: qtype,
    559
    0
                });
    560
            }
    561
    562
            // Build binary tensor index
    563
    0
            let mut tensor_index_bytes: Vec<u8> = Vec::new();
    564
    0
            let mut current_offset = 0u64;
    565
    566
    0
            for tensor in &raw_tensors {
    567
                // name_len (2 bytes) + name
    568
    0
                let name_bytes = tensor.name.as_bytes();
    569
    0
                tensor_index_bytes.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
    570
    0
                tensor_index_bytes.extend_from_slice(name_bytes);
    571
    572
                // dtype (1 byte) - map GGML dtype to APR dtype
    573
                // GGML: 0=F32, 1=F16, 8=Q8_0, 12=Q4_K, 13=Q5_K, 14=Q6_K
    574
                // APR:  0=F32, 1=F16, 8=Q4_K, 9=Q6_K, 10=Q8_0
    575
    0
                let apr_dtype = match tensor.dtype {
    576
    0
                    0 => 0u8,  // F32 -> F32
    577
    0
                    1 => 1u8,  // F16 -> F16
    578
    0
                    8 => 10u8, // Q8_0 -> APR dtype 10
    579
    0
                    12 => 8u8, // Q4_K -> APR dtype 8
    580
    0
                    13 => 8u8, // Q5_K -> treat as Q4_K for now
    581
    0
                    14 => 9u8, // Q6_K -> APR dtype 9
    582
    0
                    _ => 0u8,
    583
                };
    584
    0
                tensor_index_bytes.push(apr_dtype);
    585
    586
                // ndim (1 byte) + dims (8 bytes each)
    587
    0
                tensor_index_bytes.push(tensor.shape.len() as u8);
    588
    0
                for &dim in &tensor.shape {
    589
    0
                    tensor_index_bytes.extend_from_slice(&(dim as u64).to_le_bytes());
    590
    0
                }
    591
    592
                // offset (8 bytes)
    593
    0
                tensor_index_bytes.extend_from_slice(&current_offset.to_le_bytes());
    594
    595
                // size (8 bytes)
    596
    0
                let size = tensor.data.len() as u64;
    597
    0
                tensor_index_bytes.extend_from_slice(&size.to_le_bytes());
    598
    599
                // Align next tensor to 64 bytes
    600
    0
                current_offset += size;
    601
    0
                let aligned = current_offset.div_ceil(ALIGNMENT as u64) * ALIGNMENT as u64;
    602
    0
                current_offset = aligned;
    603
            }
    604
    605
            // Calculate offsets
    606
    0
            let metadata_offset = HEADER_SIZE as u64;
    607
    0
            let tensor_index_offset = metadata_offset + metadata_padded_len as u64;
    608
    0
            let data_offset = tensor_index_offset + tensor_index_bytes.len() as u64;
    609
            // Align data offset
    610
    0
            let data_offset_aligned = data_offset.div_ceil(ALIGNMENT as u64) * ALIGNMENT as u64;
    611
    612
            // Build header (64 bytes)
    613
    0
            let mut header = vec![0u8; HEADER_SIZE];
    614
    0
            header[0..4].copy_from_slice(&MAGIC);
    615
    0
            header[4] = 2; // version major
    616
    0
            header[5] = 0; // version minor
    617
    0
            header[6..8].copy_from_slice(&0x0020u16.to_le_bytes()); // flags: QUANTIZED=0x0020
    618
    0
            header[8..12].copy_from_slice(&(raw_tensors.len() as u32).to_le_bytes());
    619
    0
            header[12..20].copy_from_slice(&metadata_offset.to_le_bytes());
    620
    0
            header[20..24].copy_from_slice(&(metadata_bytes.len() as u32).to_le_bytes());
    621
    0
            header[24..32].copy_from_slice(&tensor_index_offset.to_le_bytes());
    622
    0
            header[32..40].copy_from_slice(&data_offset_aligned.to_le_bytes());
    623
    624
            // Write file
    625
    0
            let mut file = std::fs::File::create(output_path).map_err(|e| RealizarError::IoError {
    626
    0
                message: format!("Failed to create output file: {e}"),
    627
    0
            })?;
    628
    629
            // Header
    630
    0
            file.write_all(&header)
    631
    0
                .map_err(|e| RealizarError::IoError {
    632
    0
                    message: format!("Failed to write header: {e}"),
    633
    0
                })?;
    634
    635
            // Metadata (padded)
    636
    0
            file.write_all(&metadata_bytes)
    637
    0
                .map_err(|e| RealizarError::IoError {
    638
    0
                    message: format!("Failed to write metadata: {e}"),
    639
    0
                })?;
    640
    0
            let padding = metadata_padded_len - metadata_bytes.len();
    641
    0
            if padding > 0 {
    642
    0
                file.write_all(&vec![0u8; padding])
    643
    0
                    .map_err(|e| RealizarError::IoError {
    644
    0
                        message: format!("Failed to write padding: {e}"),
    645
    0
                    })?;
    646
    0
            }
    647
    648
            // Tensor index
    649
    0
            file.write_all(&tensor_index_bytes)
    650
    0
                .map_err(|e| RealizarError::IoError {
    651
    0
                    message: format!("Failed to write tensor index: {e}"),
    652
    0
                })?;
    653
    654
            // Alignment padding before data
    655
    0
            let pre_data_padding = (data_offset_aligned - data_offset) as usize;
    656
    0
            if pre_data_padding > 0 {
    657
    0
                file.write_all(&vec![0u8; pre_data_padding])
    658
    0
                    .map_err(|e| RealizarError::IoError {
    659
    0
                        message: format!("Failed to write data alignment: {e}"),
    660
    0
                    })?;
    661
    0
            }
    662
    663
            // Tensor data (with alignment)
    664
    0
            for tensor in &raw_tensors {
    665
    0
                file.write_all(&tensor.data)
    666
    0
                    .map_err(|e| RealizarError::IoError {
    667
    0
                        message: format!("Failed to write tensor '{}': {e}", tensor.name),
    668
    0
                    })?;
    669
    670
                // Align to 64 bytes
    671
    0
                let pad = (ALIGNMENT - (tensor.data.len() % ALIGNMENT)) % ALIGNMENT;
    672
    0
                if pad > 0 {
    673
    0
                    file.write_all(&vec![0u8; pad])
    674
    0
                        .map_err(|e| RealizarError::IoError {
    675
    0
                            message: format!("Failed to write tensor padding: {e}"),
    676
    0
                        })?;
    677
    0
                }
    678
            }
    679
    680
    0
            Ok(Q4KConversionStats {
    681
    0
                tensor_count: raw_tensors.len(),
    682
    0
                q4k_tensor_count: q4k_count,
    683
    0
                total_bytes,
    684
    0
                architecture: architecture.clone(),
    685
    0
                num_layers: num_layers as usize,
    686
    0
                hidden_size: hidden_size as usize,
    687
    0
            })
    688
    0
        }
    689
    }
    690
    691
    /// Statistics from Q4K conversion
    692
    #[derive(Debug, Clone)]
    693
    pub struct Q4KConversionStats {
    694
        /// Total number of tensors
    695
        pub tensor_count: usize,
    696
        /// Number of Q4K quantized tensors
    697
        pub q4k_tensor_count: usize,
    698
        /// Total bytes written
    699
        pub total_bytes: usize,
    700
        /// Model architecture
    701
        pub architecture: String,
    702
        /// Number of layers
    703
        pub num_layers: usize,
    704
        /// Hidden size
    705
        pub hidden_size: usize,
    706
    }
    707
    708
    // Tests extracted to tests.rs (PMAT-802)
    709
    #[cfg(test)]
    710
    #[path = "tests.rs"]
    711
    mod convert_tests;