Skip to main content

apr_format/v2/
writer.rs

1//! v2 in-memory writer + the reader struct declarations (issue #2231).
2//!
3//! Formerly `include!`d into `v2/mod.rs`; now a real module. The `AprV2Writer`
4//! struct lives here next to its `impl` (private fields). f16 conversion routes
5//! through [`crate::f16`] (IEEE round-to-nearest-even, `half` crate), NOT
6//! `trueno::f32_to_f16`.
7
8use super::{
9    align_64, AprV2Flags, AprV2Header, AprV2Metadata, ShardingMetadata, TensorDType,
10    TensorIndexEntry, V2FormatError, HEADER_SIZE_V2,
11};
12use crate::crc32::crc32;
13use crate::f16::f32_to_f16;
14use std::io::Write;
15
16/// APR v2 format writer
17#[derive(Debug)]
18pub struct AprV2Writer {
19    header: AprV2Header,
20    metadata: AprV2Metadata,
21    tensors: Vec<(TensorIndexEntry, Vec<u8>)>,
22}
23
24impl AprV2Writer {
25    /// Create new writer
26    ///
27    /// LAYOUT-002: All new APR files are created with LAYOUT_ROW_MAJOR flag set.
28    /// This ensures realizar can safely assume row-major layout for all tensors.
29    #[must_use]
30    pub fn new(metadata: AprV2Metadata) -> Self {
31        let mut header = AprV2Header::new();
32        // LAYOUT-002: Mark all new APR files as row-major
33        header.flags = header.flags.with(AprV2Flags::LAYOUT_ROW_MAJOR);
34        Self {
35            header,
36            metadata,
37            tensors: Vec::new(),
38        }
39    }
40
41    /// Add tensor to the file
42    pub fn add_tensor(
43        &mut self,
44        name: impl Into<String>,
45        dtype: TensorDType,
46        shape: Vec<usize>,
47        data: Vec<u8>,
48    ) {
49        let entry = TensorIndexEntry::new(name, dtype, shape, 0, data.len() as u64);
50        self.tensors.push((entry, data));
51    }
52
53    /// Add f32 tensor
54    pub fn add_f32_tensor(&mut self, name: impl Into<String>, shape: Vec<usize>, data: &[f32]) {
55        let bytes: Vec<u8> = data.iter().flat_map(|f| f.to_le_bytes()).collect();
56        self.add_tensor(name, TensorDType::F32, shape, bytes);
57    }
58
59    /// ALB-099: Add f32 tensor from owned Vec — pre-allocated byte conversion.
60    /// Uses capacity-hinted Vec + extend_from_slice instead of flat_map + collect.
61    pub fn add_tensor_f32_owned(
62        &mut self,
63        name: impl Into<String>,
64        shape: Vec<usize>,
65        data: Vec<f32>,
66    ) {
67        let mut bytes = Vec::with_capacity(data.len() * 4);
68        for &f in &data {
69            bytes.extend_from_slice(&f.to_le_bytes());
70        }
71        drop(data);
72        self.add_tensor(name, TensorDType::F32, shape, bytes);
73    }
74
75    /// Add f16 tensor (converts f32 → f16, 2 bytes per value)
76    ///
77    /// This provides true 2x compression over f32 storage with minimal precision loss
78    /// for inference workloads. Uses IEEE 754 half-precision format.
79    pub fn add_f16_tensor(&mut self, name: impl Into<String>, shape: Vec<usize>, data: &[f32]) {
80        let bytes: Vec<u8> = data
81            .iter()
82            .flat_map(|&f| f32_to_f16(f).to_le_bytes())
83            .collect();
84        self.add_tensor(name, TensorDType::F16, shape, bytes);
85    }
86
87    /// Add Q8 tensor (8-bit symmetric quantization)
88    ///
89    /// Format: [scale: f32 (4 bytes)] + [quantized: i8 × n]
90    /// Total size: 4 + n bytes (vs 4n for f32)
91    /// Compression ratio: ~4x
92    pub fn add_q8_tensor(&mut self, name: impl Into<String>, shape: Vec<usize>, data: &[f32]) {
93        let name = name.into();
94        if data.is_empty() {
95            self.add_tensor(name, TensorDType::AprQ8, shape, Vec::new());
96            return;
97        }
98
99        // Find scale (max absolute value)
100        let max_abs = data.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
101        let scale = if max_abs == 0.0 { 1.0 } else { max_abs / 127.0 };
102
103        // Pack: scale (4 bytes) + quantized values (1 byte each)
104        let mut bytes = Vec::with_capacity(4 + data.len());
105        bytes.extend_from_slice(&scale.to_le_bytes());
106
107        for &v in data {
108            let q = (v / scale).round().clamp(-127.0, 127.0) as i8;
109            bytes.push(q as u8);
110        }
111
112        // CONTRACT: Q8 byte count must be scale(4) + element_count(N)
113        let element_count: usize = shape.iter().product();
114        assert_eq!(
115            bytes.len(),
116            4 + element_count,
117            "Q8 CONTRACT VIOLATION: tensor '{}' packed {} bytes, expected {} (4 + {})",
118            name,
119            bytes.len(),
120            4 + element_count,
121            element_count
122        );
123
124        // F-DATA-QUALITY-001: Warn (not panic) if Q8 tensor has extremely high zero density.
125        // Global-scale Q8 legitimately produces high zero counts when re-quantizing from
126        // block-wise quantized sources (Q4K→F32→Q8). The global scale is dominated by outlier
127        // elements, causing small values to round to zero. This is a quality loss, not a bug.
128        // BUG-IMPORT-002 FIX: Changed from assert! (hard panic) to eprintln warning.
129        #[allow(clippy::naive_bytecount)]
130        if element_count >= 1024 {
131            let zero_count = bytes[4..].iter().filter(|&&b| b == 0).count();
132            let zero_pct = zero_count as f64 / element_count as f64;
133            if zero_pct > 0.995 {
134                eprintln!(
135                    "[F-DATA-QUALITY-001] WARNING: tensor '{}' Q8 has {:.1}% zeros (global-scale Q8 precision loss)",
136                    name,
137                    zero_pct * 100.0
138                );
139            }
140        }
141
142        self.add_tensor(name, TensorDType::AprQ8, shape, bytes);
143    }
144
145    /// Add Q4 tensor (4-bit symmetric quantization, block-wise)
146    ///
147    /// Format: For each block of 32 values:
148    ///   [block_scale: f16 (2 bytes)] + [packed nibbles: 16 bytes]
149    ///
150    /// Total size per block: 18 bytes (vs 128 bytes for f32)
151    /// Compression ratio: ~7x
152    pub fn add_q4_tensor(&mut self, name: impl Into<String>, shape: Vec<usize>, data: &[f32]) {
153        const BLOCK_SIZE: usize = 32;
154
155        let name = name.into();
156        if data.is_empty() {
157            self.add_tensor(name, TensorDType::AprQ4, shape, Vec::new());
158            return;
159        }
160
161        // Blocks: each block has 2-byte scale + 16 bytes of packed nibbles
162        let num_blocks = data.len().div_ceil(BLOCK_SIZE);
163        let mut bytes = Vec::with_capacity(num_blocks * 18);
164
165        for block_start in (0..data.len()).step_by(BLOCK_SIZE) {
166            let block_end = (block_start + BLOCK_SIZE).min(data.len());
167            let block = &data[block_start..block_end];
168
169            // Find block scale
170            let max_abs = block.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
171            let scale = if max_abs == 0.0 { 1.0 } else { max_abs / 7.0 };
172
173            // Store scale as f16
174            bytes.extend_from_slice(&f32_to_f16(scale).to_le_bytes());
175
176            // Quantize and pack (2 values per byte)
177            let mut packed_idx = 0;
178            let mut packed_buf = [0u8; 16];
179
180            for (i, &v) in block.iter().enumerate() {
181                // Quantize to 4-bit signed (-8 to 7)
182                let q = (v / scale).round().clamp(-8.0, 7.0) as i8;
183                // Store as unsigned nibble (0-15)
184                let nibble = ((q + 8) as u8) & 0x0F;
185
186                if i % 2 == 0 {
187                    packed_buf[packed_idx] = nibble;
188                } else {
189                    packed_buf[packed_idx] |= nibble << 4;
190                    packed_idx += 1;
191                }
192            }
193            // Note: No need to track packed_idx for odd elements since we write all 16 bytes anyway
194
195            // Write all 16 bytes (zero-padded for partial blocks)
196            bytes.extend_from_slice(&packed_buf);
197        }
198
199        // CONTRACT: Q4 byte count must be num_blocks * 18
200        let element_count: usize = shape.iter().product();
201        let expected_blocks = element_count.div_ceil(32);
202        assert_eq!(
203            bytes.len(),
204            expected_blocks * 18,
205            "Q4 CONTRACT VIOLATION: tensor '{}' packed {} bytes, expected {} ({} blocks * 18)",
206            name,
207            bytes.len(),
208            expected_blocks * 18,
209            expected_blocks
210        );
211
212        // CONTRACT: dequantized data must not be >99% zeros (F-DATA-QUALITY-001)
213        // For Q4, nibble value 8 (0x08) represents zero (signed 0 = unsigned 8).
214        // Threshold is 99% — same rationale as Q8 density check.
215        // Only enforced for large tensors (≥1024 elements).
216        if element_count >= 1024 {
217            let mut zero_nibbles = 0usize;
218            let mut total_nibbles = 0usize;
219            for block_idx in 0..num_blocks {
220                let block_offset = block_idx * 18 + 2; // skip 2-byte scale
221                let block_elem_count =
222                    BLOCK_SIZE.min(element_count.saturating_sub(block_idx * BLOCK_SIZE));
223                for i in 0..block_elem_count {
224                    let byte = bytes[block_offset + i / 2];
225                    let nibble = if i % 2 == 0 {
226                        byte & 0x0F
227                    } else {
228                        (byte >> 4) & 0x0F
229                    };
230                    if nibble == 8 {
231                        zero_nibbles += 1;
232                    }
233                    total_nibbles += 1;
234                }
235            }
236            if total_nibbles > 0 {
237                let zero_pct = zero_nibbles as f64 / total_nibbles as f64;
238                assert!(
239                    zero_pct <= 0.99,
240                    "Q4 DENSITY VIOLATION: tensor '{}' has {:.1}% zeros (threshold 99%)",
241                    name,
242                    zero_pct * 100.0
243                );
244            }
245        }
246
247        self.add_tensor(name, TensorDType::AprQ4, shape, bytes);
248    }
249
250    /// Add raw Q4_K tensor (GGUF-compatible super-block format)
251    ///
252    /// This stores GGUF Q4_K data directly without re-quantization.
253    /// Q4_K format: 256-element super-blocks with nested 32-element sub-blocks
254    /// Each super-block: d (f16, 2B) + dmin (f16, 2B) + scales (12B) + qs (128B) = 144 bytes
255    /// Effective bits per weight: ~4.5
256    ///
257    /// Use this when importing from GGUF to preserve exact quantization.
258    pub fn add_q4k_raw_tensor(
259        &mut self,
260        name: impl Into<String>,
261        shape: Vec<usize>,
262        raw_data: Vec<u8>,
263    ) {
264        self.add_tensor(name, TensorDType::Q4K, shape, raw_data);
265    }
266
267    /// Add raw Q6_K tensor (GGUF-compatible super-block format)
268    ///
269    /// This stores GGUF Q6_K data directly without re-quantization.
270    /// Q6_K format: 256-element super-blocks
271    /// Each super-block: ql (128B) + qh (64B) + scales (16B) + d (f16, 2B) = 210 bytes
272    /// Effective bits per weight: ~6.5
273    pub fn add_q6k_raw_tensor(
274        &mut self,
275        name: impl Into<String>,
276        shape: Vec<usize>,
277        raw_data: Vec<u8>,
278    ) {
279        self.add_tensor(name, TensorDType::Q6K, shape, raw_data);
280    }
281
282    /// Set LZ4 compression flag
283    pub fn with_lz4_compression(&mut self) -> &mut Self {
284        self.header.flags = self.header.flags.with(AprV2Flags::LZ4_COMPRESSED);
285        self
286    }
287
288    /// Preserve header flags from an existing APR file on round-trip.
289    ///
290    /// Used by `stamp_provenance_bytes` so that QUANTIZED / HAS_VOCAB /
291    /// HAS_MODEL_CARD / etc. set on the input are carried across — without
292    /// this, `AprV2Writer::new()` would emit an output with only
293    /// `LAYOUT_ROW_MAJOR` set and downstream consumers that branch on
294    /// `QUANTIZED` would silently see a different file. `LAYOUT_ROW_MAJOR`
295    /// is always included regardless of the passed-in value so the
296    /// LAYOUT-002 jidoka guard never disengages.
297    pub fn set_header_flags(&mut self, flags: AprV2Flags) {
298        self.header.flags = flags.with(AprV2Flags::LAYOUT_ROW_MAJOR);
299    }
300
301    /// Set sharding info
302    pub fn with_sharding(&mut self, shard_count: usize, shard_index: usize) -> &mut Self {
303        self.header.flags = self.header.flags.with(AprV2Flags::SHARDED);
304        self.metadata.sharding = Some(ShardingMetadata {
305            shard_count,
306            shard_index,
307            total_size: 0,
308            pattern: None,
309        });
310        self
311    }
312
313    /// Write to bytes
314    ///
315    /// # Errors
316    /// Returns error if serialization fails.
317    pub fn write(&mut self) -> Result<Vec<u8>, V2FormatError> {
318        // Sort tensors by name
319        self.tensors.sort_by(|a, b| a.0.name.cmp(&b.0.name));
320
321        // Serialize metadata
322        let metadata_bytes = self.metadata.to_json()?;
323        let metadata_padded_size = align_64(metadata_bytes.len());
324
325        // Build tensor index
326        let mut tensor_index_bytes = Vec::new();
327        let mut data_offset = 0_u64;
328
329        for (entry, data) in &mut self.tensors {
330            entry.offset = data_offset;
331            entry.size = data.len() as u64;
332            tensor_index_bytes.extend_from_slice(&entry.to_bytes());
333            data_offset += align_64(data.len()) as u64;
334        }
335        let tensor_index_padded_size = align_64(tensor_index_bytes.len());
336
337        // Calculate offsets
338        let metadata_offset = HEADER_SIZE_V2;
339        let tensor_index_offset = metadata_offset + metadata_padded_size;
340        let data_section_offset = tensor_index_offset + tensor_index_padded_size;
341
342        // Update header
343        self.header.tensor_count = self.tensors.len() as u32;
344        self.header.metadata_offset = metadata_offset as u64;
345        self.header.metadata_size = metadata_bytes.len() as u32;
346        self.header.tensor_index_offset = tensor_index_offset as u64;
347        self.header.data_offset = data_section_offset as u64;
348        self.header.update_checksum();
349
350        // ALB-099: Pre-allocate output with known total size
351        let total_data_size: usize = self.tensors.iter().map(|(_, d)| align_64(d.len())).sum();
352        let total_size = data_section_offset + total_data_size + 4; // +4 for footer CRC32
353        let mut output = Vec::with_capacity(total_size);
354
355        // Header
356        output.extend_from_slice(&self.header.to_bytes());
357
358        // Metadata (padded)
359        output.extend_from_slice(&metadata_bytes);
360        output.resize(metadata_offset + metadata_padded_size, 0);
361
362        // Tensor index (padded)
363        output.extend_from_slice(&tensor_index_bytes);
364        output.resize(tensor_index_offset + tensor_index_padded_size, 0);
365
366        // Tensor data (each 64-byte aligned)
367        for (_, data) in &self.tensors {
368            let start = output.len();
369            output.extend_from_slice(data);
370            let padded_size = align_64(data.len());
371            output.resize(start + padded_size, 0);
372        }
373
374        // Footer checksum
375        let footer_checksum = crc32(&output);
376        output.extend_from_slice(&footer_checksum.to_le_bytes());
377
378        Ok(output)
379    }
380
381    /// Write to a Write impl
382    ///
383    /// # Errors
384    /// Returns error if write fails.
385    pub fn write_to<W: Write>(&mut self, writer: &mut W) -> Result<(), V2FormatError> {
386        let bytes = self.write()?;
387        writer
388            .write_all(&bytes)
389            .map_err(|e| V2FormatError::IoError(e.to_string()))
390    }
391
392    /// ALB-099: Write directly to a file path — consumes writer.
393    ///
394    /// # Errors
395    /// Returns error if file creation or write fails.
396    pub fn write_into(mut self, path: impl AsRef<std::path::Path>) -> Result<(), V2FormatError> {
397        let mut file =
398            std::fs::File::create(path).map_err(|e| V2FormatError::IoError(e.to_string()))?;
399        self.write_to(&mut file)
400    }
401}
402
403// The `AprV2Reader` / `AprV2ReaderRef` struct declarations now live in
404// `reader_impl.rs` alongside their `impl` blocks (private-field access).