Skip to main content

ad_plugins_rs/
codec.rs

1use std::io::{Read, Write};
2use std::sync::Arc;
3
4use ad_core_rs::codec::{Codec, CodecName};
5use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
6use ad_core_rs::ndarray_pool::NDArrayPool;
7use ad_core_rs::plugin::runtime::{NDPluginProcess, ParamUpdate, ProcessResult};
8
9use flate2::Compression;
10use flate2::read::ZlibDecoder;
11use flate2::write::ZlibEncoder;
12use lz4_flex::block::{compress, decompress};
13use rust_hdf5::format::messages::filter::{
14    FILTER_BLOSC, Filter, FilterPipeline, apply_filters, reverse_filters,
15};
16
17/// The original (uncompressed) element type of an NDArray.
18///
19/// For an uncompressed array this is the buffer's own type. For a compressed
20/// array the typed buffer has collapsed to raw bytes (`UInt8`), so the original
21/// type is read from [`Codec::original_data_type`], which the codec plugin set
22/// on compress — mirroring C ADCore keeping it in `NDArray::dataType`
23/// (NDPluginCodec.cpp:35-36). Shared by the decompress round-trip and the
24/// NTNDArray converter, which needs it to publish `uncompressedSize` and
25/// `codec.parameters` (C `NDDataTypeToScalar[src->dataType]`,
26/// ntndArrayConverter.cpp:413-419) since a compressed array's value union no
27/// longer carries the element type.
28pub fn original_data_type(array: &NDArray) -> NDDataType {
29    match &array.codec {
30        Some(c) => c.original_data_type,
31        None => array.data.data_type(),
32    }
33}
34
35/// Reconstruct an `NDDataBuffer` from raw bytes and a target data type.
36///
37/// The byte slice is reinterpreted as the target type using native endianness.
38/// Returns `None` if the byte count is not a multiple of the element size.
39fn buffer_from_bytes(bytes: &[u8], data_type: NDDataType) -> Option<NDDataBuffer> {
40    let elem_size = data_type.element_size();
41    if bytes.len() % elem_size != 0 {
42        return None;
43    }
44    let count = bytes.len() / elem_size;
45
46    Some(match data_type {
47        NDDataType::Int8 => {
48            let mut v = vec![0i8; count];
49            // SAFETY: i8 and u8 have the same size/alignment
50            unsafe {
51                std::ptr::copy_nonoverlapping(
52                    bytes.as_ptr(),
53                    v.as_mut_ptr() as *mut u8,
54                    bytes.len(),
55                );
56            }
57            NDDataBuffer::I8(v)
58        }
59        NDDataType::UInt8 => NDDataBuffer::U8(bytes.to_vec()),
60        NDDataType::Int16 => {
61            let mut v = vec![0i16; count];
62            unsafe {
63                std::ptr::copy_nonoverlapping(
64                    bytes.as_ptr(),
65                    v.as_mut_ptr() as *mut u8,
66                    bytes.len(),
67                );
68            }
69            NDDataBuffer::I16(v)
70        }
71        NDDataType::UInt16 => {
72            let mut v = vec![0u16; count];
73            unsafe {
74                std::ptr::copy_nonoverlapping(
75                    bytes.as_ptr(),
76                    v.as_mut_ptr() as *mut u8,
77                    bytes.len(),
78                );
79            }
80            NDDataBuffer::U16(v)
81        }
82        NDDataType::Int32 => {
83            let mut v = vec![0i32; count];
84            unsafe {
85                std::ptr::copy_nonoverlapping(
86                    bytes.as_ptr(),
87                    v.as_mut_ptr() as *mut u8,
88                    bytes.len(),
89                );
90            }
91            NDDataBuffer::I32(v)
92        }
93        NDDataType::UInt32 => {
94            let mut v = vec![0u32; count];
95            unsafe {
96                std::ptr::copy_nonoverlapping(
97                    bytes.as_ptr(),
98                    v.as_mut_ptr() as *mut u8,
99                    bytes.len(),
100                );
101            }
102            NDDataBuffer::U32(v)
103        }
104        NDDataType::Int64 => {
105            let mut v = vec![0i64; count];
106            unsafe {
107                std::ptr::copy_nonoverlapping(
108                    bytes.as_ptr(),
109                    v.as_mut_ptr() as *mut u8,
110                    bytes.len(),
111                );
112            }
113            NDDataBuffer::I64(v)
114        }
115        NDDataType::UInt64 => {
116            let mut v = vec![0u64; count];
117            unsafe {
118                std::ptr::copy_nonoverlapping(
119                    bytes.as_ptr(),
120                    v.as_mut_ptr() as *mut u8,
121                    bytes.len(),
122                );
123            }
124            NDDataBuffer::U64(v)
125        }
126        NDDataType::Float32 => {
127            let mut v = vec![0f32; count];
128            unsafe {
129                std::ptr::copy_nonoverlapping(
130                    bytes.as_ptr(),
131                    v.as_mut_ptr() as *mut u8,
132                    bytes.len(),
133                );
134            }
135            NDDataBuffer::F32(v)
136        }
137        NDDataType::Float64 => {
138            let mut v = vec![0f64; count];
139            unsafe {
140                std::ptr::copy_nonoverlapping(
141                    bytes.as_ptr(),
142                    v.as_mut_ptr() as *mut u8,
143                    bytes.len(),
144                );
145            }
146            NDDataBuffer::F64(v)
147        }
148    })
149}
150
151/// Compress an NDArray using LZ4.
152///
153/// The raw bytes of the data buffer are compressed with LZ4 (block mode, size-prepended).
154/// The original data type ordinal is stored as an attribute so decompression can
155/// reconstruct the correct typed buffer.
156pub fn compress_lz4(src: &NDArray) -> NDArray {
157    let raw = src.data.as_u8_slice();
158    let original_data_type = src.data.data_type();
159    let original_size = raw.len();
160    // C++ uses raw LZ4_compress_default (no size header)
161    let compressed = compress(raw);
162    let compressed_size = compressed.len();
163
164    let mut arr = src.clone();
165    arr.data = NDDataBuffer::U8(compressed);
166    arr.codec = Some(Codec {
167        name: CodecName::LZ4,
168        compressed_size,
169        level: 0,
170        shuffle: 0,
171        compressor: 0,
172        // The original element type travels in the codec (C `NDArray::dataType`,
173        // NDPluginCodec.cpp:35-36), so decompression can rebuild the buffer.
174        original_data_type,
175    });
176
177    tracing::debug!(
178        original_size,
179        compressed_size,
180        ratio = original_size as f64 / compressed_size.max(1) as f64,
181        "LZ4 compress"
182    );
183
184    arr
185}
186
187/// Decompress an LZ4-compressed NDArray.
188///
189/// Returns `None` if the codec is not LZ4 or decompression fails.
190/// The original typed buffer is reconstructed using the stored data type attribute.
191pub fn decompress_lz4(src: &NDArray) -> Option<NDArray> {
192    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::LZ4) {
193        return None;
194    }
195    let compressed = src.data.as_u8_slice();
196    // C++ uses LZ4_decompress_fast with a known uncompressed size; the original
197    // element type travels in the codec (C `NDArray::dataType`).
198    let original_type = original_data_type(src);
199    let num_elements: usize = src.dims.iter().map(|d| d.size).product();
200    let uncompressed_size = num_elements * original_type.element_size();
201    let decompressed = decompress(compressed, uncompressed_size).ok()?;
202
203    let buffer = buffer_from_bytes(&decompressed, original_type)?;
204
205    let mut arr = src.clone();
206    arr.data = buffer;
207    arr.codec = None;
208
209    Some(arr)
210}
211
212// ---------------------------------------------------------------------------
213// Zlib (deflate) — port of the C++ NDCodec ZLIB codec
214// ---------------------------------------------------------------------------
215//
216// C++ `compressZlib`/`decompressZlib` call zlib `compress2`/`uncompress` on the
217// raw element bytes. We use `flate2`'s `ZlibEncoder`/`ZlibDecoder`, which emit
218// and parse the same zlib (RFC 1950) stream. The original data type is stored
219// as an attribute so decompression can rebuild the typed buffer.
220
221/// Default zlib compression level (mirrors `Compression::default()`, level 6).
222const ZLIB_DEFAULT_LEVEL: u32 = 6;
223
224/// Compress an NDArray using zlib (deflate).
225///
226/// Mirrors C++ `compressZlib`. The raw bytes of the data buffer are compressed
227/// with a zlib stream. The original data type ordinal is stored as an attribute
228/// so decompression can reconstruct the correct typed buffer.
229pub fn compress_zlib(src: &NDArray) -> NDArray {
230    let raw = src.data.as_u8_slice();
231    let original_data_type = src.data.data_type();
232    let original_size = raw.len();
233
234    let mut encoder = ZlibEncoder::new(Vec::<u8>::new(), Compression::new(ZLIB_DEFAULT_LEVEL));
235    // Writing to a `Vec` and finishing the stream are infallible here.
236    if encoder.write_all(raw).is_err() {
237        return src.clone();
238    }
239    let compressed = match encoder.finish() {
240        Ok(buf) => buf,
241        Err(_) => return src.clone(),
242    };
243    let compressed_size = compressed.len();
244
245    let mut arr = src.clone();
246    arr.data = NDDataBuffer::U8(compressed);
247    arr.codec = Some(Codec {
248        name: CodecName::Zlib,
249        compressed_size,
250        level: ZLIB_DEFAULT_LEVEL as i32,
251        shuffle: 0,
252        compressor: 0,
253        original_data_type,
254    });
255
256    tracing::debug!(
257        original_size,
258        compressed_size,
259        ratio = original_size as f64 / compressed_size.max(1) as f64,
260        "Zlib compress"
261    );
262    arr
263}
264
265/// Decompress a zlib-compressed NDArray.
266///
267/// Returns `None` if the codec is not Zlib or decompression fails.
268/// The original typed buffer is reconstructed using the stored data type attribute.
269pub fn decompress_zlib(src: &NDArray) -> Option<NDArray> {
270    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::Zlib) {
271        return None;
272    }
273    let compressed = src.data.as_u8_slice();
274
275    let original_type = original_data_type(src);
276    let num_elements: usize = src.dims.iter().map(|d| d.size).product();
277    let uncompressed_size = num_elements * original_type.element_size();
278
279    let mut decoder = ZlibDecoder::new(compressed);
280    let mut decompressed = Vec::with_capacity(uncompressed_size);
281    decoder.read_to_end(&mut decompressed).ok()?;
282
283    let buffer = buffer_from_bytes(&decompressed, original_type)?;
284
285    let mut arr = src.clone();
286    arr.data = buffer;
287    arr.codec = None;
288    Some(arr)
289}
290
291// ---------------------------------------------------------------------------
292// LZ4HDF5 — port of the C++ NDCodec LZ4HDF5 codec
293// ---------------------------------------------------------------------------
294//
295// C++ `compressLZ4`/`decompressLZ4` (the HAVE_BITSHUFFLE LZ4 variant) use the
296// HDF5 LZ4 filter block framing. The container layout is:
297//
298//   8 bytes  total uncompressed size  (big-endian u64)
299//   4 bytes  block size in bytes      (big-endian u32)
300//   then, per block:
301//     4 bytes  compressed block byte length (big-endian u32)
302//     LZ4-block-compressed payload
303//
304// Each block compresses up to `block_size` raw bytes with the LZ4 block codec.
305// The HDF5 LZ4 filter stores a block uncompressed when LZ4 does not shrink it;
306// the framed length then equals the raw block length, which decompression uses
307// to detect and copy the block verbatim.
308
309/// Default LZ4HDF5 block size in bytes (HDF5 LZ4 filter `DEFAULT_BLOCK_SIZE`, 1 MiB).
310const LZ4HDF5_DEFAULT_BLOCK_SIZE: usize = 1 << 20;
311
312/// Compress an NDArray with the HDF5 LZ4 filter framing (`lz4hdf5`).
313///
314/// Mirrors C++ `compressLZ4` (the HDF5 LZ4 filter variant). The raw data buffer
315/// is split into fixed-size blocks, each LZ4-block-compressed, and the HDF5 LZ4
316/// container header is prepended. The original data type is stored as an
317/// attribute so decompression can rebuild the typed buffer.
318pub fn compress_lz4hdf5(src: &NDArray) -> NDArray {
319    let raw = src.data.as_u8_slice();
320    let data_type = src.data.data_type();
321    let original_size = raw.len();
322    let block_size = LZ4HDF5_DEFAULT_BLOCK_SIZE;
323
324    // HDF5 LZ4 header: 8-byte total uncompressed size, 4-byte block size.
325    let mut out: Vec<u8> = Vec::with_capacity(original_size / 2 + 12);
326    out.extend_from_slice(&(original_size as u64).to_be_bytes());
327    out.extend_from_slice(&(block_size as u32).to_be_bytes());
328
329    let mut pos = 0usize;
330    while pos < raw.len() {
331        let n = block_size.min(raw.len() - pos);
332        let block = &raw[pos..pos + n];
333        let comp = compress(block);
334        // The HDF5 LZ4 filter stores the block uncompressed when LZ4 does not
335        // shrink it; the framed length then equals the raw block length.
336        if comp.len() < n {
337            out.extend_from_slice(&(comp.len() as u32).to_be_bytes());
338            out.extend_from_slice(&comp);
339        } else {
340            out.extend_from_slice(&(n as u32).to_be_bytes());
341            out.extend_from_slice(block);
342        }
343        pos += n;
344    }
345
346    let compressed_size = out.len();
347    let mut arr = src.clone();
348    arr.data = NDDataBuffer::U8(out);
349    arr.codec = Some(Codec {
350        name: CodecName::LZ4HDF5,
351        compressed_size,
352        level: 0,
353        shuffle: 0,
354        compressor: 0,
355        original_data_type: data_type,
356    });
357
358    tracing::debug!(
359        original_size,
360        compressed_size,
361        ratio = original_size as f64 / compressed_size.max(1) as f64,
362        "LZ4HDF5 compress"
363    );
364    arr
365}
366
367/// Decompress an LZ4HDF5-compressed NDArray.
368///
369/// Returns `None` if the codec is not LZ4HDF5 or the container is malformed.
370pub fn decompress_lz4hdf5(src: &NDArray) -> Option<NDArray> {
371    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::LZ4HDF5) {
372        return None;
373    }
374    let buf = src.data.as_u8_slice();
375    if buf.len() < 12 {
376        return None;
377    }
378    let total_bytes = u64::from_be_bytes(buf[0..8].try_into().ok()?) as usize;
379    let block_size = u32::from_be_bytes(buf[8..12].try_into().ok()?) as usize;
380    if block_size == 0 {
381        return None;
382    }
383
384    let original_type = original_data_type(src);
385
386    let mut out: Vec<u8> = Vec::with_capacity(total_bytes);
387    let mut pos = 12usize;
388    while out.len() < total_bytes {
389        let n = block_size.min(total_bytes - out.len());
390        if pos + 4 > buf.len() {
391            return None;
392        }
393        let clen = u32::from_be_bytes(buf[pos..pos + 4].try_into().ok()?) as usize;
394        pos += 4;
395        if pos + clen > buf.len() {
396            return None;
397        }
398        let block_payload = &buf[pos..pos + clen];
399        if clen == n {
400            // Block was stored uncompressed (LZ4 did not shrink it).
401            out.extend_from_slice(block_payload);
402        } else {
403            let block = decompress(block_payload, n).ok()?;
404            if block.len() != n {
405                return None;
406            }
407            out.extend_from_slice(&block);
408        }
409        pos += clen;
410    }
411    if out.len() != total_bytes {
412        return None;
413    }
414
415    let buffer = buffer_from_bytes(&out, original_type)?;
416    let mut arr = src.clone();
417    arr.data = buffer;
418    arr.codec = None;
419    Some(arr)
420}
421
422// ---------------------------------------------------------------------------
423// Bitshuffle / LZ4 (bslz4) — port of the C++ NDCodec BSLZ4 codec
424// ---------------------------------------------------------------------------
425//
426// C++ `compressBSLZ4`/`decompressBSLZ4` call `bshuf_compress_lz4` /
427// `bshuf_decompress_lz4` from the Bitshuffle library. We reproduce both the
428// bitshuffle bit-transpose and the bslz4 container format here so the output
429// is byte-compatible with the HDF5 `bslz4` filter:
430//
431//   8 bytes  total uncompressed size  (big-endian u64)
432//   4 bytes  block size in elements   (big-endian u32)
433//   then, per block:
434//     4 bytes  compressed block byte length (big-endian u32)
435//     LZ4-block-compressed, bit-shuffled block payload
436//
437// Bitshuffle transposes the *bit* matrix of a block: a block of `n` elements
438// of `elem_size` bytes is viewed as an `n` x `(elem_size*8)` bit matrix and
439// transposed to `(elem_size*8)` x `n`. Bitshuffle requires the per-block
440// element count to be a multiple of 8 for the bit transpose; a trailing
441// partial block is byte-transposed only (this matches the reference library).
442
443/// Bitshuffle target block size in bytes (library `BSHUF_TARGET_BLOCK_SIZE_B`).
444const BSHUF_TARGET_BLOCK_SIZE_B: usize = 8192;
445/// Block element count must be a multiple of this (`BSHUF_BLOCKED_MULT`).
446const BSHUF_BLOCKED_MULT: usize = 8;
447/// Recommended minimum block size in elements (`BSHUF_MIN_RECOMMEND_BLOCK`).
448const BSHUF_MIN_RECOMMEND_BLOCK: usize = 128;
449
450/// Default bitshuffle block size in elements for a given element size.
451///
452/// Mirrors `bshuf_default_block_size` (bitshuffle_core.c:2009): `TARGET /
453/// elem_size` rounded down to a multiple of `BSHUF_BLOCKED_MULT`, floored at
454/// `BSHUF_MIN_RECOMMEND_BLOCK`. This value must stay stable across versions or
455/// previously-encoded streams become undecodable.
456pub(crate) fn bshuf_default_block_size(elem_size: usize) -> usize {
457    let bs = BSHUF_TARGET_BLOCK_SIZE_B / elem_size.max(1);
458    let bs = (bs / BSHUF_BLOCKED_MULT) * BSHUF_BLOCKED_MULT;
459    bs.max(BSHUF_MIN_RECOMMEND_BLOCK)
460}
461
462/// 8x8 bit-matrix transpose of a quadword, little-endian convention
463/// (library macro `TRANS_BIT_8X8`, bitshuffle_core.c:110).
464#[inline]
465fn trans_bit_8x8(mut x: u64) -> u64 {
466    let t = (x ^ (x >> 7)) & 0x00AA_00AA_00AA_00AA;
467    x = x ^ t ^ (t << 7);
468    let t = (x ^ (x >> 14)) & 0x0000_CCCC_0000_CCCC;
469    x = x ^ t ^ (t << 14);
470    let t = (x ^ (x >> 28)) & 0x0000_0000_F0F0_F0F0;
471    x = x ^ t ^ (t << 28);
472    x
473}
474
475/// Read 8 bytes at `off` as a little-endian quadword.
476#[inline]
477fn read_u64_le(b: &[u8], off: usize) -> u64 {
478    u64::from_le_bytes(b[off..off + 8].try_into().unwrap())
479}
480
481/// Transpose bytes within elements (library `bshuf_trans_byte_elem_scal`,
482/// bitshuffle_core.c:166). `size` is a multiple of 8 for every shuffled block.
483fn bshuf_trans_byte_elem(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
484    let mut ii = 0;
485    while ii + 7 < size {
486        for jj in 0..elem_size {
487            for kk in 0..8 {
488                out[jj * size + ii + kk] = input[ii * elem_size + kk * elem_size + jj];
489            }
490        }
491        ii += 8;
492    }
493    // Remainder (size % 8); never taken for a shuffled block but kept faithful.
494    let mut ii = size - size % 8;
495    while ii < size {
496        for jj in 0..elem_size {
497            out[jj * size + ii] = input[ii * elem_size + jj];
498        }
499        ii += 1;
500    }
501}
502
503/// Transpose bits within bytes (library `bshuf_trans_bit_byte_scal`,
504/// bitshuffle_core.c:205, little-endian path).
505fn bshuf_trans_bit_byte(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
506    let nbyte = elem_size * size;
507    let nbyte_bitrow = nbyte / 8;
508    for ii in 0..nbyte_bitrow {
509        let mut x = trans_bit_8x8(read_u64_le(input, ii * 8));
510        for kk in 0..8 {
511            out[kk * nbyte_bitrow + ii] = x as u8;
512            x >>= 8;
513        }
514    }
515}
516
517/// Transpose rows of shuffled bits within groups of eight (library
518/// `bshuf_trans_bitrow_eight` -> `bshuf_trans_elem`, lda=8, ldb=elem_size).
519fn bshuf_trans_bitrow_eight(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
520    let nbyte_bitrow = size / 8;
521    for ii in 0..8 {
522        for jj in 0..elem_size {
523            let src = (ii * elem_size + jj) * nbyte_bitrow;
524            let dst = (jj * 8 + ii) * nbyte_bitrow;
525            out[dst..dst + nbyte_bitrow].copy_from_slice(&input[src..src + nbyte_bitrow]);
526        }
527    }
528}
529
530/// Bit-transpose one block of `size` elements (a multiple of 8) — library
531/// `bshuf_trans_bit_elem_scal` (bitshuffle_core.c:280): byte transpose, then
532/// bit-within-byte transpose, then bit-row transpose.
533fn bshuf_trans_bit_elem(input: &[u8], size: usize, elem_size: usize) -> Vec<u8> {
534    debug_assert_eq!(size % 8, 0);
535    let nbyte = size * elem_size;
536    let mut a = vec![0u8; nbyte];
537    bshuf_trans_byte_elem(input, &mut a, size, elem_size);
538    let mut b = vec![0u8; nbyte];
539    bshuf_trans_bit_byte(&a, &mut b, size, elem_size);
540    let mut out = vec![0u8; nbyte];
541    bshuf_trans_bitrow_eight(&b, &mut out, size, elem_size);
542    out
543}
544
545/// Transpose bytes for data organized as one row per bit (library
546/// `bshuf_trans_byte_bitrow_scal`, bitshuffle_core.c:306).
547fn bshuf_trans_byte_bitrow(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
548    let nbyte_row = size / 8;
549    for jj in 0..elem_size {
550        for ii in 0..nbyte_row {
551            for kk in 0..8 {
552                out[ii * 8 * elem_size + jj * 8 + kk] = input[(jj * 8 + kk) * nbyte_row + ii];
553            }
554        }
555    }
556}
557
558/// Shuffle bits within the bytes of eight-element groups (library
559/// `bshuf_shuffle_bit_eightelem_scal`, bitshuffle_core.c:331, LE path).
560fn bshuf_shuffle_bit_eightelem(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
561    let nbyte = elem_size * size;
562    let mut jj = 0;
563    while jj < 8 * elem_size {
564        let mut ii = 0;
565        while ii + 8 * elem_size - 1 < nbyte {
566            let mut x = trans_bit_8x8(read_u64_le(input, ii + jj));
567            for kk in 0..8 {
568                out[ii + jj / 8 + kk * elem_size] = x as u8;
569                x >>= 8;
570            }
571            ii += 8 * elem_size;
572        }
573        jj += 8;
574    }
575}
576
577/// Inverse of [`bshuf_trans_bit_elem`] — library `bshuf_untrans_bit_elem_scal`
578/// (bitshuffle_core.c:373).
579fn bshuf_untrans_bit_elem(input: &[u8], size: usize, elem_size: usize) -> Vec<u8> {
580    debug_assert_eq!(size % 8, 0);
581    let nbyte = size * elem_size;
582    let mut tmp = vec![0u8; nbyte];
583    bshuf_trans_byte_bitrow(input, &mut tmp, size, elem_size);
584    let mut out = vec![0u8; nbyte];
585    bshuf_shuffle_bit_eightelem(&tmp, &mut out, size, elem_size);
586    out
587}
588
589/// Bit-transpose and LZ4-block-compress one block, framed `[u32 nbytes_BE][lz4]`
590/// (library `bshuf_compress_lz4_block`, bitshuffle.c:34). `size` is a multiple
591/// of 8.
592fn bshuf_compress_lz4_block(
593    out: &mut Vec<u8>,
594    raw: &[u8],
595    elem_start: usize,
596    size: usize,
597    elem_size: usize,
598) {
599    let off = elem_start * elem_size;
600    let shuffled = bshuf_trans_bit_elem(&raw[off..off + size * elem_size], size, elem_size);
601    let comp = compress(&shuffled);
602    out.extend_from_slice(&(comp.len() as u32).to_be_bytes());
603    out.extend_from_slice(&comp);
604}
605
606/// Read one `[u32 nbytes_BE][lz4]` frame at `pos`, LZ4-decode and bit-untranspose
607/// it (library `bshuf_decompress_lz4_block`, bitshuffle.c:82). Returns the
608/// unshuffled block bytes and the buffer offset past the frame.
609fn bshuf_decompress_lz4_block(
610    buf: &[u8],
611    pos: usize,
612    size: usize,
613    elem_size: usize,
614) -> Option<(Vec<u8>, usize)> {
615    if pos + 4 > buf.len() {
616        return None;
617    }
618    let clen = u32::from_be_bytes(buf[pos..pos + 4].try_into().ok()?) as usize;
619    let dstart = pos + 4;
620    if dstart + clen > buf.len() {
621        return None;
622    }
623    let shuffled = decompress(&buf[dstart..dstart + clen], size * elem_size).ok()?;
624    if shuffled.len() != size * elem_size {
625        return None;
626    }
627    Some((
628        bshuf_untrans_bit_elem(&shuffled, size, elem_size),
629        dstart + clen,
630    ))
631}
632
633/// Compress an NDArray with the Bitshuffle + LZ4 (`bslz4`) codec.
634///
635/// Produces the per-block stream exactly as the bitshuffle library's
636/// `bshuf_compress_lz4` emits it (bitshuffle.c:237, blocked via
637/// `bshuf_blocked_wrap_fun`, bitshuffle_core.c:1852): every full block plus one
638/// trailing partial block (the remainder rounded down to a multiple of 8) is
639/// bit-transposed, LZ4-block-compressed and framed `[u32 nbytes_BE][lz4]`; the
640/// final `size % 8` elements are copied verbatim. There is NO global
641/// `[total][block_bytes]` header — that HDF5-chunk framing is added by the file
642/// writer (NDFileHDF5Dataset::writeFile), so this payload matches C
643/// `pArray->pData`. The original element type is recorded in the codec so
644/// decompression can rebuild the typed buffer and derive the element count.
645pub fn compress_bslz4(src: &NDArray) -> NDArray {
646    let raw = src.data.as_u8_slice();
647    let data_type = src.data.data_type();
648    let elem_size = data_type.element_size();
649    let total_elems = if elem_size > 0 {
650        raw.len() / elem_size
651    } else {
652        0
653    };
654    let block_size = bshuf_default_block_size(elem_size);
655
656    let mut out: Vec<u8> = Vec::with_capacity(raw.len() / 2 + 16);
657
658    let n_full = total_elems / block_size;
659    let mut elem = 0usize;
660    for _ in 0..n_full {
661        bshuf_compress_lz4_block(&mut out, raw, elem, block_size, elem_size);
662        elem += block_size;
663    }
664    // One trailing partial block, rounded down to a multiple of 8.
665    let mut last_block = total_elems % block_size;
666    last_block -= last_block % BSHUF_BLOCKED_MULT;
667    if last_block > 0 {
668        bshuf_compress_lz4_block(&mut out, raw, elem, last_block, elem_size);
669        elem += last_block;
670    }
671    // The final `size % 8` elements are copied raw (no shuffle, no frame).
672    if elem < total_elems {
673        out.extend_from_slice(&raw[elem * elem_size..total_elems * elem_size]);
674    }
675
676    let compressed_size = out.len();
677    let mut arr = src.clone();
678    arr.data = NDDataBuffer::U8(out);
679    arr.codec = Some(Codec {
680        name: CodecName::BSLZ4,
681        compressed_size,
682        level: 0,
683        shuffle: 0,
684        compressor: 0,
685        original_data_type: data_type,
686    });
687
688    tracing::debug!(
689        original_size = raw.len(),
690        compressed_size,
691        ratio = raw.len() as f64 / compressed_size.max(1) as f64,
692        "BSLZ4 compress"
693    );
694    arr
695}
696
697/// Decompress a Bitshuffle + LZ4 (`bslz4`) NDArray.
698///
699/// Inverse of [`compress_bslz4`], mirroring `bshuf_decompress_lz4`
700/// (bitshuffle.c:244). The uncompressed element count comes from the preserved
701/// array dims (matching C, which passes `nElements` from the NDArray, not from
702/// the payload), so the codec buffer carries no global header. Returns `None`
703/// if the codec is not BSLZ4 or the stream is malformed.
704pub fn decompress_bslz4(src: &NDArray) -> Option<NDArray> {
705    let codec = src.codec.as_ref()?;
706    if codec.name != CodecName::BSLZ4 {
707        return None;
708    }
709    let buf = src.data.as_u8_slice();
710    let original_type = original_data_type(src);
711    let elem_size = original_type.element_size();
712    if elem_size == 0 {
713        return None;
714    }
715    let total_elems: usize = src.dims.iter().map(|d| d.size).product();
716    let total_bytes = total_elems * elem_size;
717    let block_size = bshuf_default_block_size(elem_size);
718
719    let mut out: Vec<u8> = Vec::with_capacity(total_bytes);
720    let mut pos = 0usize;
721
722    let n_full = total_elems / block_size;
723    for _ in 0..n_full {
724        let (block, next) = bshuf_decompress_lz4_block(buf, pos, block_size, elem_size)?;
725        out.extend_from_slice(&block);
726        pos = next;
727    }
728    // One trailing partial block, rounded down to a multiple of 8.
729    let mut last_block = total_elems % block_size;
730    last_block -= last_block % BSHUF_BLOCKED_MULT;
731    if last_block > 0 {
732        let (block, next) = bshuf_decompress_lz4_block(buf, pos, last_block, elem_size)?;
733        out.extend_from_slice(&block);
734        pos = next;
735    }
736    // The final `size % 8` elements were copied raw.
737    let leftover_bytes = (total_elems % BSHUF_BLOCKED_MULT) * elem_size;
738    if leftover_bytes > 0 {
739        if pos + leftover_bytes > buf.len() {
740            return None;
741        }
742        out.extend_from_slice(&buf[pos..pos + leftover_bytes]);
743    }
744    if out.len() != total_bytes {
745        return None;
746    }
747
748    let buffer = buffer_from_bytes(&out, original_type)?;
749    let mut arr = src.clone();
750    arr.data = buffer;
751    arr.codec = None;
752    Some(arr)
753}
754
755/// Compress an NDArray to JPEG.
756///
757/// Only supports UInt8 data. Handles:
758/// - 2D arrays (mono/grayscale)
759/// - 3D arrays with dims\[0\]=3 (RGB1 interleaved)
760///
761/// Returns `None` if the data type is not UInt8 or the layout is unsupported.
762pub fn compress_jpeg(src: &NDArray, quality: u8) -> Option<NDArray> {
763    if src.data.data_type() != NDDataType::UInt8 {
764        return None;
765    }
766
767    let raw = src.data.as_u8_slice();
768    let info = src.info();
769
770    // JPEG dimensions must fit in u16
771    if info.x_size > u16::MAX as usize || info.y_size > u16::MAX as usize {
772        return None;
773    }
774
775    let (width, height, color_type) = match src.dims.len() {
776        2 => {
777            // Mono: dims = [x, y]
778            (
779                info.x_size as u16,
780                info.y_size as u16,
781                jpeg_encoder::ColorType::Luma,
782            )
783        }
784        3 if src.dims[0].size == 3 => {
785            // RGB1: dims = [3, x, y], pixel-interleaved
786            (
787                info.x_size as u16,
788                info.y_size as u16,
789                jpeg_encoder::ColorType::Rgb,
790            )
791        }
792        _ => return None,
793    };
794
795    let mut jpeg_buf = Vec::new();
796    let encoder = jpeg_encoder::Encoder::new(&mut jpeg_buf, quality);
797    if encoder.encode(raw, width, height, color_type).is_err() {
798        return None;
799    }
800
801    let compressed_size = jpeg_buf.len();
802    let original_size = raw.len();
803
804    let mut arr = src.clone();
805    arr.data = NDDataBuffer::U8(jpeg_buf);
806    arr.codec = Some(Codec {
807        name: CodecName::JPEG,
808        compressed_size,
809        level: 0,
810        shuffle: 0,
811        compressor: 0,
812        // JPEG input is constrained to UInt8 above; record the source type so
813        // the codec carries the original element type uniformly (C
814        // `NDArray::dataType`, NDPluginCodec.cpp:35-36).
815        original_data_type: src.data.data_type(),
816    });
817
818    tracing::debug!(
819        original_size,
820        compressed_size,
821        ratio = original_size as f64 / compressed_size.max(1) as f64,
822        "JPEG compress (quality={})",
823        quality,
824    );
825
826    Some(arr)
827}
828
829/// Decompress a JPEG-compressed NDArray.
830///
831/// Uses jpeg-decoder to decode the JPEG data back to pixel data.
832/// Reconstructs proper dimensions and color layout (mono or RGB1).
833///
834/// Returns `None` if the codec is not JPEG or decoding fails.
835pub fn decompress_jpeg(src: &NDArray) -> Option<NDArray> {
836    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::JPEG) {
837        return None;
838    }
839
840    let compressed = src.data.as_u8_slice();
841    let mut decoder = jpeg_decoder::Decoder::new(compressed);
842    let pixels = decoder.decode().ok()?;
843    let metadata = decoder.info()?;
844
845    let width = metadata.width as usize;
846    let height = metadata.height as usize;
847
848    let dims = match metadata.pixel_format {
849        jpeg_decoder::PixelFormat::L8 => {
850            // Grayscale
851            vec![NDDimension::new(width), NDDimension::new(height)]
852        }
853        jpeg_decoder::PixelFormat::RGB24 => {
854            // RGB1 interleaved
855            vec![
856                NDDimension::new(3),
857                NDDimension::new(width),
858                NDDimension::new(height),
859            ]
860        }
861        _ => return None,
862    };
863
864    let mut arr = src.clone();
865    arr.dims = dims;
866    arr.data = NDDataBuffer::U8(pixels);
867    arr.codec = None;
868
869    Some(arr)
870}
871
872/// Blosc compression settings.
873#[derive(Debug, Clone, Copy)]
874pub struct BloscConfig {
875    /// Sub-compressor: 0=BloscLZ, 1=LZ4, 2=LZ4HC, 3=Snappy, 4=Zlib, 5=Zstd
876    pub compressor: u32,
877    /// Compression level (0-9).
878    pub clevel: u32,
879    /// Shuffle mode: 0=None, 1=ByteShuffle, 2=BitShuffle.
880    pub shuffle: u32,
881}
882
883impl Default for BloscConfig {
884    fn default() -> Self {
885        Self {
886            compressor: 0,
887            // C NDPluginCodec sets the default NDCodecBloscCLevel to 5
888            // (NDPluginCodec.cpp:894); a lower default would yield different
889            // compressed bytes and NDCompressedSize than C for an unconfigured
890            // plugin.
891            clevel: 5,
892            shuffle: 0,
893        }
894    }
895}
896
897/// Compress an NDArray using Blosc via rust-hdf5's filter pipeline.
898pub fn compress_blosc(src: &NDArray, config: &BloscConfig) -> NDArray {
899    let raw = src.data.as_u8_slice();
900    let element_size = src.data.data_type().element_size();
901
902    // Standard H5Zblosc cd_values layout (c-blosc `blosc_filter.c`):
903    // [filter_ver, blosc_ver, typesize, nbytes, clevel, shuffle, compcode].
904    // The HDF5 reader keys on typesize@2, doshuffle@5 and compcode@6; placing
905    // the sub-compressor anywhere but index 6 makes the pipeline compress with
906    // the wrong codec (e.g. clevel 5 at slot 6 selects ZSTD instead of the
907    // configured BloscLZ).
908    let pipeline = FilterPipeline {
909        filters: vec![Filter {
910            id: FILTER_BLOSC,
911            flags: 0,
912            cd_values: vec![
913                2,                   // filter version (cd_values[0])
914                2,                   // blosc version (cd_values[1])
915                element_size as u32, // type size (cd_values[2])
916                raw.len() as u32,    // uncompressed chunk size (cd_values[3])
917                config.clevel,       // compression level (cd_values[4])
918                config.shuffle,      // shuffle (cd_values[5])
919                config.compressor,   // sub-compressor (cd_values[6])
920            ],
921        }],
922    };
923
924    let compressed = match apply_filters(&pipeline, raw) {
925        Ok(data) => data,
926        Err(_) => return src.clone(),
927    };
928
929    let compressed_size = compressed.len();
930    let original_data_type = src.data.data_type();
931    let mut arr = src.clone();
932    arr.data = NDDataBuffer::U8(compressed);
933    arr.codec = Some(Codec {
934        name: CodecName::Blosc,
935        compressed_size,
936        // C records the real Blosc params in the codec (NDPluginCodec.cpp:
937        // 400-402: codec.level = clevel; shuffle; compressor), not zeros.
938        level: config.clevel as i32,
939        shuffle: config.shuffle as i32,
940        compressor: config.compressor as i32,
941        original_data_type,
942    });
943    arr
944}
945
946/// Decompress a Blosc-compressed NDArray via rust-hdf5's filter pipeline.
947pub fn decompress_blosc(src: &NDArray) -> Option<NDArray> {
948    let codec = src.codec.as_ref()?;
949    if codec.name != CodecName::Blosc {
950        return None;
951    }
952
953    let compressed = src.data.as_u8_slice();
954    let original_type = original_data_type(src);
955    let element_size = original_type.element_size();
956
957    // The blosc chunk header self-describes typesize/nbytes/flags, but the HDF5
958    // reader takes the sub-compressor from cd_values[6] (defaulting to LZ4). An
959    // empty cd_values therefore mis-decodes any non-LZ4 buffer, so author the
960    // standard layout with the codec's recorded sub-compressor at index 6.
961    let pipeline = FilterPipeline {
962        filters: vec![Filter {
963            id: FILTER_BLOSC,
964            flags: 0,
965            cd_values: vec![
966                2,
967                2,
968                element_size as u32,
969                0,
970                codec.level as u32,
971                codec.shuffle as u32,
972                codec.compressor as u32,
973            ],
974        }],
975    };
976
977    let decompressed = reverse_filters(&pipeline, compressed).ok()?;
978
979    let buffer = buffer_from_bytes(&decompressed, original_type)?;
980
981    let mut arr = src.clone();
982    arr.data = buffer;
983    arr.codec = None;
984    Some(arr)
985}
986
987/// Codec operation mode.
988#[derive(Debug, Clone, Copy, PartialEq, Eq)]
989pub enum CodecMode {
990    /// Compress using the specified codec. `quality` is used for JPEG (1-100).
991    Compress { codec: CodecName, quality: u8 },
992    /// Decompress: auto-detect codec from the array's codec field.
993    Decompress,
994}
995
996/// Pure codec processing logic.
997///
998/// Reports compression ratio after each operation via `compression_ratio()`.
999#[derive(Default)]
1000struct CodecParamIndices {
1001    mode: Option<usize>,
1002    compressor: Option<usize>,
1003    comp_factor: Option<usize>,
1004    jpeg_quality: Option<usize>,
1005    blosc_compressor: Option<usize>,
1006    blosc_clevel: Option<usize>,
1007    blosc_shuffle: Option<usize>,
1008    blosc_numthreads: Option<usize>,
1009    codec_status: Option<usize>,
1010    codec_error: Option<usize>,
1011}
1012
1013pub struct CodecProcessor {
1014    mode: CodecMode,
1015    compression_ratio: f64,
1016    jpeg_quality: u8,
1017    blosc_config: BloscConfig,
1018    params: CodecParamIndices,
1019}
1020
1021impl CodecProcessor {
1022    pub fn new(mode: CodecMode) -> Self {
1023        let quality = match mode {
1024            CodecMode::Compress { quality, .. } => quality,
1025            _ => 85,
1026        };
1027        Self {
1028            mode,
1029            compression_ratio: 1.0,
1030            jpeg_quality: quality,
1031            blosc_config: BloscConfig::default(),
1032            params: CodecParamIndices::default(),
1033        }
1034    }
1035
1036    /// Last computed compression ratio (original_size / compressed_size).
1037    /// Returns 1.0 if no compression has been performed yet or on decompression.
1038    pub fn compression_ratio(&self) -> f64 {
1039        self.compression_ratio
1040    }
1041}
1042
1043impl NDPluginProcess for CodecProcessor {
1044    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
1045        let original_bytes = array.data.as_u8_slice().len();
1046
1047        let result = match self.mode {
1048            CodecMode::Compress { .. } if array.codec.is_some() => {
1049                // Already compressed — pass through unchanged
1050                Some(array.clone())
1051            }
1052            CodecMode::Compress {
1053                codec: CodecName::LZ4,
1054                ..
1055            } => Some(compress_lz4(array)),
1056            CodecMode::Compress {
1057                codec: CodecName::JPEG,
1058                ..
1059            } => compress_jpeg(array, self.jpeg_quality),
1060            CodecMode::Compress {
1061                codec: CodecName::Zlib,
1062                ..
1063            } => Some(compress_zlib(array)),
1064            CodecMode::Compress {
1065                codec: CodecName::Blosc,
1066                ..
1067            } => Some(compress_blosc(array, &self.blosc_config)),
1068            CodecMode::Compress {
1069                codec: CodecName::LZ4HDF5,
1070                ..
1071            } => Some(compress_lz4hdf5(array)),
1072            CodecMode::Compress {
1073                codec: CodecName::BSLZ4,
1074                ..
1075            } => Some(compress_bslz4(array)),
1076            CodecMode::Compress { .. } => None,
1077            CodecMode::Decompress => match array.codec.as_ref().map(|c| c.name) {
1078                Some(CodecName::LZ4) => decompress_lz4(array),
1079                Some(CodecName::JPEG) => decompress_jpeg(array),
1080                Some(CodecName::Zlib) => decompress_zlib(array),
1081                Some(CodecName::Blosc) => decompress_blosc(array),
1082                Some(CodecName::LZ4HDF5) => decompress_lz4hdf5(array),
1083                Some(CodecName::BSLZ4) => decompress_bslz4(array),
1084                _ => None,
1085            },
1086        };
1087
1088        let mut updates = Vec::new();
1089
1090        match result {
1091            Some(ref out) => {
1092                let output_bytes = out.data.as_u8_slice().len();
1093                match self.mode {
1094                    CodecMode::Compress { .. } => {
1095                        self.compression_ratio = original_bytes as f64 / output_bytes.max(1) as f64;
1096                    }
1097                    CodecMode::Decompress => {
1098                        self.compression_ratio = output_bytes as f64 / original_bytes.max(1) as f64;
1099                    }
1100                }
1101                if let Some(idx) = self.params.comp_factor {
1102                    updates.push(ParamUpdate::float64(idx, self.compression_ratio));
1103                }
1104                if let Some(idx) = self.params.codec_status {
1105                    updates.push(ParamUpdate::int32(idx, 0)); // Success
1106                }
1107                if let Some(idx) = self.params.codec_error {
1108                    updates.push(ParamUpdate::Octet {
1109                        reason: idx,
1110                        addr: 0,
1111                        value: String::new(),
1112                    });
1113                }
1114                let mut r = ProcessResult::arrays(vec![Arc::new(out.clone())]);
1115                r.param_updates = updates;
1116                r
1117            }
1118            None => {
1119                // C++: on failure, pass through the original array unchanged
1120                self.compression_ratio = 1.0;
1121                if let Some(idx) = self.params.comp_factor {
1122                    updates.push(ParamUpdate::float64(idx, 1.0));
1123                }
1124                if let Some(idx) = self.params.codec_status {
1125                    updates.push(ParamUpdate::int32(idx, 1)); // Error
1126                }
1127                if let Some(idx) = self.params.codec_error {
1128                    updates.push(ParamUpdate::Octet {
1129                        reason: idx,
1130                        addr: 0,
1131                        value: "codec operation failed or unsupported".to_string(),
1132                    });
1133                }
1134                let mut r = ProcessResult::arrays(vec![Arc::new(array.clone())]);
1135                r.param_updates = updates;
1136                r
1137            }
1138        }
1139    }
1140
1141    fn plugin_type(&self) -> &str {
1142        "NDPluginCodec"
1143    }
1144
1145    /// C `NDPluginCodec` passes `compressionAware=true` to the base constructor
1146    /// (`NDPluginCodec.cpp:865-870`), unconditionally regardless of mode, so
1147    /// compressed arrays reach it for decompression. Without this override the
1148    /// runtime drop gate (`if compressed && !compression_aware`) would discard
1149    /// every compressed input before `process_array`, making `Decompress` dead.
1150    /// Returned unconditionally because the same instance can switch
1151    /// Compress↔Decompress at runtime, while this flag is read once at
1152    /// construction.
1153    fn compression_aware(&self) -> bool {
1154        true
1155    }
1156
1157    fn register_params(
1158        &mut self,
1159        base: &mut asyn_rs::port::PortDriverBase,
1160    ) -> asyn_rs::error::AsynResult<()> {
1161        use asyn_rs::param::ParamType;
1162        base.create_param("MODE", ParamType::Int32)?;
1163        base.create_param("COMPRESSOR", ParamType::Int32)?;
1164        base.create_param("COMP_FACTOR", ParamType::Float64)?;
1165        base.create_param("JPEG_QUALITY", ParamType::Int32)?;
1166        base.create_param("BLOSC_COMPRESSOR", ParamType::Int32)?;
1167        base.create_param("BLOSC_CLEVEL", ParamType::Int32)?;
1168        base.create_param("BLOSC_SHUFFLE", ParamType::Int32)?;
1169        base.create_param("BLOSC_NUMTHREADS", ParamType::Int32)?;
1170        base.create_param("CODEC_STATUS", ParamType::Int32)?;
1171        base.create_param("CODEC_ERROR", ParamType::Octet)?;
1172
1173        self.params.mode = base.find_param("MODE");
1174        self.params.compressor = base.find_param("COMPRESSOR");
1175        self.params.comp_factor = base.find_param("COMP_FACTOR");
1176        self.params.jpeg_quality = base.find_param("JPEG_QUALITY");
1177        self.params.blosc_compressor = base.find_param("BLOSC_COMPRESSOR");
1178        self.params.blosc_clevel = base.find_param("BLOSC_CLEVEL");
1179        self.params.blosc_shuffle = base.find_param("BLOSC_SHUFFLE");
1180        self.params.blosc_numthreads = base.find_param("BLOSC_NUMTHREADS");
1181        self.params.codec_status = base.find_param("CODEC_STATUS");
1182        self.params.codec_error = base.find_param("CODEC_ERROR");
1183        Ok(())
1184    }
1185
1186    fn on_param_change(
1187        &mut self,
1188        reason: usize,
1189        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
1190    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
1191        if Some(reason) == self.params.mode {
1192            let v = params.value.as_i32();
1193            if v == 0 {
1194                // Compress — keep current codec
1195                let codec = match self.mode {
1196                    CodecMode::Compress { codec, .. } => codec,
1197                    _ => CodecName::LZ4,
1198                };
1199                self.mode = CodecMode::Compress {
1200                    codec,
1201                    quality: self.jpeg_quality,
1202                };
1203            } else {
1204                self.mode = CodecMode::Decompress;
1205            }
1206        } else if Some(reason) == self.params.compressor {
1207            // C `NDCodecCompressor_t` (Codec.h:12-18): NONE=0, JPEG=1,
1208            // BLOSC=2, LZ4=3, BSLZ4=4. The Rust-only zlib/lz4hdf5 codecs
1209            // (ADP-26 sign-off) take ordinals after the C set so they never
1210            // shadow a C ordinal — COMPRESSOR=2 must select Blosc as in C.
1211            let codec = match params.value.as_i32() {
1212                0 => CodecName::None,
1213                1 => CodecName::JPEG,
1214                2 => CodecName::Blosc,
1215                3 => CodecName::LZ4,
1216                4 => CodecName::BSLZ4,
1217                5 => CodecName::Zlib,
1218                6 => CodecName::LZ4HDF5,
1219                _ => CodecName::None,
1220            };
1221            if let CodecMode::Compress { .. } = self.mode {
1222                self.mode = CodecMode::Compress {
1223                    codec,
1224                    quality: self.jpeg_quality,
1225                };
1226            }
1227        } else if Some(reason) == self.params.jpeg_quality {
1228            self.jpeg_quality = params.value.as_i32().clamp(1, 100) as u8;
1229            if let CodecMode::Compress { codec, .. } = self.mode {
1230                self.mode = CodecMode::Compress {
1231                    codec,
1232                    quality: self.jpeg_quality,
1233                };
1234            }
1235        } else if Some(reason) == self.params.blosc_compressor {
1236            self.blosc_config.compressor = params.value.as_i32().max(0) as u32;
1237        } else if Some(reason) == self.params.blosc_clevel {
1238            self.blosc_config.clevel = params.value.as_i32().clamp(0, 9) as u32;
1239        } else if Some(reason) == self.params.blosc_shuffle {
1240            self.blosc_config.shuffle = params.value.as_i32().max(0) as u32;
1241        }
1242
1243        ad_core_rs::plugin::runtime::ParamChangeResult::updates(vec![])
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    fn make_u8_array(width: usize, height: usize) -> NDArray {
1252        let mut arr = NDArray::new(
1253            vec![NDDimension::new(width), NDDimension::new(height)],
1254            NDDataType::UInt8,
1255        );
1256        if let NDDataBuffer::U8(ref mut v) = arr.data {
1257            for i in 0..v.len() {
1258                v[i] = (i % 256) as u8;
1259            }
1260        }
1261        arr
1262    }
1263
1264    fn make_rgb_array(width: usize, height: usize) -> NDArray {
1265        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
1266        let mut arr = NDArray::new(
1267            vec![
1268                NDDimension::new(3),
1269                NDDimension::new(width),
1270                NDDimension::new(height),
1271            ],
1272            NDDataType::UInt8,
1273        );
1274        // info() reads ColorMode for 3D arrays
1275        arr.attributes.add(NDAttribute::new_static(
1276            "ColorMode",
1277            "Color Mode",
1278            NDAttrSource::Driver,
1279            NDAttrValue::Int32(2), // RGB1
1280        ));
1281        if let NDDataBuffer::U8(ref mut v) = arr.data {
1282            for i in 0..v.len() {
1283                v[i] = (i % 256) as u8;
1284            }
1285        }
1286        arr
1287    }
1288
1289    /// Every compressor must record the original element type STRUCTURALLY in
1290    /// `codec.original_data_type` (C `NDArray::dataType`, NDPluginCodec.cpp:35-36)
1291    /// and must attach NO carrier attribute, so the attribute list a compressed
1292    /// frame carries holds only genuine driver/user attributes at every output
1293    /// boundary by construction.
1294    #[test]
1295    fn compressors_record_type_in_codec_not_an_attribute() {
1296        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::UInt16);
1297        if let NDDataBuffer::U16(ref mut v) = arr.data {
1298            for (i, x) in v.iter_mut().enumerate() {
1299                *x = (i * 7) as u16;
1300            }
1301        }
1302        for compressed in [
1303            compress_lz4(&arr),
1304            compress_zlib(&arr),
1305            compress_lz4hdf5(&arr),
1306            compress_bslz4(&arr),
1307            compress_blosc(&arr, &BloscConfig::default()),
1308        ] {
1309            assert_eq!(
1310                compressed.codec.as_ref().unwrap().original_data_type,
1311                NDDataType::UInt16,
1312                "the original element type must travel in the codec"
1313            );
1314            assert!(
1315                compressed
1316                    .attributes
1317                    .get("CODEC_ORIGINAL_DATA_TYPE")
1318                    .is_none(),
1319                "no codec carrier attribute may be attached to a compressed frame"
1320            );
1321        }
1322    }
1323
1324    #[test]
1325    fn test_adp29_blosc_default_clevel_and_codec_params() {
1326        // C NDPluginCodec default BloscCLevel = 5 (NDPluginCodec.cpp:894); a
1327        // lower default would change the compressed bytes and NDCompressedSize.
1328        assert_eq!(
1329            BloscConfig::default().clevel,
1330            5,
1331            "default Blosc clevel must be 5 (C parity)"
1332        );
1333
1334        // C records the real level/shuffle/compressor in the codec
1335        // (NDPluginCodec.cpp:400-402), not zeros.
1336        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::UInt16);
1337        if let NDDataBuffer::U16(ref mut v) = arr.data {
1338            for (i, x) in v.iter_mut().enumerate() {
1339                *x = (i * 7) as u16;
1340            }
1341        }
1342        let out = compress_blosc(&arr, &BloscConfig::default());
1343        let codec = out.codec.as_ref().expect("blosc codec metadata");
1344        // codec.level = 5 (not the old hardcoded 0) proves the real clevel is
1345        // recorded; shuffle/compressor likewise mirror the config.
1346        assert_eq!(codec.level, 5, "codec.level records the default clevel 5");
1347        assert_eq!(codec.shuffle, 0, "codec.shuffle records shuffle");
1348        assert_eq!(codec.compressor, 0, "codec.compressor records compressor");
1349    }
1350
1351    #[test]
1352    fn test_blosc_roundtrip_u16_default_compressor() {
1353        // Regression: the cd_values were mis-ordered so the sub-compressor slot
1354        // (index 6) held the clevel, selecting ZSTD instead of the configured
1355        // BloscLZ; the buffer then failed to reverse. Round-trip with the
1356        // default config (compressor 0 = BloscLZ, clevel 5) must reconstruct the
1357        // exact bytes.
1358        let mut arr = NDArray::new(
1359            vec![NDDimension::new(100), NDDimension::new(20)],
1360            NDDataType::UInt16,
1361        );
1362        if let NDDataBuffer::U16(ref mut v) = arr.data {
1363            for (i, x) in v.iter_mut().enumerate() {
1364                *x = (i * 37 % 65521) as u16;
1365            }
1366        }
1367        let original = arr.data.as_u8_slice().to_vec();
1368
1369        let compressed = compress_blosc(&arr, &BloscConfig::default());
1370        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::Blosc);
1371        assert_ne!(
1372            compressed.data.as_u8_slice(),
1373            original.as_slice(),
1374            "blosc must actually compress (not fall back to the raw clone)"
1375        );
1376
1377        let decompressed = decompress_blosc(&compressed).expect("blosc round-trip");
1378        assert!(decompressed.codec.is_none());
1379        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
1380        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1381    }
1382
1383    #[test]
1384    fn test_blosc_roundtrip_u16_lz4_subcompressor() {
1385        // A non-default sub-compressor (LZ4 = 1) must round-trip too — the
1386        // recorded cd_values[6] drives the reader's sub-codec dispatch.
1387        let cfg = BloscConfig {
1388            compressor: 1,
1389            clevel: 5,
1390            shuffle: 1,
1391        };
1392        let mut arr = NDArray::new(vec![NDDimension::new(256)], NDDataType::UInt16);
1393        if let NDDataBuffer::U16(ref mut v) = arr.data {
1394            for (i, x) in v.iter_mut().enumerate() {
1395                *x = (i * 13 % 65521) as u16;
1396            }
1397        }
1398        let original = arr.data.as_u8_slice().to_vec();
1399
1400        let compressed = compress_blosc(&arr, &cfg);
1401        let codec = compressed.codec.as_ref().unwrap();
1402        assert_eq!(codec.compressor, 1, "records the LZ4 sub-compressor");
1403        assert_eq!(codec.shuffle, 1, "records byte shuffle");
1404
1405        let decompressed = decompress_blosc(&compressed).expect("blosc lz4 round-trip");
1406        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1407    }
1408
1409    // ---- LZ4 tests ----
1410
1411    #[test]
1412    fn test_lz4_roundtrip_u8() {
1413        let arr = make_u8_array(4, 4);
1414        let original_data = arr.data.as_u8_slice().to_vec();
1415
1416        let compressed = compress_lz4(&arr);
1417        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4);
1418        // Data buffer should now be the compressed bytes
1419        assert_ne!(compressed.data.as_u8_slice(), original_data.as_slice());
1420
1421        let decompressed = decompress_lz4(&compressed).unwrap();
1422        assert!(decompressed.codec.is_none());
1423        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
1424        assert_eq!(decompressed.data.as_u8_slice(), original_data.as_slice());
1425    }
1426
1427    #[test]
1428    fn test_decompress_runtime_does_not_drop_compressed_input() {
1429        // ADP-98: a Codec plugin in Decompress mode is compression-aware
1430        // (C NDPluginCodec passes compressionAware=true, NDPluginCodec.cpp:870),
1431        // so the runtime drop gate (runtime.rs:1785 `if compressed &&
1432        // !compression_aware`) must NOT discard its compressed input. Without the
1433        // compression_aware() override the compressed array is dropped before
1434        // process_array runs and the entire Decompress path is dead.
1435        use ad_core_rs::plugin::channel::{NDArrayOutput, ndarray_channel};
1436        use ad_core_rs::plugin::runtime::create_plugin_runtime_with_output;
1437        use ad_core_rs::plugin::wiring::WiringRegistry;
1438        use std::sync::atomic::Ordering;
1439
1440        // A genuinely-compressed input array (codec = LZ4).
1441        let mut raw = make_u8_array(4, 4);
1442        raw.unique_id = 1;
1443        let original_data = raw.data.as_u8_slice().to_vec();
1444        let compressed = compress_lz4(&raw);
1445        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4);
1446        assert_eq!(compressed.unique_id, 1);
1447
1448        // Sentinel uncompressed array: even if the compressed one is dropped, this
1449        // reaches downstream, so a wrong first unique_id pinpoints the drop (no
1450        // reliance on a timeout).
1451        let mut sentinel = make_u8_array(4, 4);
1452        sentinel.unique_id = 2;
1453
1454        let pool = Arc::new(NDArrayPool::new(1_000_000));
1455        let (ds_sender, mut ds_rx) = ndarray_channel("DS", 10);
1456        let mut output = NDArrayOutput::new();
1457        output.add(ds_sender);
1458        let (handle, _jh) = create_plugin_runtime_with_output(
1459            "CODEC_DECOMP",
1460            CodecProcessor::new(CodecMode::Decompress),
1461            pool,
1462            10,
1463            output,
1464            "",
1465            Arc::new(WiringRegistry::new()),
1466        );
1467        let dropped = handle.array_sender().dropped_arrays_counter().clone();
1468        handle
1469            .port_runtime()
1470            .port_handle()
1471            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
1472            .unwrap();
1473        std::thread::sleep(std::time::Duration::from_millis(10));
1474
1475        let rt = tokio::runtime::Builder::new_current_thread()
1476            .enable_all()
1477            .build()
1478            .unwrap();
1479        rt.block_on(handle.array_sender().publish(Arc::new(compressed)));
1480        rt.block_on(handle.array_sender().publish(Arc::new(sentinel)));
1481
1482        let first = ds_rx.blocking_recv().expect("downstream array");
1483        assert_eq!(
1484            first.unique_id, 1,
1485            "compressed input must be decompressed and delivered, not dropped"
1486        );
1487        assert!(
1488            first.codec.is_none(),
1489            "delivered array must be decompressed (codec cleared)"
1490        );
1491        assert_eq!(first.data.as_u8_slice(), original_data.as_slice());
1492        assert_eq!(
1493            dropped.load(Ordering::Acquire),
1494            0,
1495            "compression-aware Codec must not count its compressed input as dropped"
1496        );
1497    }
1498
1499    #[test]
1500    fn test_lz4_roundtrip_u16() {
1501        let mut arr = NDArray::new(
1502            vec![NDDimension::new(8), NDDimension::new(8)],
1503            NDDataType::UInt16,
1504        );
1505        if let NDDataBuffer::U16(ref mut v) = arr.data {
1506            for i in 0..v.len() {
1507                v[i] = (i * 100) as u16;
1508            }
1509        }
1510        let original_bytes = arr.data.as_u8_slice().to_vec();
1511
1512        let compressed = compress_lz4(&arr);
1513        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4);
1514        // The original data type is recorded structurally in the codec.
1515        assert_eq!(
1516            compressed.codec.as_ref().unwrap().original_data_type,
1517            NDDataType::UInt16
1518        );
1519        // No carrier attribute leaks onto the array.
1520        assert!(
1521            compressed
1522                .attributes
1523                .get("CODEC_ORIGINAL_DATA_TYPE")
1524                .is_none()
1525        );
1526
1527        let decompressed = decompress_lz4(&compressed).unwrap();
1528        assert!(decompressed.codec.is_none());
1529        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
1530        assert_eq!(decompressed.data.as_u8_slice(), original_bytes.as_slice());
1531    }
1532
1533    #[test]
1534    fn test_lz4_roundtrip_f64() {
1535        let mut arr = NDArray::new(vec![NDDimension::new(16)], NDDataType::Float64);
1536        if let NDDataBuffer::F64(ref mut v) = arr.data {
1537            for i in 0..v.len() {
1538                v[i] = i as f64 * 1.5;
1539            }
1540        }
1541        let original_bytes = arr.data.as_u8_slice().to_vec();
1542
1543        let compressed = compress_lz4(&arr);
1544        let decompressed = decompress_lz4(&compressed).unwrap();
1545        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
1546        assert_eq!(decompressed.data.as_u8_slice(), original_bytes.as_slice());
1547    }
1548
1549    #[test]
1550    fn test_lz4_compresses_repetitive_data() {
1551        // Highly repetitive data should compress well
1552        let mut arr = NDArray::new(
1553            vec![NDDimension::new(256), NDDimension::new(256)],
1554            NDDataType::UInt8,
1555        );
1556        // All zeros = very compressible
1557        if let NDDataBuffer::U8(ref mut v) = arr.data {
1558            for x in v.iter_mut() {
1559                *x = 0;
1560            }
1561        }
1562        let original_size = arr.data.as_u8_slice().len();
1563
1564        let compressed = compress_lz4(&arr);
1565        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
1566        assert!(
1567            compressed_size < original_size,
1568            "compressed ({}) should be smaller than original ({})",
1569            compressed_size,
1570            original_size,
1571        );
1572    }
1573
1574    #[test]
1575    fn test_lz4_preserves_metadata() {
1576        let mut arr = make_u8_array(4, 4);
1577        arr.unique_id = 42;
1578
1579        let compressed = compress_lz4(&arr);
1580        assert_eq!(compressed.unique_id, 42);
1581        assert_eq!(compressed.dims.len(), 2);
1582        assert_eq!(compressed.dims[0].size, 4);
1583        assert_eq!(compressed.dims[1].size, 4);
1584    }
1585
1586    // ---- Bitshuffle / LZ4 (bslz4) tests ----
1587
1588    #[test]
1589    fn test_bitshuffle_block_transpose_roundtrip() {
1590        // The canonical bit transpose must be its own paired inverse for a
1591        // block whose element count is a multiple of 8, across element sizes.
1592        for &(n, elem_size) in &[(16usize, 4usize), (8, 2), (256, 8), (128, 1)] {
1593            let input: Vec<u8> = (0..n * elem_size).map(|i| (i * 7 + 3) as u8).collect();
1594            let shuffled = bshuf_trans_bit_elem(&input, n, elem_size);
1595            assert_eq!(shuffled.len(), input.len());
1596            let restored = bshuf_untrans_bit_elem(&shuffled, n, elem_size);
1597            assert_eq!(restored, input, "elem_size {elem_size}, n {n}");
1598        }
1599    }
1600
1601    #[test]
1602    fn test_bitshuffle_matches_c_reference_vector() {
1603        // Locks the on-disk byte format to the canonical bitshuffle library
1604        // (the one h5py / libhdf5 / C areaDetector use). The expected vector was
1605        // produced by compiling hdf5_plugins/BSHUF/src/bitshuffle_core.c
1606        // (scalar path) and running `bshuf_bitshuffle(in, out, 16, 2, 0)` on the
1607        // u16 ramp 0..15: bit-row 0 (LSB of each elem) packs elem k -> output
1608        // bit k (little-endian element order), giving 0xAA/0xCC/0xF0 for the
1609        // varying low nibble and 0xFF where bit 3 separates elems 8..15.
1610        let input: Vec<u8> = (0..16u16).flat_map(|v| v.to_le_bytes()).collect();
1611        let shuffled = bshuf_trans_bit_elem(&input, 16, 2);
1612        let mut expected = vec![0u8; 32];
1613        expected[..8].copy_from_slice(&[170, 170, 204, 204, 240, 240, 0, 255]);
1614        assert_eq!(
1615            shuffled, expected,
1616            "canonical bitshuffle transpose must match the C library bytes"
1617        );
1618    }
1619
1620    #[test]
1621    fn test_bslz4_roundtrip_u8() {
1622        let mut arr = NDArray::new(
1623            vec![NDDimension::new(64), NDDimension::new(64)],
1624            NDDataType::UInt8,
1625        );
1626        if let NDDataBuffer::U8(ref mut v) = arr.data {
1627            for (i, x) in v.iter_mut().enumerate() {
1628                *x = (i % 251) as u8;
1629            }
1630        }
1631        let original = arr.data.as_u8_slice().to_vec();
1632
1633        let compressed = compress_bslz4(&arr);
1634        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::BSLZ4);
1635        assert_ne!(compressed.data.as_u8_slice(), original.as_slice());
1636
1637        let decompressed = decompress_bslz4(&compressed).unwrap();
1638        assert!(decompressed.codec.is_none());
1639        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
1640        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1641    }
1642
1643    #[test]
1644    fn test_bslz4_roundtrip_u16() {
1645        let mut arr = NDArray::new(
1646            vec![NDDimension::new(100), NDDimension::new(20)],
1647            NDDataType::UInt16,
1648        );
1649        if let NDDataBuffer::U16(ref mut v) = arr.data {
1650            for (i, x) in v.iter_mut().enumerate() {
1651                *x = (i * 37 % 65521) as u16;
1652            }
1653        }
1654        let original = arr.data.as_u8_slice().to_vec();
1655
1656        let compressed = compress_bslz4(&arr);
1657        assert_eq!(
1658            compressed.codec.as_ref().unwrap().original_data_type,
1659            NDDataType::UInt16
1660        );
1661        let decompressed = decompress_bslz4(&compressed).unwrap();
1662        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
1663        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1664    }
1665
1666    #[test]
1667    fn test_bslz4_roundtrip_f64_with_negatives() {
1668        let mut arr = NDArray::new(vec![NDDimension::new(73)], NDDataType::Float64);
1669        if let NDDataBuffer::F64(ref mut v) = arr.data {
1670            for (i, x) in v.iter_mut().enumerate() {
1671                *x = (i as f64 - 36.0) * 2.5;
1672            }
1673        }
1674        let original = arr.data.as_u8_slice().to_vec();
1675
1676        let compressed = compress_bslz4(&arr);
1677        let decompressed = decompress_bslz4(&compressed).unwrap();
1678        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
1679        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1680    }
1681
1682    #[test]
1683    fn test_bslz4_roundtrip_multi_block() {
1684        // A buffer larger than the default block size exercises the
1685        // per-block container framing and a trailing partial block.
1686        let elem_size = 4usize;
1687        let block = bshuf_default_block_size(elem_size);
1688        // 2.5 blocks worth of i32 elements.
1689        let count = block * 2 + block / 2 + 3;
1690        let mut arr = NDArray::new(vec![NDDimension::new(count)], NDDataType::Int32);
1691        if let NDDataBuffer::I32(ref mut v) = arr.data {
1692            for (i, x) in v.iter_mut().enumerate() {
1693                *x = (i as i32).wrapping_mul(2_654_435_761u32 as i32);
1694            }
1695        }
1696        let original = arr.data.as_u8_slice().to_vec();
1697
1698        let compressed = compress_bslz4(&arr);
1699        let decompressed = decompress_bslz4(&compressed).unwrap();
1700        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1701    }
1702
1703    #[test]
1704    fn test_bslz4_compresses_repetitive_data() {
1705        // Bitshuffle makes near-constant data extremely compressible.
1706        let arr = NDArray::new(
1707            vec![NDDimension::new(256), NDDimension::new(256)],
1708            NDDataType::UInt16,
1709        );
1710        let original_size = arr.data.as_u8_slice().len();
1711        let compressed = compress_bslz4(&arr);
1712        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
1713        assert!(
1714            compressed_size < original_size,
1715            "bslz4 compressed ({compressed_size}) should be < original ({original_size})"
1716        );
1717    }
1718
1719    #[test]
1720    fn test_bslz4_via_processor() {
1721        // The CodecProcessor must round-trip through the BSLZ4 codec.
1722        let mut arr = NDArray::new(
1723            vec![NDDimension::new(32), NDDimension::new(32)],
1724            NDDataType::UInt16,
1725        );
1726        if let NDDataBuffer::U16(ref mut v) = arr.data {
1727            for (i, x) in v.iter_mut().enumerate() {
1728                *x = (i * 11) as u16;
1729            }
1730        }
1731        let original = arr.data.as_u8_slice().to_vec();
1732        let pool = NDArrayPool::new(10_000_000);
1733
1734        let mut comp = CodecProcessor::new(CodecMode::Compress {
1735            codec: CodecName::BSLZ4,
1736            quality: 0,
1737        });
1738        let compressed = comp.process_array(&arr, &pool);
1739        let compressed_arr = &compressed.output_arrays[0];
1740        assert_eq!(
1741            compressed_arr.codec.as_ref().unwrap().name,
1742            CodecName::BSLZ4
1743        );
1744
1745        let mut decomp = CodecProcessor::new(CodecMode::Decompress);
1746        let result = decomp.process_array(compressed_arr, &pool);
1747        assert_eq!(
1748            result.output_arrays[0].data.as_u8_slice(),
1749            original.as_slice()
1750        );
1751    }
1752
1753    // ---- JPEG tests ----
1754
1755    #[test]
1756    fn test_jpeg_compress_mono() {
1757        let arr = make_u8_array(16, 16);
1758        let compressed = compress_jpeg(&arr, 90).unwrap();
1759        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::JPEG);
1760        // Compressed data should be valid JPEG (starts with SOI marker)
1761        let data = compressed.data.as_u8_slice();
1762        assert_eq!(&data[0..2], &[0xFF, 0xD8]);
1763    }
1764
1765    #[test]
1766    fn test_jpeg_compress_rgb() {
1767        let arr = make_rgb_array(16, 16);
1768        let compressed = compress_jpeg(&arr, 90).unwrap();
1769        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::JPEG);
1770        let data = compressed.data.as_u8_slice();
1771        assert_eq!(&data[0..2], &[0xFF, 0xD8]);
1772    }
1773
1774    #[test]
1775    fn test_jpeg_roundtrip_mono() {
1776        let arr = make_u8_array(16, 16);
1777        let compressed = compress_jpeg(&arr, 100).unwrap();
1778        let decompressed = decompress_jpeg(&compressed).unwrap();
1779        assert!(decompressed.codec.is_none());
1780        assert_eq!(decompressed.dims.len(), 2);
1781        assert_eq!(decompressed.dims[0].size, 16); // width
1782        assert_eq!(decompressed.dims[1].size, 16); // height
1783        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
1784        // JPEG is lossy, so data won't be identical, but dimensions match
1785        assert_eq!(decompressed.data.len(), 16 * 16);
1786    }
1787
1788    #[test]
1789    fn test_jpeg_roundtrip_rgb() {
1790        let arr = make_rgb_array(16, 16);
1791        let compressed = compress_jpeg(&arr, 100).unwrap();
1792        let decompressed = decompress_jpeg(&compressed).unwrap();
1793        assert!(decompressed.codec.is_none());
1794        assert_eq!(decompressed.dims.len(), 3);
1795        assert_eq!(decompressed.dims[0].size, 3); // color
1796        assert_eq!(decompressed.dims[1].size, 16); // width
1797        assert_eq!(decompressed.dims[2].size, 16); // height
1798        assert_eq!(decompressed.data.len(), 3 * 16 * 16);
1799    }
1800
1801    #[test]
1802    fn test_jpeg_rejects_non_u8() {
1803        let arr = NDArray::new(
1804            vec![NDDimension::new(8), NDDimension::new(8)],
1805            NDDataType::UInt16,
1806        );
1807        assert!(compress_jpeg(&arr, 90).is_none());
1808    }
1809
1810    #[test]
1811    fn test_jpeg_rejects_1d() {
1812        let arr = NDArray::new(vec![NDDimension::new(64)], NDDataType::UInt8);
1813        assert!(compress_jpeg(&arr, 90).is_none());
1814    }
1815
1816    #[test]
1817    fn test_jpeg_quality_affects_size() {
1818        let arr = make_u8_array(64, 64);
1819        let high = compress_jpeg(&arr, 95).unwrap();
1820        let low = compress_jpeg(&arr, 10).unwrap();
1821        let high_size = high.codec.as_ref().unwrap().compressed_size;
1822        let low_size = low.codec.as_ref().unwrap().compressed_size;
1823        assert!(
1824            high_size > low_size,
1825            "high quality ({}) should produce larger output than low quality ({})",
1826            high_size,
1827            low_size,
1828        );
1829    }
1830
1831    // ---- Zlib tests ----
1832
1833    #[test]
1834    fn test_zlib_roundtrip_u8() {
1835        let arr = make_u8_array(8, 8);
1836        let original = arr.data.as_u8_slice().to_vec();
1837
1838        let compressed = compress_zlib(&arr);
1839        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::Zlib);
1840        assert_ne!(compressed.data.as_u8_slice(), original.as_slice());
1841
1842        let decompressed = decompress_zlib(&compressed).unwrap();
1843        assert!(decompressed.codec.is_none());
1844        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
1845        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1846    }
1847
1848    #[test]
1849    fn test_zlib_roundtrip_u16() {
1850        let mut arr = NDArray::new(
1851            vec![NDDimension::new(16), NDDimension::new(16)],
1852            NDDataType::UInt16,
1853        );
1854        if let NDDataBuffer::U16(ref mut v) = arr.data {
1855            for (i, x) in v.iter_mut().enumerate() {
1856                *x = (i * 257 % 65521) as u16;
1857            }
1858        }
1859        let original = arr.data.as_u8_slice().to_vec();
1860
1861        let compressed = compress_zlib(&arr);
1862        assert_eq!(
1863            compressed.codec.as_ref().unwrap().original_data_type,
1864            NDDataType::UInt16
1865        );
1866
1867        let decompressed = decompress_zlib(&compressed).unwrap();
1868        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
1869        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1870    }
1871
1872    #[test]
1873    fn test_zlib_roundtrip_f64_with_negatives() {
1874        let mut arr = NDArray::new(vec![NDDimension::new(64)], NDDataType::Float64);
1875        if let NDDataBuffer::F64(ref mut v) = arr.data {
1876            for (i, x) in v.iter_mut().enumerate() {
1877                *x = (i as f64 - 32.0) * 3.25;
1878            }
1879        }
1880        let original = arr.data.as_u8_slice().to_vec();
1881
1882        let compressed = compress_zlib(&arr);
1883        let decompressed = decompress_zlib(&compressed).unwrap();
1884        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
1885        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1886    }
1887
1888    #[test]
1889    fn test_zlib_compresses_repetitive_data() {
1890        let arr = NDArray::new(
1891            vec![NDDimension::new(256), NDDimension::new(256)],
1892            NDDataType::UInt8,
1893        );
1894        let original_size = arr.data.as_u8_slice().len();
1895        let compressed = compress_zlib(&arr);
1896        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
1897        assert!(
1898            compressed_size < original_size,
1899            "zlib compressed ({compressed_size}) should be < original ({original_size})"
1900        );
1901    }
1902
1903    #[test]
1904    fn test_zlib_via_processor() {
1905        let mut arr = NDArray::new(
1906            vec![NDDimension::new(32), NDDimension::new(32)],
1907            NDDataType::UInt16,
1908        );
1909        if let NDDataBuffer::U16(ref mut v) = arr.data {
1910            for (i, x) in v.iter_mut().enumerate() {
1911                *x = (i * 13) as u16;
1912            }
1913        }
1914        let original = arr.data.as_u8_slice().to_vec();
1915        let pool = NDArrayPool::new(10_000_000);
1916
1917        let mut comp = CodecProcessor::new(CodecMode::Compress {
1918            codec: CodecName::Zlib,
1919            quality: 0,
1920        });
1921        let compressed = comp.process_array(&arr, &pool);
1922        let compressed_arr = &compressed.output_arrays[0];
1923        assert_eq!(compressed_arr.codec.as_ref().unwrap().name, CodecName::Zlib);
1924
1925        let mut decomp = CodecProcessor::new(CodecMode::Decompress);
1926        let result = decomp.process_array(compressed_arr, &pool);
1927        assert_eq!(
1928            result.output_arrays[0].data.as_u8_slice(),
1929            original.as_slice()
1930        );
1931    }
1932
1933    // ---- LZ4HDF5 tests ----
1934
1935    #[test]
1936    fn test_lz4hdf5_roundtrip_u8() {
1937        let mut arr = NDArray::new(
1938            vec![NDDimension::new(64), NDDimension::new(64)],
1939            NDDataType::UInt8,
1940        );
1941        if let NDDataBuffer::U8(ref mut v) = arr.data {
1942            for (i, x) in v.iter_mut().enumerate() {
1943                *x = (i % 251) as u8;
1944            }
1945        }
1946        let original = arr.data.as_u8_slice().to_vec();
1947
1948        let compressed = compress_lz4hdf5(&arr);
1949        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4HDF5);
1950        assert_ne!(compressed.data.as_u8_slice(), original.as_slice());
1951
1952        let decompressed = decompress_lz4hdf5(&compressed).unwrap();
1953        assert!(decompressed.codec.is_none());
1954        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
1955        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1956    }
1957
1958    #[test]
1959    fn test_lz4hdf5_roundtrip_u16() {
1960        let mut arr = NDArray::new(
1961            vec![NDDimension::new(80), NDDimension::new(40)],
1962            NDDataType::UInt16,
1963        );
1964        if let NDDataBuffer::U16(ref mut v) = arr.data {
1965            for (i, x) in v.iter_mut().enumerate() {
1966                *x = (i * 37 % 65521) as u16;
1967            }
1968        }
1969        let original = arr.data.as_u8_slice().to_vec();
1970
1971        let compressed = compress_lz4hdf5(&arr);
1972        assert_eq!(
1973            compressed.codec.as_ref().unwrap().original_data_type,
1974            NDDataType::UInt16
1975        );
1976
1977        let decompressed = decompress_lz4hdf5(&compressed).unwrap();
1978        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
1979        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1980    }
1981
1982    #[test]
1983    fn test_lz4hdf5_roundtrip_f64_with_negatives() {
1984        let mut arr = NDArray::new(vec![NDDimension::new(97)], NDDataType::Float64);
1985        if let NDDataBuffer::F64(ref mut v) = arr.data {
1986            for (i, x) in v.iter_mut().enumerate() {
1987                *x = (i as f64 - 48.0) * 1.75;
1988            }
1989        }
1990        let original = arr.data.as_u8_slice().to_vec();
1991
1992        let compressed = compress_lz4hdf5(&arr);
1993        let decompressed = decompress_lz4hdf5(&compressed).unwrap();
1994        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
1995        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1996    }
1997
1998    #[test]
1999    fn test_lz4hdf5_multi_block_roundtrip() {
2000        // A buffer larger than the default block size exercises the per-block
2001        // container framing and a trailing partial block.
2002        let block = LZ4HDF5_DEFAULT_BLOCK_SIZE;
2003        let count = block * 2 + block / 3 + 7; // 2.33 blocks of u8.
2004        let mut arr = NDArray::new(vec![NDDimension::new(count)], NDDataType::UInt8);
2005        if let NDDataBuffer::U8(ref mut v) = arr.data {
2006            for (i, x) in v.iter_mut().enumerate() {
2007                *x = (i.wrapping_mul(2_654_435_761) % 251) as u8;
2008            }
2009        }
2010        let original = arr.data.as_u8_slice().to_vec();
2011
2012        let compressed = compress_lz4hdf5(&arr);
2013        let decompressed = decompress_lz4hdf5(&compressed).unwrap();
2014        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
2015    }
2016
2017    #[test]
2018    fn test_lz4hdf5_compresses_repetitive_data() {
2019        let arr = NDArray::new(
2020            vec![NDDimension::new(256), NDDimension::new(256)],
2021            NDDataType::UInt16,
2022        );
2023        let original_size = arr.data.as_u8_slice().len();
2024        let compressed = compress_lz4hdf5(&arr);
2025        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
2026        assert!(
2027            compressed_size < original_size,
2028            "lz4hdf5 compressed ({compressed_size}) should be < original ({original_size})"
2029        );
2030    }
2031
2032    #[test]
2033    fn test_lz4hdf5_via_processor() {
2034        let mut arr = NDArray::new(
2035            vec![NDDimension::new(48), NDDimension::new(48)],
2036            NDDataType::UInt16,
2037        );
2038        if let NDDataBuffer::U16(ref mut v) = arr.data {
2039            for (i, x) in v.iter_mut().enumerate() {
2040                *x = (i * 7) as u16;
2041            }
2042        }
2043        let original = arr.data.as_u8_slice().to_vec();
2044        let pool = NDArrayPool::new(10_000_000);
2045
2046        let mut comp = CodecProcessor::new(CodecMode::Compress {
2047            codec: CodecName::LZ4HDF5,
2048            quality: 0,
2049        });
2050        let compressed = comp.process_array(&arr, &pool);
2051        let compressed_arr = &compressed.output_arrays[0];
2052        assert_eq!(
2053            compressed_arr.codec.as_ref().unwrap().name,
2054            CodecName::LZ4HDF5
2055        );
2056
2057        let mut decomp = CodecProcessor::new(CodecMode::Decompress);
2058        let result = decomp.process_array(compressed_arr, &pool);
2059        assert_eq!(
2060            result.output_arrays[0].data.as_u8_slice(),
2061            original.as_slice()
2062        );
2063    }
2064
2065    // ---- COMPRESSOR ordinal mapping ----
2066
2067    #[test]
2068    fn test_compressor_ordinal_mapping() {
2069        // C `NDCodecCompressor_t` (Codec.h:12-18): 0=None, 1=JPEG, 2=Blosc,
2070        // 3=LZ4, 4=BSLZ4. Rust-only zlib/lz4hdf5 follow at 5/6. Selecting a
2071        // compressor by its C ordinal must pick the matching `CodecName`.
2072        use ad_core_rs::plugin::runtime::{ParamChangeValue, PluginParamSnapshot};
2073
2074        let cases = [
2075            (0i32, CodecName::None),
2076            (1, CodecName::JPEG),
2077            (2, CodecName::Blosc),
2078            (3, CodecName::LZ4),
2079            (4, CodecName::BSLZ4),
2080            (5, CodecName::Zlib),
2081            (6, CodecName::LZ4HDF5),
2082        ];
2083
2084        for (ordinal, expected) in cases {
2085            let mut proc = CodecProcessor::new(CodecMode::Compress {
2086                codec: CodecName::LZ4,
2087                quality: 85,
2088            });
2089            // The compressor param index is otherwise discovered via
2090            // `register_params`; set it directly for the unit test.
2091            proc.params.compressor = Some(0);
2092            let snapshot = PluginParamSnapshot {
2093                enable_callbacks: true,
2094                reason: 0,
2095                addr: 0,
2096                value: ParamChangeValue::Int32(ordinal),
2097            };
2098            proc.on_param_change(0, &snapshot);
2099            match proc.mode {
2100                CodecMode::Compress { codec, .. } => assert_eq!(
2101                    codec, expected,
2102                    "ordinal {ordinal} should select {expected:?}"
2103                ),
2104                other => panic!("expected Compress mode, got {other:?}"),
2105            }
2106        }
2107    }
2108
2109    // ---- Decompress wrong codec ----
2110
2111    #[test]
2112    fn test_decompress_wrong_codec() {
2113        let arr = make_u8_array(4, 4);
2114        assert!(decompress_lz4(&arr).is_none());
2115        assert!(decompress_jpeg(&arr).is_none());
2116        assert!(decompress_zlib(&arr).is_none());
2117        assert!(decompress_lz4hdf5(&arr).is_none());
2118    }
2119
2120    // ---- CodecProcessor tests ----
2121
2122    #[test]
2123    fn test_processor_lz4_compress() {
2124        let pool = NDArrayPool::new(1_000_000);
2125        let mut proc = CodecProcessor::new(CodecMode::Compress {
2126            codec: CodecName::LZ4,
2127            quality: 0,
2128        });
2129        let arr = make_u8_array(32, 32);
2130        let result = proc.process_array(&arr, &pool);
2131        assert_eq!(result.output_arrays.len(), 1);
2132        assert_eq!(
2133            result.output_arrays[0].codec.as_ref().unwrap().name,
2134            CodecName::LZ4
2135        );
2136        assert!(proc.compression_ratio() >= 1.0);
2137    }
2138
2139    #[test]
2140    fn test_processor_jpeg_compress() {
2141        let pool = NDArrayPool::new(1_000_000);
2142        let mut proc = CodecProcessor::new(CodecMode::Compress {
2143            codec: CodecName::JPEG,
2144            quality: 80,
2145        });
2146        let arr = make_u8_array(16, 16);
2147        let result = proc.process_array(&arr, &pool);
2148        assert_eq!(result.output_arrays.len(), 1);
2149        assert_eq!(
2150            result.output_arrays[0].codec.as_ref().unwrap().name,
2151            CodecName::JPEG
2152        );
2153    }
2154
2155    #[test]
2156    fn test_processor_decompress_auto_lz4() {
2157        let pool = NDArrayPool::new(1_000_000);
2158        let arr = make_u8_array(16, 16);
2159        let compressed = compress_lz4(&arr);
2160
2161        let mut proc = CodecProcessor::new(CodecMode::Decompress);
2162        let result = proc.process_array(&compressed, &pool);
2163        assert_eq!(result.output_arrays.len(), 1);
2164        assert!(result.output_arrays[0].codec.is_none());
2165        assert_eq!(
2166            result.output_arrays[0].data.as_u8_slice(),
2167            arr.data.as_u8_slice()
2168        );
2169        assert!(proc.compression_ratio() > 0.0);
2170    }
2171
2172    #[test]
2173    fn test_processor_decompress_auto_jpeg() {
2174        let pool = NDArrayPool::new(1_000_000);
2175        let arr = make_u8_array(16, 16);
2176        let compressed = compress_jpeg(&arr, 90).unwrap();
2177
2178        let mut proc = CodecProcessor::new(CodecMode::Decompress);
2179        let result = proc.process_array(&compressed, &pool);
2180        assert_eq!(result.output_arrays.len(), 1);
2181        assert!(result.output_arrays[0].codec.is_none());
2182    }
2183
2184    #[test]
2185    fn test_processor_decompress_no_codec() {
2186        let pool = NDArrayPool::new(1_000_000);
2187        let arr = make_u8_array(8, 8);
2188        let mut proc = CodecProcessor::new(CodecMode::Decompress);
2189        let result = proc.process_array(&arr, &pool);
2190        // C++: on failure, pass through original array unchanged
2191        assert_eq!(result.output_arrays.len(), 1);
2192        assert_eq!(proc.compression_ratio(), 1.0);
2193    }
2194
2195    #[test]
2196    fn test_processor_compression_ratio() {
2197        let pool = NDArrayPool::new(1_000_000);
2198        // Create highly compressible data (all zeros)
2199        let mut arr = NDArray::new(
2200            vec![NDDimension::new(128), NDDimension::new(128)],
2201            NDDataType::UInt8,
2202        );
2203        if let NDDataBuffer::U8(ref mut v) = arr.data {
2204            for x in v.iter_mut() {
2205                *x = 0;
2206            }
2207        }
2208
2209        let mut proc = CodecProcessor::new(CodecMode::Compress {
2210            codec: CodecName::LZ4,
2211            quality: 0,
2212        });
2213        let _ = proc.process_array(&arr, &pool);
2214        let ratio = proc.compression_ratio();
2215        assert!(
2216            ratio > 2.0,
2217            "all-zeros 128x128 should compress at least 2x, got {}",
2218            ratio,
2219        );
2220    }
2221
2222    #[test]
2223    fn test_processor_plugin_type() {
2224        let proc = CodecProcessor::new(CodecMode::Decompress);
2225        assert_eq!(proc.plugin_type(), "NDPluginCodec");
2226    }
2227
2228    // ---- buffer_from_bytes tests ----
2229
2230    #[test]
2231    fn test_buffer_from_bytes_u8() {
2232        let data = vec![1u8, 2, 3, 4];
2233        let buf = buffer_from_bytes(&data, NDDataType::UInt8).unwrap();
2234        assert_eq!(buf.data_type(), NDDataType::UInt8);
2235        assert_eq!(buf.len(), 4);
2236        assert_eq!(buf.as_u8_slice(), &[1, 2, 3, 4]);
2237    }
2238
2239    #[test]
2240    fn test_buffer_from_bytes_u16() {
2241        let original = vec![1000u16, 2000, 3000];
2242        let bytes: Vec<u8> = original.iter().flat_map(|v| v.to_ne_bytes()).collect();
2243        let buf = buffer_from_bytes(&bytes, NDDataType::UInt16).unwrap();
2244        assert_eq!(buf.data_type(), NDDataType::UInt16);
2245        assert_eq!(buf.len(), 3);
2246        if let NDDataBuffer::U16(v) = buf {
2247            assert_eq!(v, original);
2248        } else {
2249            panic!("wrong buffer type");
2250        }
2251    }
2252
2253    #[test]
2254    fn test_buffer_from_bytes_bad_alignment() {
2255        // 3 bytes can't form a u16 array
2256        let data = vec![0u8; 3];
2257        assert!(buffer_from_bytes(&data, NDDataType::UInt16).is_none());
2258    }
2259
2260    #[test]
2261    fn test_buffer_from_bytes_f64_roundtrip() {
2262        let original = vec![1.5f64, -2.7, 3.14159];
2263        let bytes: Vec<u8> = original.iter().flat_map(|v| v.to_ne_bytes()).collect();
2264        let buf = buffer_from_bytes(&bytes, NDDataType::Float64).unwrap();
2265        if let NDDataBuffer::F64(v) = buf {
2266            assert_eq!(v, original);
2267        } else {
2268            panic!("wrong buffer type");
2269        }
2270    }
2271}