Skip to main content

tensogram_encodings/
pipeline.rs

1// (C) Copyright 2026- ECMWF and individual contributors.
2//
3// This software is licensed under the terms of the Apache Licence Version 2.0
4// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5// In applying this licence, ECMWF does not waive the privileges and immunities
6// granted to it by virtue of its status as an intergovernmental organisation nor
7// does it submit to any jurisdiction.
8
9use std::borrow::Cow;
10
11#[cfg(feature = "blosc2")]
12use crate::compression::Blosc2Compressor;
13#[cfg(feature = "lz4")]
14use crate::compression::Lz4Compressor;
15#[cfg(feature = "sz3")]
16use crate::compression::Sz3Compressor;
17#[cfg(feature = "szip")]
18use crate::compression::SzipCompressor;
19#[cfg(feature = "szip-pure")]
20use crate::compression::SzipPureCompressor;
21#[cfg(feature = "zfp")]
22use crate::compression::ZfpCompressor;
23#[cfg(feature = "zstd")]
24use crate::compression::ZstdCompressor;
25#[cfg(feature = "zstd-pure")]
26use crate::compression::ZstdPureCompressor;
27use crate::compression::{CompressResult, CompressionError, Compressor};
28use crate::shuffle;
29use crate::simple_packing::{self, PackingError, SimplePackingParams};
30use serde::{Deserialize, Serialize};
31use std::sync::OnceLock;
32use thiserror::Error;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum ByteOrder {
37    Big,
38    Little,
39}
40
41impl ByteOrder {
42    /// Returns the byte order of the platform this code was compiled for.
43    #[inline]
44    pub fn native() -> Self {
45        #[cfg(target_endian = "little")]
46        {
47            ByteOrder::Little
48        }
49        #[cfg(target_endian = "big")]
50        {
51            ByteOrder::Big
52        }
53    }
54}
55
56/// Reverse bytes within each `unit_size`-byte chunk of `data`, converting
57/// between big-endian and little-endian representations in place.
58///
59/// No-op when `unit_size <= 1` (single-byte types have no byte order).
60/// Returns an error if `data.len()` is not a multiple of `unit_size`.
61/// For complex types, pass the scalar component size (e.g. 4 for complex64)
62/// so that each float32 component is swapped independently.
63pub fn byteswap(data: &mut [u8], unit_size: usize) -> Result<(), PipelineError> {
64    if unit_size <= 1 {
65        return Ok(());
66    }
67    if !data.len().is_multiple_of(unit_size) {
68        return Err(PipelineError::Range(format!(
69            "byteswap: data length {} is not a multiple of unit_size {}",
70            data.len(),
71            unit_size,
72        )));
73    }
74    for chunk in data.chunks_exact_mut(unit_size) {
75        chunk.reverse();
76    }
77    Ok(())
78}
79
80#[derive(Debug, Error)]
81#[non_exhaustive]
82pub enum PipelineError {
83    #[error("encoding error: {0}")]
84    Encoding(#[from] PackingError),
85    #[error("compression error: {0}")]
86    Compression(#[from] CompressionError),
87    #[error("shuffle error: {0}")]
88    Shuffle(String),
89    #[error("range error: {0}")]
90    Range(String),
91    #[error("unknown encoding: {0}")]
92    UnknownEncoding(String),
93    #[error("unknown filter: {0}")]
94    UnknownFilter(String),
95    #[error("unknown compression: {0}")]
96    UnknownCompression(String),
97    /// Surfaced when a process-wide configuration source (typically an
98    /// environment variable like `TENSOGRAM_COMPRESSION_BACKEND` or
99    /// `TENSOGRAM_THREADS`) carries a value that does not parse.
100    /// Strict-input contract: bad config does not silently default.
101    #[error("invalid configuration: {0}")]
102    InvalidConfig(String),
103    /// The descriptor's claimed decoded size is structurally
104    /// inconsistent with the on-disk payload length, detected
105    /// *before* any decompression runs.
106    ///
107    /// Raised only for pipelines where the decoded size is known
108    /// exactly from the descriptor and the payload length —
109    /// uncompressed `encoding=none` and uncompressed
110    /// `encoding=simple_packing`.  In both cases the equality is
111    /// categorical: a mismatch in **either** direction (claim too
112    /// large *or* too small relative to the payload) is malformed
113    /// input, never a legitimate message.
114    ///
115    /// Compressed codecs are deliberately *not* guarded here — their
116    /// decoded size cannot be derived from the payload without
117    /// decompressing, and any fixed expansion-ratio ceiling would
118    /// risk false-rejecting legitimately highly-compressible
119    /// scientific data.  A hostile compressed descriptor instead
120    /// fails gracefully at the fallible allocation step (see
121    /// [`Compressor::decompress`](crate::compression::Compressor)),
122    /// which surfaces a structured allocation error rather than
123    /// aborting the process.
124    #[error(
125        "descriptor claims {claimed_bytes} decoded byte(s) but frame payload is \
126         {payload_bytes} byte(s) (encoding {encoding}, dtype width {dtype_byte_width})"
127    )]
128    DescriptorSizeMismatch {
129        claimed_bytes: u128,
130        payload_bytes: usize,
131        encoding: String,
132        dtype_byte_width: usize,
133    },
134}
135
136#[derive(Debug, Clone)]
137pub enum EncodingType {
138    None,
139    SimplePacking(SimplePackingParams),
140}
141
142impl std::fmt::Display for EncodingType {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            EncodingType::None => write!(f, "none"),
146            EncodingType::SimplePacking(_) => write!(f, "simple_packing"),
147        }
148    }
149}
150
151#[derive(Debug, Clone)]
152pub enum FilterType {
153    None,
154    Shuffle { element_size: usize },
155}
156
157#[cfg(feature = "blosc2")]
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "lowercase")]
160pub enum Blosc2Codec {
161    Blosclz,
162    Lz4,
163    Lz4hc,
164    Zlib,
165    Zstd,
166}
167
168#[cfg(feature = "zfp")]
169#[derive(Debug, Clone)]
170pub enum ZfpMode {
171    FixedRate { rate: f64 },
172    FixedPrecision { precision: u32 },
173    FixedAccuracy { tolerance: f64 },
174}
175
176#[cfg(feature = "sz3")]
177#[derive(Debug, Clone)]
178pub enum Sz3ErrorBound {
179    Absolute(f64),
180    Relative(f64),
181    Psnr(f64),
182}
183
184#[derive(Debug, Clone)]
185pub enum CompressionType {
186    None,
187    #[cfg(any(feature = "szip", feature = "szip-pure"))]
188    Szip {
189        rsi: u32,
190        block_size: u32,
191        flags: u32,
192        bits_per_sample: u32,
193    },
194    #[cfg(any(feature = "zstd", feature = "zstd-pure"))]
195    Zstd {
196        level: i32,
197    },
198    #[cfg(feature = "lz4")]
199    Lz4,
200    #[cfg(feature = "blosc2")]
201    Blosc2 {
202        codec: Blosc2Codec,
203        clevel: i32,
204        typesize: usize,
205    },
206    #[cfg(feature = "zfp")]
207    Zfp {
208        mode: ZfpMode,
209    },
210    #[cfg(feature = "sz3")]
211    Sz3 {
212        error_bound: Sz3ErrorBound,
213    },
214    /// Bit-level run-length encoding.  Bitmask-only — the pipeline
215    /// build-layer rejects this codec on any other dtype.
216    /// See [`crate::compression::RleCompressor`].
217    Rle,
218    /// Roaring bitmap.  Bitmask-only.
219    /// See [`crate::compression::RoaringCompressor`].
220    Roaring,
221}
222
223/// Selects which backend to use when both FFI and pure-Rust implementations
224/// are compiled in for the same codec (szip or zstd).
225///
226/// When only one feature is enabled the backend field is ignored — the
227/// available implementation is always used.
228///
229/// `Auto` is the default and means "consult the
230/// `TENSOGRAM_COMPRESSION_BACKEND` environment variable; on a missing
231/// variable, fall back to the platform default — `Pure` on `wasm32`,
232/// `Ffi` on native".  `Ffi` and `Pure` are explicit overrides that
233/// always win over the env var.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235pub enum CompressionBackend {
236    /// Consult `TENSOGRAM_COMPRESSION_BACKEND` and platform defaults at
237    /// pipeline-build time.  An unparseable env variable surfaces as a
238    /// [`PipelineError`] at the next encode / decode call rather than
239    /// being silently swallowed by `Default::default()` at process start.
240    #[default]
241    Auto,
242    /// Use the C FFI implementation (libaec for szip, libzstd for zstd).
243    /// Falls back to pure-Rust if the FFI feature is not compiled in.
244    Ffi,
245    /// Use the pure-Rust implementation (tensogram-szip, ruzstd).
246    /// Falls back to FFI if the pure feature is not compiled in.
247    Pure,
248}
249
250/// Environment variable consulted by `Auto` backend resolution.  An
251/// unparseable value (anything other than `"ffi"` or `"pure"`,
252/// case-insensitive, after trimming) is rejected with
253/// [`PipelineError::InvalidConfig`] at the next pipeline call.
254pub const ENV_COMPRESSION_BACKEND: &str = "TENSOGRAM_COMPRESSION_BACKEND";
255
256/// Resolve a [`CompressionBackend`] to a concrete `Ffi` or `Pure`
257/// variant.  No-op when the input is already explicit.
258///
259/// **Strict-input contract.**  Values of
260/// [`ENV_COMPRESSION_BACKEND`] other than `"ffi"` / `"pure"`
261/// (case-insensitive, trimmed) are rejected with
262/// [`PipelineError::InvalidConfig`].  Earlier versions silently
263/// fell back to `Ffi` on any unrecognised value, which masked typos
264/// such as `TENSOGRAM_COMPRESSION_BACKEND=ffix`.
265///
266/// The result is cached after the first successful resolution so
267/// repeated calls do not re-read the environment.  A single bad
268/// value is therefore reported on every subsequent encode / decode,
269/// not just the first one — operators see the same diagnostic until
270/// the env is fixed.
271pub fn resolve_compression_backend(
272    requested: CompressionBackend,
273) -> Result<CompressionBackend, PipelineError> {
274    match requested {
275        CompressionBackend::Ffi | CompressionBackend::Pure => Ok(requested),
276        CompressionBackend::Auto => resolve_auto_backend(),
277    }
278}
279
280fn resolve_auto_backend() -> Result<CompressionBackend, PipelineError> {
281    // Cache the resolved value (or the typed parse error) so repeated
282    // `Default::default()` callers don't pay a syscall per call.  An
283    // unparseable env variable is sticky: every encode in the
284    // process surfaces the same typed error until the env is fixed
285    // and the process restarts.
286    static CACHED: OnceLock<Result<CompressionBackend, String>> = OnceLock::new();
287    CACHED
288        .get_or_init(|| {
289            if cfg!(target_arch = "wasm32") {
290                return Ok(CompressionBackend::Pure);
291            }
292            match std::env::var(ENV_COMPRESSION_BACKEND) {
293                Ok(val) => parse_backend(&val),
294                // env var unset → platform default.
295                Err(_) => Ok(CompressionBackend::Ffi),
296            }
297        })
298        .clone()
299        .map_err(PipelineError::InvalidConfig)
300}
301
302fn parse_backend(val: &str) -> Result<CompressionBackend, String> {
303    match val.trim().to_ascii_lowercase().as_str() {
304        "pure" | "rust" => Ok(CompressionBackend::Pure),
305        "ffi" | "c" => Ok(CompressionBackend::Ffi),
306        other => Err(format!(
307            "invalid {ENV_COMPRESSION_BACKEND} value {other:?}; \
308             expected 'ffi' or 'pure' (case-insensitive)"
309        )),
310    }
311}
312
313#[derive(Debug, Clone)]
314pub struct PipelineConfig {
315    pub encoding: EncodingType,
316    pub filter: FilterType,
317    pub compression: CompressionType,
318    pub num_values: usize,
319    pub byte_order: ByteOrder,
320    pub dtype_byte_width: usize,
321    /// The size of the fundamental scalar component for byte-order swapping.
322    /// Equal to `dtype_byte_width` for simple types.  For complex types the
323    /// swap must operate on each float component independently, so this is
324    /// half of `dtype_byte_width` (e.g. 4 for complex64, 8 for complex128).
325    pub swap_unit_size: usize,
326    /// Which backend to use for szip / zstd when both are compiled in.
327    pub compression_backend: CompressionBackend,
328    /// Intra-codec thread budget for this object.
329    ///
330    /// - `0` — sequential (default; preserves pre-threads behaviour).
331    /// - `N ≥ 1` — codec may use up to `N` threads internally.  Only a
332    ///   subset of codecs honour this (currently: blosc2, zstd FFI,
333    ///   simple_packing, shuffle); others ignore it.  Output bytes are
334    ///   byte-identical regardless of `N`.
335    ///
336    /// Callers should expect this to be set by the `tensogram`
337    /// layer after consulting
338    /// [`EncodeOptions.threads`](../../../tensogram/encode/struct.EncodeOptions.html#structfield.threads)
339    /// and the small-message threshold; direct pipeline callers may
340    /// leave it at `0`.
341    pub intra_codec_threads: u32,
342    /// Opt-in: compute xxh3-64 over the final encoded bytes inline with
343    /// encoding, exposing the digest via [`PipelineResult::hash`].
344    ///
345    /// - `false` (default) — the pipeline does no hashing; callers that
346    ///   need a hash must walk the output buffer themselves with
347    ///   `xxhash_rust::xxh3::xxh3_64`.
348    /// - `true` — the pipeline drives an `Xxh3Default` hasher in lockstep
349    ///   with the codec output, eliminating a second pass over the
350    ///   encoded buffer.  The digest is bit-identical to
351    ///   `xxhash_rust::xxh3::xxh3_64(&encoded_bytes)` by construction.
352    ///
353    /// Hashing always runs in the calling thread *after* any internal
354    /// codec parallelism has joined; it never participates in the
355    /// intra-codec thread budget.  For transparent codecs the hash is
356    /// byte-identical across `intra_codec_threads` values; for opaque
357    /// codecs the hash tracks the codec's output bytes (which may reorder
358    /// by worker-completion order).
359    pub compute_hash: bool,
360}
361
362pub struct PipelineResult {
363    pub encoded_bytes: Vec<u8>,
364    /// Block offsets produced by compressors that support random access (szip, blosc2).
365    pub block_offsets: Option<Vec<u64>>,
366    /// xxh3-64 digest over `encoded_bytes`, produced inline with encoding
367    /// when [`PipelineConfig::compute_hash`] was `true`.  `None` otherwise.
368    ///
369    /// The digest is bit-identical to `xxhash_rust::xxh3::xxh3_64(&encoded_bytes)`
370    /// by construction (both use seed 0 and the default xxh3 secret).
371    pub hash: Option<u64>,
372}
373
374/// Build an szip compressor, dispatching between FFI and pure-Rust at runtime.
375#[cfg(any(feature = "szip", feature = "szip-pure"))]
376fn build_szip_compressor(
377    #[allow(unused_variables)] backend: CompressionBackend,
378    rsi: u32,
379    block_size: u32,
380    flags: u32,
381    bits_per_sample: u32,
382) -> Box<dyn Compressor> {
383    // When both features are compiled in, dispatch at runtime.
384    #[cfg(all(feature = "szip", feature = "szip-pure"))]
385    if matches!(backend, CompressionBackend::Pure) {
386        return Box::new(SzipPureCompressor {
387            rsi,
388            block_size,
389            flags,
390            bits_per_sample,
391        });
392    }
393
394    // FFI path — used when szip feature is enabled and either:
395    // (a) both features compiled but backend is Ffi, or
396    // (b) only szip is compiled.
397    #[cfg(feature = "szip")]
398    {
399        Box::new(SzipCompressor {
400            rsi,
401            block_size,
402            flags,
403            bits_per_sample,
404        })
405    }
406
407    // Pure-only path — used when only szip-pure is compiled (no FFI).
408    #[cfg(all(feature = "szip-pure", not(feature = "szip")))]
409    {
410        Box::new(SzipPureCompressor {
411            rsi,
412            block_size,
413            flags,
414            bits_per_sample,
415        })
416    }
417}
418
419/// Build a zstd compressor, dispatching between FFI and pure-Rust at runtime.
420///
421/// `nb_workers` is forwarded to the FFI path (libzstd `NbWorkers`
422/// parameter) and ignored by the pure-Rust path (ruzstd is
423/// single-threaded).
424#[cfg(any(feature = "zstd", feature = "zstd-pure"))]
425fn build_zstd_compressor(
426    #[allow(unused_variables)] backend: CompressionBackend,
427    level: i32,
428    #[allow(unused_variables)] nb_workers: u32,
429) -> Box<dyn Compressor> {
430    #[cfg(all(feature = "zstd", feature = "zstd-pure"))]
431    if matches!(backend, CompressionBackend::Pure) {
432        return Box::new(ZstdPureCompressor { level });
433    }
434
435    #[cfg(feature = "zstd")]
436    {
437        Box::new(ZstdCompressor { level, nb_workers })
438    }
439
440    #[cfg(all(feature = "zstd-pure", not(feature = "zstd")))]
441    {
442        Box::new(ZstdPureCompressor { level })
443    }
444}
445
446/// Build a boxed compressor from a CompressionType variant.
447///
448/// For szip and zstd, the `compression_backend` field in `PipelineConfig`
449/// selects between FFI and pure-Rust implementations at **runtime**.  When
450/// only one feature is compiled in, the backend field is ignored.
451///
452/// `Auto` backend variants are resolved here via
453/// [`resolve_compression_backend`]; bad
454/// `TENSOGRAM_COMPRESSION_BACKEND` env values surface as
455/// [`PipelineError::InvalidConfig`] (rather than silently defaulting
456/// to FFI as earlier versions did).
457fn build_compressor(
458    compression: &CompressionType,
459    #[allow(unused_variables)] config: &PipelineConfig,
460) -> Result<Option<Box<dyn Compressor>>, PipelineError> {
461    match compression {
462        CompressionType::None => Ok(None),
463        #[cfg(any(feature = "szip", feature = "szip-pure"))]
464        CompressionType::Szip {
465            rsi,
466            block_size,
467            flags,
468            bits_per_sample,
469        } => {
470            let backend = resolve_compression_backend(config.compression_backend)?;
471            let mut szip_flags = *flags;
472            // simple_packing output is MSB-first; tell the szip codec so its
473            // predictor sees bytes in the correct significance order.
474            // AEC_DATA_MSB = 4 in both libaec-sys and tensogram-szip.
475            if matches!(config.encoding, EncodingType::SimplePacking(_)) {
476                szip_flags |= 4; // AEC_DATA_MSB
477            }
478
479            // Runtime backend selection.  The helper builds the right
480            // Box<dyn Compressor> based on what features are compiled in
481            // and what the caller requested.
482            let compressor: Box<dyn Compressor> =
483                build_szip_compressor(backend, *rsi, *block_size, szip_flags, *bits_per_sample);
484            Ok(Some(compressor))
485        }
486        #[cfg(any(feature = "zstd", feature = "zstd-pure"))]
487        CompressionType::Zstd { level } => {
488            let backend = resolve_compression_backend(config.compression_backend)?;
489            let compressor: Box<dyn Compressor> =
490                build_zstd_compressor(backend, *level, config.intra_codec_threads);
491            Ok(Some(compressor))
492        }
493        #[cfg(feature = "lz4")]
494        CompressionType::Lz4 => Ok(Some(Box::new(Lz4Compressor))),
495        #[cfg(feature = "blosc2")]
496        CompressionType::Blosc2 {
497            codec,
498            clevel,
499            typesize,
500        } => Ok(Some(Box::new(Blosc2Compressor {
501            codec: *codec,
502            clevel: *clevel,
503            typesize: *typesize,
504            nthreads: config.intra_codec_threads,
505        }))),
506        #[cfg(feature = "zfp")]
507        CompressionType::Zfp { mode } => Ok(Some(Box::new(ZfpCompressor {
508            mode: mode.clone(),
509            num_values: config.num_values,
510            byte_order: config.byte_order,
511        }))),
512        #[cfg(feature = "sz3")]
513        CompressionType::Sz3 { error_bound } => Ok(Some(Box::new(Sz3Compressor {
514            error_bound: error_bound.clone(),
515            num_values: config.num_values,
516            byte_order: config.byte_order,
517        }))),
518        CompressionType::Rle => Ok(Some(Box::new(crate::compression::RleCompressor))),
519        CompressionType::Roaring => Ok(Some(Box::new(crate::compression::RoaringCompressor))),
520    }
521}
522
523/// Copy `src` into a freshly allocated `Vec<u8>` while updating `hasher` in
524/// lockstep — one pass over the data.
525///
526/// When `hasher` is `None` this is a plain `src.to_vec()`.  When `hasher` is
527/// `Some`, the function walks `src` in 64 KiB chunks: each chunk is first
528/// fed to the hasher (bringing the bytes into L1/L2) and then appended to
529/// the destination `Vec` (still cache-hot).  This avoids the second DRAM
530/// read that `src.to_vec()` followed by `xxh3_64(&dst)` would incur on
531/// buffers larger than L3.
532///
533/// The chunk size is a power-of-two that comfortably fits inside a typical
534/// L2 cache while amortising the per-chunk call overhead of
535/// `Xxh3Default::update`.
536#[inline]
537fn copy_and_hash(src: &[u8], hasher: Option<&mut xxhash_rust::xxh3::Xxh3Default>) -> Vec<u8> {
538    match hasher {
539        None => src.to_vec(),
540        Some(h) => {
541            const CHUNK: usize = 64 * 1024;
542            let mut dst = Vec::with_capacity(src.len());
543            let mut offset = 0;
544            while offset < src.len() {
545                let end = (offset + CHUNK).min(src.len());
546                let chunk = &src[offset..end];
547                h.update(chunk);
548                dst.extend_from_slice(chunk);
549                offset = end;
550            }
551            dst
552        }
553    }
554}
555
556/// Feed `bytes` to an optional hasher.  Used at each codec exit point in
557/// `encode_pipeline` — the codec just wrote those bytes, so they are
558/// maximally cache-hot; the hasher reads them from L2/L3 rather than DRAM.
559#[inline]
560fn update_hasher(bytes: &[u8], hasher: Option<&mut xxhash_rust::xxh3::Xxh3Default>) {
561    if let Some(h) = hasher {
562        h.update(bytes);
563    }
564}
565
566/// Full forward pipeline: encode → filter → compress.
567///
568/// When `config.compute_hash` is `true`, the xxh3-64 digest of the final
569/// encoded bytes is produced inline with the codec output (no second pass
570/// over the buffer) and returned via `PipelineResult.hash`.  The digest is
571/// bit-identical to what `xxhash_rust::xxh3::xxh3_64(&encoded_bytes)` would
572/// return, by construction — both use seed 0 and the default secret.
573///
574/// Hashing runs entirely in the calling thread *after* any intra-codec
575/// parallelism has joined.  The hasher is never shared across threads.
576#[tracing::instrument(skip(data, config), fields(data_len = data.len(), encoding = %config.encoding))]
577pub fn encode_pipeline(
578    data: &[u8],
579    config: &PipelineConfig,
580) -> Result<PipelineResult, PipelineError> {
581    let mut hasher = config
582        .compute_hash
583        .then(xxhash_rust::xxh3::Xxh3Default::new);
584
585    // Step 1: Encoding — Cow avoids cloning when encoding is None
586    let encoded: Cow<'_, [u8]> = match &config.encoding {
587        EncodingType::None => Cow::Borrowed(data),
588        EncodingType::SimplePacking(params) => {
589            let values = bytes_to_f64(data, config.byte_order)?;
590            Cow::Owned(simple_packing::encode_with_threads(
591                &values,
592                params,
593                config.intra_codec_threads,
594            )?)
595        }
596    };
597
598    // Step 2: Filter
599    let filtered: Cow<'_, [u8]> = match &config.filter {
600        FilterType::None => encoded,
601        FilterType::Shuffle { element_size } => Cow::Owned(
602            shuffle::shuffle_with_threads(&encoded, *element_size, config.intra_codec_threads)
603                .map_err(|e| PipelineError::Shuffle(e.to_string()))?,
604        ),
605    };
606
607    // Step 3: Compression
608    let compressor = build_compressor(&config.compression, config)?;
609    let (encoded_bytes, block_offsets) = match compressor {
610        None => {
611            // No compression: if `filtered` is still borrowed from `data`
612            // (passthrough pipeline) we fuse the copy with hashing to
613            // avoid a second walk over the source.  Otherwise it is
614            // already owned and we just hash the Vec in place.
615            let owned = match filtered {
616                Cow::Borrowed(src) => copy_and_hash(src, hasher.as_mut()),
617                Cow::Owned(buf) => {
618                    update_hasher(&buf, hasher.as_mut());
619                    buf
620                }
621            };
622            (owned, None)
623        }
624        Some(compressor) => {
625            let CompressResult {
626                data: compressed,
627                block_offsets,
628            } = compressor.compress(&filtered)?;
629            update_hasher(&compressed, hasher.as_mut());
630            (compressed, block_offsets)
631        }
632    };
633
634    Ok(PipelineResult {
635        encoded_bytes,
636        block_offsets,
637        hash: hasher.map(|h| h.digest()),
638    })
639}
640
641/// Encode from f64 values directly, avoiding the bytes→f64 conversion overhead
642/// that `encode_pipeline` pays when the caller already has typed values.
643///
644/// Hash handling is identical to [`encode_pipeline`] — see that function's
645/// documentation for the `compute_hash` contract.
646#[tracing::instrument(skip(values, config), fields(num_values = values.len(), encoding = %config.encoding))]
647pub fn encode_pipeline_f64(
648    values: &[f64],
649    config: &PipelineConfig,
650) -> Result<PipelineResult, PipelineError> {
651    let mut hasher = config
652        .compute_hash
653        .then(xxhash_rust::xxh3::Xxh3Default::new);
654
655    let encoded: Cow<'_, [u8]> = match &config.encoding {
656        EncodingType::None => Cow::Owned(f64_to_bytes(values, config.byte_order)?),
657        EncodingType::SimplePacking(params) => Cow::Owned(simple_packing::encode_with_threads(
658            values,
659            params,
660            config.intra_codec_threads,
661        )?),
662    };
663
664    let filtered: Cow<'_, [u8]> = match &config.filter {
665        FilterType::None => encoded,
666        FilterType::Shuffle { element_size } => Cow::Owned(
667            shuffle::shuffle_with_threads(&encoded, *element_size, config.intra_codec_threads)
668                .map_err(|e| PipelineError::Shuffle(e.to_string()))?,
669        ),
670    };
671
672    let compressor = build_compressor(&config.compression, config)?;
673    let (encoded_bytes, block_offsets) = match compressor {
674        None => {
675            // `encoded` is always owned in this function (both branches of
676            // the match above construct a Cow::Owned), so `into_owned` is
677            // a zero-cost unwrap.
678            let owned = filtered.into_owned();
679            update_hasher(&owned, hasher.as_mut());
680            (owned, None)
681        }
682        Some(compressor) => {
683            let CompressResult {
684                data: compressed,
685                block_offsets,
686            } = compressor.compress(&filtered)?;
687            update_hasher(&compressed, hasher.as_mut());
688            (compressed, block_offsets)
689        }
690    };
691
692    Ok(PipelineResult {
693        encoded_bytes,
694        block_offsets,
695        hash: hasher.map(|h| h.digest()),
696    })
697}
698
699/// Full reverse pipeline: decompress → unshuffle → decode → native byteswap.
700///
701/// When `native_byte_order` is true (the default at the API level), the
702/// output bytes are converted to the caller's native byte order so that a
703/// simple `reinterpret_cast` or `from_ne_bytes` produces correct values.
704/// When false, bytes are returned in the message's declared wire byte order.
705#[tracing::instrument(skip(encoded, config), fields(encoded_len = encoded.len()))]
706pub fn decode_pipeline(
707    encoded: &[u8],
708    config: &PipelineConfig,
709    native_byte_order: bool,
710) -> Result<Vec<u8>, PipelineError> {
711    // Step 0: Reject descriptors whose claimed decoded size is
712    // structurally inconsistent with the on-disk payload, before any
713    // decompression runs.  No-op for filtered / compressed pipelines
714    // (see `validate_descriptor_size`).
715    validate_descriptor_size(encoded.len(), config)?;
716
717    // Step 1: Decompress — Cow avoids cloning when no compression
718    let decompressed: Cow<'_, [u8]> = match build_compressor(&config.compression, config)? {
719        None => Cow::Borrowed(encoded),
720        Some(compressor) => {
721            let expected_size = estimate_decompressed_size(config);
722            Cow::Owned(compressor.decompress(encoded, expected_size)?)
723        }
724    };
725
726    // Step 2: Unshuffle
727    let unfiltered: Cow<'_, [u8]> = match &config.filter {
728        FilterType::None => decompressed,
729        FilterType::Shuffle { element_size } => Cow::Owned(
730            shuffle::unshuffle_with_threads(
731                &decompressed,
732                *element_size,
733                config.intra_codec_threads,
734            )
735            .map_err(|e| PipelineError::Shuffle(e.to_string()))?,
736        ),
737    };
738
739    // Determine the target byte order for the output.  When the caller
740    // requests native byte order, simple_packing can write directly in
741    // native (avoiding a redundant write + swap).
742    let target_byte_order = if native_byte_order {
743        ByteOrder::native()
744    } else {
745        config.byte_order
746    };
747
748    // Step 3: Decode encoding
749    let mut decoded = match &config.encoding {
750        EncodingType::None => unfiltered.into_owned(),
751        EncodingType::SimplePacking(params) => {
752            // simple_packing decodes to Vec<f64> in-register values (no byte
753            // order) then serialises directly to the target byte order.
754            let values = simple_packing::decode_with_threads(
755                &unfiltered,
756                config.num_values,
757                params,
758                config.intra_codec_threads,
759            )?;
760            f64_to_bytes(&values, target_byte_order)?
761        }
762    };
763
764    // Step 4: Native-endian byteswap for encoding=none.
765    // (simple_packing already wrote in target_byte_order above.)
766    if native_byte_order
767        && matches!(config.encoding, EncodingType::None)
768        && config.byte_order != ByteOrder::native()
769    {
770        byteswap(&mut decoded, config.swap_unit_size)?;
771    }
772
773    Ok(decoded)
774}
775
776/// Decode a partial sample range from a compressed+encoded pipeline.
777///
778/// Supports compressors with random access (szip, blosc2, zfp fixed-rate).
779/// Shuffle filter is not supported with range decode.
780///
781/// `sample_offset` and `sample_count` are in logical element units.
782/// `block_offsets` are block boundary offsets from encoding (compressor-specific).
783///
784/// When `native_byte_order` is true, the output bytes are in the caller's
785/// native byte order.
786pub fn decode_range_pipeline(
787    encoded: &[u8],
788    config: &PipelineConfig,
789    block_offsets: &[u64],
790    sample_offset: u64,
791    sample_count: u64,
792    native_byte_order: bool,
793) -> Result<Vec<u8>, PipelineError> {
794    if matches!(config.filter, FilterType::Shuffle { .. }) {
795        return Err(PipelineError::Shuffle(
796            "partial range decode is not supported with shuffle filter".to_string(),
797        ));
798    }
799
800    // Reject a descriptor whose full-tensor decoded size is
801    // structurally inconsistent with the payload before slicing.
802    // No-op for compressed pipelines (random-access range decode runs
803    // only on compressors with block offsets); meaningful for the
804    // uncompressed `encoding=none` / `simple_packing` paths, where the
805    // full payload is present and `num_values × width` must match.
806    validate_descriptor_size(encoded.len(), config)?;
807
808    // Phase 1: Compute byte range needed from the (possibly compressed) stream
809    let (byte_start, byte_size, bit_offset_in_chunk) = match &config.encoding {
810        EncodingType::SimplePacking(params) => {
811            // Promote to u128 for the bit-position arithmetic so a hostile
812            // `sample_offset` or `sample_count` cannot silently wrap.
813            let bpv = params.bits_per_value as u128;
814            let bit_start_u128 = (sample_offset as u128)
815                .checked_mul(bpv)
816                .ok_or_else(|| PipelineError::Range("bit start overflow".to_string()))?;
817            let bit_count_u128 = (sample_count as u128)
818                .checked_mul(bpv)
819                .ok_or_else(|| PipelineError::Range("bit count overflow".to_string()))?;
820            let bit_end_u128 = bit_start_u128
821                .checked_add(bit_count_u128)
822                .ok_or_else(|| PipelineError::Range("bit end overflow".to_string()))?;
823            let bs = usize::try_from(bit_start_u128 / 8)
824                .map_err(|_| PipelineError::Range("byte start exceeds usize".to_string()))?;
825            let be = usize::try_from(bit_end_u128.div_ceil(8))
826                .map_err(|_| PipelineError::Range("byte end exceeds usize".to_string()))?;
827            let size = be
828                .checked_sub(bs)
829                .ok_or_else(|| PipelineError::Range("byte range invariant violated".to_string()))?;
830            (bs, size, Some((bit_start_u128 % 8) as usize))
831        }
832        EncodingType::None => {
833            let elem_size = config.dtype_byte_width;
834            // `usize::try_from` rather than `as usize` so a `u64`
835            // sample offset/count truncating on 32-bit surfaces as a
836            // typed error instead of slicing into bogus memory.
837            let offset_usize = usize::try_from(sample_offset).map_err(|_| {
838                PipelineError::Range(format!(
839                    "sample_offset {sample_offset} exceeds usize on this target"
840                ))
841            })?;
842            let count_usize = usize::try_from(sample_count).map_err(|_| {
843                PipelineError::Range(format!(
844                    "sample_count {sample_count} exceeds usize on this target"
845                ))
846            })?;
847            let bs = offset_usize
848                .checked_mul(elem_size)
849                .ok_or_else(|| PipelineError::Range("byte offset overflow".to_string()))?;
850            let sz = count_usize
851                .checked_mul(elem_size)
852                .ok_or_else(|| PipelineError::Range("byte count overflow".to_string()))?;
853            (bs, sz, None)
854        }
855    };
856
857    // Phase 2: Get decompressed bytes for the range
858    let decompressed = match build_compressor(&config.compression, config)? {
859        None => {
860            // No compression: slice directly from encoded buffer
861            let byte_end = byte_start
862                .checked_add(byte_size)
863                .ok_or_else(|| PipelineError::Range("byte end overflow".to_string()))?;
864            if byte_end > encoded.len() {
865                return Err(PipelineError::Range(format!(
866                    "range ({sample_offset}, {sample_count}) exceeds payload size"
867                )));
868            }
869            try_clone_bytes(&encoded[byte_start..byte_end])?
870        }
871        Some(compressor) => {
872            compressor.decompress_range(encoded, block_offsets, byte_start, byte_size)?
873        }
874    };
875
876    let target_byte_order = if native_byte_order {
877        ByteOrder::native()
878    } else {
879        config.byte_order
880    };
881
882    // Phase 3: Decode encoding from decompressed bytes
883    match &config.encoding {
884        EncodingType::None => {
885            let mut result = decompressed;
886            if native_byte_order && config.byte_order != ByteOrder::native() {
887                byteswap(&mut result, config.swap_unit_size)?;
888            }
889            Ok(result)
890        }
891        EncodingType::SimplePacking(params) => {
892            let count_usize = usize::try_from(sample_count).map_err(|_| {
893                PipelineError::Range(format!(
894                    "sample_count {sample_count} exceeds usize on this target"
895                ))
896            })?;
897            let values = simple_packing::decode_range(
898                &decompressed,
899                bit_offset_in_chunk.unwrap_or(0),
900                count_usize,
901                params,
902            )?;
903            f64_to_bytes(&values, target_byte_order)
904        }
905    }
906}
907
908/// Exact number of decoded payload bytes implied by the descriptor,
909/// computed in `u128` so the multiplication never wraps.
910///
911/// This is the single source of truth for the descriptor-implied
912/// decoded size.  Both [`estimate_decompressed_size`] (the
913/// decompression pre-allocation hint) and [`validate_descriptor_size`]
914/// (the structural consistency gate) derive from it, so the two can
915/// never drift apart.
916///
917/// For `encoding=none` this is `num_values × dtype_byte_width`
918/// (`ceil(num_values / 8)` for the bitmask dtype, whose
919/// `dtype_byte_width` is reported as `0`).  For
920/// `encoding=simple_packing` it is `ceil(num_values × bits_per_value
921/// / 8)` — the bit-packed width, independent of `dtype_byte_width`.
922fn exact_decoded_bytes(config: &PipelineConfig) -> u128 {
923    match &config.encoding {
924        EncodingType::None => {
925            // `Dtype::Bitmask` reports `byte_width = 0` because its
926            // elements are 1 bit each.  The decoded byte count for a
927            // bitmask payload is `ceil(num_values / 8)`.
928            if config.dtype_byte_width == 0 {
929                (config.num_values as u128).div_ceil(8)
930            } else {
931                (config.num_values as u128) * (config.dtype_byte_width as u128)
932            }
933        }
934        EncodingType::SimplePacking(params) => {
935            let total_bits = (config.num_values as u128) * (params.bits_per_value as u128);
936            total_bits.div_ceil(8)
937        }
938    }
939}
940
941fn estimate_decompressed_size(config: &PipelineConfig) -> usize {
942    // Clamp the exact (un-saturating) size down to `usize` for the
943    // pre-allocation hint.  Over-large values clamp to `usize::MAX`,
944    // which the fallible reservation downstream then rejects
945    // gracefully.
946    exact_decoded_bytes(config).min(usize::MAX as u128) as usize
947}
948
949/// Structural consistency gate run *before* any decompression.
950///
951/// For pipelines whose decoded size is known **exactly** from the
952/// descriptor — uncompressed `encoding=none` and uncompressed
953/// `encoding=simple_packing` — the encoded payload length must equal
954/// that exact size.  A mismatch in either direction is categorically
955/// malformed input (truncation, corruption, or a hostile descriptor)
956/// and is rejected with [`PipelineError::DescriptorSizeMismatch`].
957///
958/// Pipelines that apply a [`filter`](FilterType) (shuffle) or any
959/// compression are **not** gated here: a filter is size-preserving
960/// but the check is only wired for the uncompressed-no-filter case to
961/// keep the equality exact, and compressed payloads have no
962/// descriptor-derivable on-disk length (see the
963/// `DescriptorSizeMismatch` docs for why no ratio heuristic is
964/// imposed).  Those paths fail gracefully at the fallible
965/// decompression allocation instead.
966fn validate_descriptor_size(
967    payload_len: usize,
968    config: &PipelineConfig,
969) -> Result<(), PipelineError> {
970    // Only the uncompressed, unfiltered pipelines have an exactly
971    // known on-disk payload length.
972    if !matches!(config.compression, CompressionType::None)
973        || !matches!(config.filter, FilterType::None)
974    {
975        return Ok(());
976    }
977
978    let claimed = exact_decoded_bytes(config);
979    if claimed != payload_len as u128 {
980        return Err(PipelineError::DescriptorSizeMismatch {
981            claimed_bytes: claimed,
982            payload_bytes: payload_len,
983            encoding: config.encoding.to_string(),
984            dtype_byte_width: config.dtype_byte_width,
985        });
986    }
987    Ok(())
988}
989
990pub(crate) fn try_clone_bytes(src: &[u8]) -> Result<Vec<u8>, PipelineError> {
991    let mut out: Vec<u8> = Vec::new();
992    out.try_reserve_exact(src.len()).map_err(|e| {
993        PipelineError::Range(format!(
994            "failed to reserve {} bytes for range output clone: {e}",
995            src.len()
996        ))
997    })?;
998    out.extend_from_slice(src);
999    Ok(out)
1000}
1001
1002fn bytes_to_f64(data: &[u8], byte_order: ByteOrder) -> Result<Vec<f64>, PipelineError> {
1003    // Reject partial trailing bytes rather than silently truncating —
1004    // `chunks_exact(8)` would otherwise drop them, which on the encode
1005    // side (simple_packing) would feed a truncated value stream into
1006    // the encoder without any error.
1007    if !data.len().is_multiple_of(8) {
1008        return Err(PipelineError::Range(format!(
1009            "byte-to-f64 input length {} is not a multiple of 8",
1010            data.len()
1011        )));
1012    }
1013    let n = data.len() / 8;
1014    let mut out: Vec<f64> = Vec::new();
1015    out.try_reserve_exact(n).map_err(|e| {
1016        PipelineError::Range(format!(
1017            "failed to reserve {} bytes for byte-to-f64 conversion: {e}",
1018            n.saturating_mul(std::mem::size_of::<f64>()),
1019        ))
1020    })?;
1021    for chunk in data.chunks_exact(8) {
1022        let mut arr = [0u8; 8];
1023        arr.copy_from_slice(chunk);
1024        out.push(match byte_order {
1025            ByteOrder::Big => f64::from_be_bytes(arr),
1026            ByteOrder::Little => f64::from_le_bytes(arr),
1027        });
1028    }
1029    Ok(out)
1030}
1031
1032fn f64_to_bytes(values: &[f64], byte_order: ByteOrder) -> Result<Vec<u8>, PipelineError> {
1033    let bytes_len = values.len().checked_mul(8).ok_or_else(|| {
1034        PipelineError::Range(format!(
1035            "f64-to-byte output length overflows usize: {} values x 8 bytes",
1036            values.len()
1037        ))
1038    })?;
1039    let mut out: Vec<u8> = Vec::new();
1040    out.try_reserve_exact(bytes_len).map_err(|e| {
1041        PipelineError::Range(format!(
1042            "failed to reserve {bytes_len} bytes for f64-to-byte conversion: {e}"
1043        ))
1044    })?;
1045    for v in values {
1046        out.extend_from_slice(&match byte_order {
1047            ByteOrder::Big => v.to_be_bytes(),
1048            ByteOrder::Little => v.to_le_bytes(),
1049        });
1050    }
1051    Ok(out)
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    // ── parse_backend strict-input tests (Wave 1.1) ──────────────────
1059
1060    #[test]
1061    fn parse_backend_accepts_ffi_pure_aliases() {
1062        for (raw, expected) in [
1063            ("ffi", CompressionBackend::Ffi),
1064            ("FFI", CompressionBackend::Ffi),
1065            ("c", CompressionBackend::Ffi),
1066            ("pure", CompressionBackend::Pure),
1067            ("Pure", CompressionBackend::Pure),
1068            ("rust", CompressionBackend::Pure),
1069            ("  ffi  ", CompressionBackend::Ffi),
1070        ] {
1071            assert_eq!(
1072                parse_backend(raw).unwrap(),
1073                expected,
1074                "backend {raw:?} should parse as {expected:?}"
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn parse_backend_rejects_unknown_value() {
1081        // Strict-input contract: anything other than the accepted
1082        // aliases is rejected with a typed error naming the env var
1083        // and the offending input.  Earlier versions silently fell
1084        // back to FFI on any unknown value, which masked typos like
1085        // `TENSOGRAM_COMPRESSION_BACKEND=ffix`.
1086        let err = parse_backend("ffix").unwrap_err();
1087        assert!(err.contains("TENSOGRAM_COMPRESSION_BACKEND"));
1088        assert!(err.contains("ffix"));
1089        assert!(err.contains("'ffi' or 'pure'"));
1090    }
1091
1092    #[test]
1093    fn parse_backend_rejects_empty_string() {
1094        let err = parse_backend("").unwrap_err();
1095        assert!(err.contains("TENSOGRAM_COMPRESSION_BACKEND"));
1096    }
1097
1098    #[test]
1099    fn parse_backend_rejects_whitespace_only() {
1100        let err = parse_backend("   ").unwrap_err();
1101        assert!(err.contains("TENSOGRAM_COMPRESSION_BACKEND"));
1102    }
1103
1104    #[test]
1105    fn resolve_compression_backend_passes_through_explicit() {
1106        // Explicit `Ffi` / `Pure` are no-ops regardless of env state.
1107        assert_eq!(
1108            resolve_compression_backend(CompressionBackend::Ffi).unwrap(),
1109            CompressionBackend::Ffi
1110        );
1111        assert_eq!(
1112            resolve_compression_backend(CompressionBackend::Pure).unwrap(),
1113            CompressionBackend::Pure
1114        );
1115    }
1116
1117    #[test]
1118    fn compression_backend_default_is_auto() {
1119        // `Default` no longer reads env vars at struct-init time
1120        // (Wave 7.1).  Resolution now happens at pipeline-build time
1121        // via `resolve_compression_backend`, where errors can surface.
1122        assert_eq!(CompressionBackend::default(), CompressionBackend::Auto);
1123    }
1124
1125    #[test]
1126    fn test_passthrough_pipeline() {
1127        let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
1128        let config = PipelineConfig {
1129            encoding: EncodingType::None,
1130            filter: FilterType::None,
1131            compression: CompressionType::None,
1132            num_values: 1,
1133            byte_order: ByteOrder::Little,
1134            dtype_byte_width: 8,
1135            swap_unit_size: 8,
1136            compression_backend: CompressionBackend::default(),
1137            intra_codec_threads: 0,
1138            compute_hash: false,
1139        };
1140        let result = encode_pipeline(&data, &config).unwrap();
1141        assert_eq!(result.encoded_bytes, data);
1142        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
1143        assert_eq!(decoded, data);
1144    }
1145
1146    #[test]
1147    fn test_simple_packing_pipeline() {
1148        let values: Vec<f64> = (0..50).map(|i| 200.0 + i as f64 * 0.1).collect();
1149        let data: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
1150        let params = simple_packing::compute_params(&values, 16, 0).unwrap();
1151
1152        let config = PipelineConfig {
1153            encoding: EncodingType::SimplePacking(params),
1154            filter: FilterType::None,
1155            compression: CompressionType::None,
1156            num_values: values.len(),
1157            byte_order: ByteOrder::Little,
1158            dtype_byte_width: 8,
1159            swap_unit_size: 8,
1160            compression_backend: CompressionBackend::default(),
1161            intra_codec_threads: 0,
1162            compute_hash: false,
1163        };
1164
1165        let result = encode_pipeline(&data, &config).unwrap();
1166        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
1167        let decoded_values = bytes_to_f64(&decoded, ByteOrder::Little).unwrap();
1168
1169        for (orig, dec) in values.iter().zip(decoded_values.iter()) {
1170            assert!((orig - dec).abs() < 0.01, "orig={orig}, dec={dec}");
1171        }
1172    }
1173
1174    #[test]
1175    fn test_shuffle_pipeline() {
1176        let data: Vec<u8> = (0..16).collect();
1177        let config = PipelineConfig {
1178            encoding: EncodingType::None,
1179            filter: FilterType::Shuffle { element_size: 4 },
1180            compression: CompressionType::None,
1181            num_values: 4,
1182            byte_order: ByteOrder::Little,
1183            dtype_byte_width: 4,
1184            swap_unit_size: 4,
1185            compression_backend: CompressionBackend::default(),
1186            intra_codec_threads: 0,
1187            compute_hash: false,
1188        };
1189
1190        let result = encode_pipeline(&data, &config).unwrap();
1191        assert_ne!(result.encoded_bytes, data); // shuffled should differ
1192        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
1193        assert_eq!(decoded, data);
1194    }
1195
1196    #[cfg(any(feature = "szip", feature = "szip-pure"))]
1197    #[test]
1198    fn test_szip_round_trip_pipeline() {
1199        let data: Vec<u8> = (0..2048).map(|i| (i % 256) as u8).collect();
1200
1201        // AEC_DATA_PREPROCESS = 8 in both libaec-sys and tensogram-szip
1202        let preprocess_flag = 8u32;
1203
1204        let config = PipelineConfig {
1205            encoding: EncodingType::None,
1206            filter: FilterType::None,
1207            compression: CompressionType::Szip {
1208                rsi: 128,
1209                block_size: 16,
1210                flags: preprocess_flag,
1211                bits_per_sample: 8,
1212            },
1213            num_values: 2048,
1214            byte_order: ByteOrder::Little,
1215            dtype_byte_width: 1,
1216            swap_unit_size: 1,
1217            compression_backend: CompressionBackend::default(),
1218            intra_codec_threads: 0,
1219            compute_hash: false,
1220        };
1221
1222        let result = encode_pipeline(&data, &config).unwrap();
1223        assert!(result.block_offsets.is_some());
1224
1225        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
1226        assert_eq!(decoded, data);
1227    }
1228
1229    // -----------------------------------------------------------------------
1230    // byteswap utility tests
1231    // -----------------------------------------------------------------------
1232
1233    #[test]
1234    fn test_byteswap_noop_for_single_byte() {
1235        let mut data = vec![1, 2, 3, 4];
1236        let original = data.clone();
1237        byteswap(&mut data, 1).unwrap();
1238        assert_eq!(data, original);
1239        byteswap(&mut data, 0).unwrap();
1240        assert_eq!(data, original);
1241    }
1242
1243    #[test]
1244    fn test_byteswap_2_bytes() {
1245        let mut data = vec![0xAA, 0xBB, 0xCC, 0xDD];
1246        byteswap(&mut data, 2).unwrap();
1247        assert_eq!(data, vec![0xBB, 0xAA, 0xDD, 0xCC]);
1248    }
1249
1250    #[test]
1251    fn test_byteswap_4_bytes() {
1252        let mut data = vec![1, 2, 3, 4, 5, 6, 7, 8];
1253        byteswap(&mut data, 4).unwrap();
1254        assert_eq!(data, vec![4, 3, 2, 1, 8, 7, 6, 5]);
1255    }
1256
1257    #[test]
1258    fn test_byteswap_8_bytes() {
1259        let mut data: Vec<u8> = (1..=16).collect();
1260        byteswap(&mut data, 8).unwrap();
1261        assert_eq!(
1262            data,
1263            vec![8, 7, 6, 5, 4, 3, 2, 1, 16, 15, 14, 13, 12, 11, 10, 9]
1264        );
1265    }
1266
1267    #[test]
1268    fn test_byteswap_round_trip() {
1269        let original = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
1270        let mut data = original.clone();
1271        byteswap(&mut data, 4).unwrap();
1272        assert_ne!(data, original);
1273        byteswap(&mut data, 4).unwrap();
1274        assert_eq!(data, original);
1275    }
1276
1277    #[test]
1278    fn test_byteswap_misaligned_returns_error() {
1279        let mut data = vec![1, 2, 3, 4, 5]; // 5 bytes, not a multiple of 4
1280        let result = byteswap(&mut data, 4);
1281        assert!(result.is_err());
1282    }
1283
1284    // -----------------------------------------------------------------------
1285    // Native byte-order decode tests
1286    // -----------------------------------------------------------------------
1287
1288    #[test]
1289    fn test_decode_native_byte_order_encoding_none() {
1290        // Encode as big-endian float32 on a (likely) little-endian machine.
1291        let value: f32 = 42.0;
1292        let be_bytes = value.to_be_bytes();
1293        let config = PipelineConfig {
1294            encoding: EncodingType::None,
1295            filter: FilterType::None,
1296            compression: CompressionType::None,
1297            num_values: 1,
1298            byte_order: ByteOrder::Big,
1299            dtype_byte_width: 4,
1300            swap_unit_size: 4,
1301            compression_backend: CompressionBackend::default(),
1302            intra_codec_threads: 0,
1303            compute_hash: false,
1304        };
1305
1306        let result = encode_pipeline(&be_bytes, &config).unwrap();
1307
1308        // Decode with native_byte_order=true: should get native-endian bytes.
1309        let native_decoded = decode_pipeline(&result.encoded_bytes, &config, true).unwrap();
1310        let ne_value = f32::from_ne_bytes(native_decoded[..4].try_into().unwrap());
1311        assert_eq!(ne_value, value);
1312
1313        // Decode with native_byte_order=false: should get big-endian bytes.
1314        let wire_decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
1315        let be_value = f32::from_be_bytes(wire_decoded[..4].try_into().unwrap());
1316        assert_eq!(be_value, value);
1317    }
1318
1319    #[test]
1320    fn test_decode_native_byte_order_simple_packing() {
1321        let values: Vec<f64> = vec![100.0, 200.0, 300.0, 400.0];
1322        // Encode with big-endian byte order.
1323        let data: Vec<u8> = values.iter().flat_map(|v| v.to_be_bytes()).collect();
1324        let params = simple_packing::compute_params(&values, 24, 0).unwrap();
1325
1326        let config = PipelineConfig {
1327            encoding: EncodingType::SimplePacking(params),
1328            filter: FilterType::None,
1329            compression: CompressionType::None,
1330            num_values: values.len(),
1331            byte_order: ByteOrder::Big,
1332            dtype_byte_width: 8,
1333            swap_unit_size: 8,
1334            compression_backend: CompressionBackend::default(),
1335            intra_codec_threads: 0,
1336            compute_hash: false,
1337        };
1338
1339        let result = encode_pipeline(&data, &config).unwrap();
1340
1341        // Decode with native_byte_order=true: result should be native f64.
1342        let native_decoded = decode_pipeline(&result.encoded_bytes, &config, true).unwrap();
1343        let decoded_values: Vec<f64> = native_decoded
1344            .chunks_exact(8)
1345            .map(|c| f64::from_ne_bytes(c.try_into().unwrap()))
1346            .collect();
1347        for (orig, dec) in values.iter().zip(decoded_values.iter()) {
1348            assert!((orig - dec).abs() < 1.0, "orig={orig}, dec={dec}");
1349        }
1350
1351        // Decode with native_byte_order=false: result should be big-endian f64.
1352        let wire_decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
1353        let wire_values: Vec<f64> = wire_decoded
1354            .chunks_exact(8)
1355            .map(|c| f64::from_be_bytes(c.try_into().unwrap()))
1356            .collect();
1357        for (orig, dec) in values.iter().zip(wire_values.iter()) {
1358            assert!((orig - dec).abs() < 1.0, "orig={orig}, dec={dec}");
1359        }
1360    }
1361
1362    #[test]
1363    fn test_native_byte_order_same_as_wire_is_noop() {
1364        // When wire byte order == native, native_byte_order=true/false should
1365        // produce identical output (no swap needed either way).
1366        let values: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1367        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
1368
1369        let config = PipelineConfig {
1370            encoding: EncodingType::None,
1371            filter: FilterType::None,
1372            compression: CompressionType::None,
1373            num_values: values.len(),
1374            byte_order: ByteOrder::native(),
1375            dtype_byte_width: 4,
1376            swap_unit_size: 4,
1377            compression_backend: CompressionBackend::default(),
1378            intra_codec_threads: 0,
1379            compute_hash: false,
1380        };
1381
1382        let result = encode_pipeline(&data, &config).unwrap();
1383        let native_decoded = decode_pipeline(&result.encoded_bytes, &config, true).unwrap();
1384        let wire_decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
1385        assert_eq!(native_decoded, wire_decoded);
1386    }
1387
1388    #[test]
1389    fn test_decode_native_byte_order_2byte_dtype() {
1390        // int16 / uint16 / float16 — 2-byte swap unit.
1391        let value: u16 = 0x0102;
1392        let be_bytes = value.to_be_bytes();
1393        let config = PipelineConfig {
1394            encoding: EncodingType::None,
1395            filter: FilterType::None,
1396            compression: CompressionType::None,
1397            num_values: 1,
1398            byte_order: ByteOrder::Big,
1399            dtype_byte_width: 2,
1400            swap_unit_size: 2,
1401            compression_backend: CompressionBackend::default(),
1402            intra_codec_threads: 0,
1403            compute_hash: false,
1404        };
1405        let result = encode_pipeline(&be_bytes, &config).unwrap();
1406        let native = decode_pipeline(&result.encoded_bytes, &config, true).unwrap();
1407        assert_eq!(u16::from_ne_bytes(native[..2].try_into().unwrap()), value);
1408    }
1409
1410    #[test]
1411    fn test_decode_native_byte_order_8byte_dtype() {
1412        // float64 / int64 / uint64 — 8-byte swap unit.
1413        let value: f64 = std::f64::consts::E;
1414        let be_bytes = value.to_be_bytes();
1415        let config = PipelineConfig {
1416            encoding: EncodingType::None,
1417            filter: FilterType::None,
1418            compression: CompressionType::None,
1419            num_values: 1,
1420            byte_order: ByteOrder::Big,
1421            dtype_byte_width: 8,
1422            swap_unit_size: 8,
1423            compression_backend: CompressionBackend::default(),
1424            intra_codec_threads: 0,
1425            compute_hash: false,
1426        };
1427        let result = encode_pipeline(&be_bytes, &config).unwrap();
1428        let native = decode_pipeline(&result.encoded_bytes, &config, true).unwrap();
1429        assert_eq!(f64::from_ne_bytes(native[..8].try_into().unwrap()), value);
1430    }
1431
1432    #[test]
1433    fn test_decode_native_byte_order_complex64() {
1434        // complex64 = two float32 — swap_unit_size=4, dtype_byte_width=8.
1435        // Each 4-byte component must be swapped independently.
1436        let real: f32 = 1.5;
1437        let imag: f32 = 2.5;
1438        let mut be_bytes = Vec::new();
1439        be_bytes.extend_from_slice(&real.to_be_bytes());
1440        be_bytes.extend_from_slice(&imag.to_be_bytes());
1441        let config = PipelineConfig {
1442            encoding: EncodingType::None,
1443            filter: FilterType::None,
1444            compression: CompressionType::None,
1445            num_values: 1,
1446            byte_order: ByteOrder::Big,
1447            dtype_byte_width: 8,
1448            swap_unit_size: 4, // complex64: swap each float32 component
1449            compression_backend: CompressionBackend::default(),
1450            intra_codec_threads: 0,
1451            compute_hash: false,
1452        };
1453        let result = encode_pipeline(&be_bytes, &config).unwrap();
1454        let native = decode_pipeline(&result.encoded_bytes, &config, true).unwrap();
1455        let decoded_real = f32::from_ne_bytes(native[0..4].try_into().unwrap());
1456        let decoded_imag = f32::from_ne_bytes(native[4..8].try_into().unwrap());
1457        assert_eq!(decoded_real, real);
1458        assert_eq!(decoded_imag, imag);
1459    }
1460
1461    #[test]
1462    fn test_decode_native_byte_order_uint8_noop() {
1463        // uint8 / int8 — swap_unit_size=1, byteswap should be a no-op.
1464        let data = vec![1u8, 2, 3, 4, 5];
1465        let config = PipelineConfig {
1466            encoding: EncodingType::None,
1467            filter: FilterType::None,
1468            compression: CompressionType::None,
1469            num_values: 5,
1470            byte_order: ByteOrder::Big, // cross-endian, but 1-byte → no-op
1471            dtype_byte_width: 1,
1472            swap_unit_size: 1,
1473            compression_backend: CompressionBackend::default(),
1474            intra_codec_threads: 0,
1475            compute_hash: false,
1476        };
1477        let result = encode_pipeline(&data, &config).unwrap();
1478        let native = decode_pipeline(&result.encoded_bytes, &config, true).unwrap();
1479        assert_eq!(native, data); // no swap for single-byte types
1480    }
1481
1482    // -----------------------------------------------------------------------
1483    // Hash-while-encoding tests — guard the invariant that
1484    // PipelineResult.hash (when compute_hash = true) is byte-equivalent to
1485    // xxh3_64(encoded_bytes) computed post-hoc.
1486    // -----------------------------------------------------------------------
1487
1488    /// Helper: minimal passthrough config with a flag for hash.
1489    fn passthrough_config(num_values: usize, compute_hash: bool) -> PipelineConfig {
1490        PipelineConfig {
1491            encoding: EncodingType::None,
1492            filter: FilterType::None,
1493            compression: CompressionType::None,
1494            num_values,
1495            byte_order: ByteOrder::Little,
1496            dtype_byte_width: 1,
1497            swap_unit_size: 1,
1498            compression_backend: CompressionBackend::default(),
1499            intra_codec_threads: 0,
1500            compute_hash,
1501        }
1502    }
1503
1504    #[test]
1505    fn streaming_and_oneshot_xxh3_agree() {
1506        // Regression guard against xxhash-rust API drift: our fused path
1507        // relies on `Xxh3Default::new().update(chunks).digest()` producing
1508        // bit-identical output to `xxh3_64(concat(chunks))`.  If this ever
1509        // diverges, the hash-while-encoding optimisation would silently
1510        // corrupt hash values.
1511        use xxhash_rust::xxh3::{Xxh3Default, xxh3_64};
1512
1513        for size in [0usize, 1, 239, 240, 1024 * 1024 + 17] {
1514            let data: Vec<u8> = (0..size).map(|i| (i * 31 + 7) as u8).collect();
1515
1516            // Full one-shot.
1517            let one_shot = xxh3_64(&data);
1518
1519            // Streaming, 64 KiB chunks (matches copy_and_hash).
1520            let mut h = Xxh3Default::new();
1521            for chunk in data.chunks(64 * 1024) {
1522                h.update(chunk);
1523            }
1524            assert_eq!(h.digest(), one_shot, "streaming vs one-shot at size {size}");
1525
1526            // Streaming, 1-byte chunks — worst case for internal buffering.
1527            let mut h = Xxh3Default::new();
1528            for chunk in data.chunks(1) {
1529                h.update(chunk);
1530            }
1531            assert_eq!(
1532                h.digest(),
1533                one_shot,
1534                "streaming 1-byte chunks vs one-shot at size {size}"
1535            );
1536        }
1537    }
1538
1539    #[test]
1540    fn pipeline_hash_none_when_disabled() {
1541        let data: Vec<u8> = (0..64).collect();
1542        let config = passthrough_config(data.len(), /* compute_hash = */ false);
1543        let result = encode_pipeline(&data, &config).unwrap();
1544        assert!(
1545            result.hash.is_none(),
1546            "compute_hash = false must leave PipelineResult.hash = None"
1547        );
1548    }
1549
1550    #[test]
1551    fn pipeline_hash_matches_post_hoc_for_passthrough() {
1552        use xxhash_rust::xxh3::xxh3_64;
1553
1554        // Exercise the sizes that hit each branch of `copy_and_hash` — below
1555        // one chunk, exactly one chunk, and multiple chunks.
1556        for size in [0usize, 1, 64 * 1024 - 1, 64 * 1024, 64 * 1024 + 1, 250_000] {
1557            let data: Vec<u8> = (0..size).map(|i| (i as u32 ^ 0xA5A5A5A5) as u8).collect();
1558            let config = passthrough_config(size, true);
1559            let result = encode_pipeline(&data, &config).unwrap();
1560            let expected = xxh3_64(&result.encoded_bytes);
1561            assert_eq!(
1562                result.hash,
1563                Some(expected),
1564                "passthrough hash-while-encoding mismatch at size {size}"
1565            );
1566            assert_eq!(
1567                result.encoded_bytes, data,
1568                "passthrough must still produce identical bytes at size {size}"
1569            );
1570        }
1571    }
1572
1573    #[test]
1574    fn pipeline_hash_matches_post_hoc_for_simple_packing() {
1575        use xxhash_rust::xxh3::xxh3_64;
1576
1577        let values: Vec<f64> = (0..10_000).map(|i| 200.0 + i as f64 * 0.1).collect();
1578        let data: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
1579        let params = simple_packing::compute_params(&values, 16, 0).unwrap();
1580
1581        let config = PipelineConfig {
1582            encoding: EncodingType::SimplePacking(params),
1583            filter: FilterType::None,
1584            compression: CompressionType::None,
1585            num_values: values.len(),
1586            byte_order: ByteOrder::Little,
1587            dtype_byte_width: 8,
1588            swap_unit_size: 8,
1589            compression_backend: CompressionBackend::default(),
1590            intra_codec_threads: 0,
1591            compute_hash: true,
1592        };
1593        let result = encode_pipeline(&data, &config).unwrap();
1594        let expected = xxh3_64(&result.encoded_bytes);
1595        assert_eq!(result.hash, Some(expected));
1596    }
1597
1598    #[cfg(feature = "lz4")]
1599    #[test]
1600    fn pipeline_hash_matches_post_hoc_for_lz4() {
1601        use xxhash_rust::xxh3::xxh3_64;
1602
1603        let data: Vec<u8> = (0..16_000).map(|i| (i % 257) as u8).collect();
1604        let config = PipelineConfig {
1605            encoding: EncodingType::None,
1606            filter: FilterType::None,
1607            compression: CompressionType::Lz4,
1608            num_values: data.len(),
1609            byte_order: ByteOrder::Little,
1610            dtype_byte_width: 1,
1611            swap_unit_size: 1,
1612            compression_backend: CompressionBackend::default(),
1613            intra_codec_threads: 0,
1614            compute_hash: true,
1615        };
1616        let result = encode_pipeline(&data, &config).unwrap();
1617        let expected = xxh3_64(&result.encoded_bytes);
1618        assert_eq!(result.hash, Some(expected));
1619    }
1620
1621    #[test]
1622    fn pipeline_f64_hash_matches_post_hoc() {
1623        use xxhash_rust::xxh3::xxh3_64;
1624
1625        let values: Vec<f64> = (0..1_000).map(|i| (i as f64).sqrt()).collect();
1626        let config = PipelineConfig {
1627            encoding: EncodingType::None,
1628            filter: FilterType::None,
1629            compression: CompressionType::None,
1630            num_values: values.len(),
1631            byte_order: ByteOrder::Little,
1632            dtype_byte_width: 8,
1633            swap_unit_size: 8,
1634            compression_backend: CompressionBackend::default(),
1635            intra_codec_threads: 0,
1636            compute_hash: true,
1637        };
1638        let result = encode_pipeline_f64(&values, &config).unwrap();
1639        let expected = xxh3_64(&result.encoded_bytes);
1640        assert_eq!(result.hash, Some(expected));
1641    }
1642
1643    #[test]
1644    fn pipeline_hash_byte_identical_across_threads_transparent() {
1645        // Transparent codec (simple_packing): hash at threads=0 must equal
1646        // hash at threads=N for every N.  Opaque codecs are checked in the
1647        // integration suite (they allow per-run byte differences).
1648        let values: Vec<f64> = (0..50_000)
1649            .map(|i| 280.0 + (i as f64 * 0.001).sin())
1650            .collect();
1651        let data: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
1652        let params = simple_packing::compute_params(&values, 24, 0).unwrap();
1653
1654        let mut hashes = Vec::new();
1655        for threads in [0u32, 1, 2, 4] {
1656            let config = PipelineConfig {
1657                encoding: EncodingType::SimplePacking(params.clone()),
1658                filter: FilterType::None,
1659                compression: CompressionType::None,
1660                num_values: values.len(),
1661                byte_order: ByteOrder::Little,
1662                dtype_byte_width: 8,
1663                swap_unit_size: 8,
1664                compression_backend: CompressionBackend::default(),
1665                intra_codec_threads: threads,
1666                compute_hash: true,
1667            };
1668            let result = encode_pipeline(&data, &config).unwrap();
1669            hashes.push(result.hash);
1670        }
1671        assert!(
1672            hashes.windows(2).all(|w| w[0] == w[1]),
1673            "transparent simple_packing must produce byte-identical hashes across thread counts: {hashes:?}"
1674        );
1675    }
1676
1677    // ── Preallocation-DoS hardening (end-to-end) ────────────────────────
1678    //
1679    // Integration tests for the cross-codec `expected_size` preallocation
1680    // hardening.  A malformed descriptor with a hostile `num_values` must
1681    // surface as a `PipelineError::Compression` through the normal error
1682    // channel, not as a process abort.  Covers both szip backends when
1683    // both features are compiled in.
1684
1685    #[cfg(any(feature = "szip", feature = "szip-pure"))]
1686    fn szip_hostile_config(num_values: usize, backend: CompressionBackend) -> PipelineConfig {
1687        PipelineConfig {
1688            encoding: EncodingType::None,
1689            filter: FilterType::None,
1690            compression: CompressionType::Szip {
1691                bits_per_sample: 8,
1692                block_size: 16,
1693                rsi: 64,
1694                flags: 0,
1695            },
1696            num_values,
1697            byte_order: ByteOrder::Little,
1698            dtype_byte_width: 1,
1699            swap_unit_size: 1,
1700            compression_backend: backend,
1701            intra_codec_threads: 0,
1702            compute_hash: false,
1703        }
1704    }
1705
1706    #[cfg(any(feature = "szip", feature = "szip-pure"))]
1707    fn small_szip_payload(backend: CompressionBackend) -> Vec<u8> {
1708        // Encode 256 bytes honestly with the selected backend so we have
1709        // a real szip-compressed payload the decode pipeline will accept
1710        // up to the reservation step.
1711        let data: Vec<u8> = (0..256).map(|i| i as u8).collect();
1712        let honest = PipelineConfig {
1713            num_values: data.len(),
1714            ..szip_hostile_config(data.len(), backend)
1715        };
1716        encode_pipeline(&data, &honest).unwrap().encoded_bytes
1717    }
1718
1719    #[cfg(feature = "szip")]
1720    #[test]
1721    fn pipeline_rejects_malicious_num_values_szip_ffi() {
1722        let payload = small_szip_payload(CompressionBackend::Ffi);
1723        let hostile = szip_hostile_config(usize::MAX, CompressionBackend::Ffi);
1724
1725        let err = decode_pipeline(&payload, &hostile, false)
1726            .expect_err("expected allocation failure, not success nor abort");
1727        let msg = format!("{err}");
1728        assert!(
1729            msg.contains("failed to reserve"),
1730            "error should report allocation failure, got: {msg}"
1731        );
1732    }
1733
1734    #[cfg(feature = "szip-pure")]
1735    #[test]
1736    fn pipeline_rejects_malicious_num_values_szip_pure() {
1737        let payload = small_szip_payload(CompressionBackend::Pure);
1738        let hostile = szip_hostile_config(usize::MAX, CompressionBackend::Pure);
1739
1740        let err = decode_pipeline(&payload, &hostile, false)
1741            .expect_err("expected allocation failure, not success nor abort");
1742        let msg = format!("{err}");
1743        assert!(
1744            msg.contains("failed to reserve"),
1745            "error should report allocation failure, got: {msg}"
1746        );
1747    }
1748
1749    #[test]
1750    fn pipeline_rejects_malicious_num_values_simple_packing() {
1751        // simple_packing with no compressor is the easiest descriptor-
1752        // driven abort path after the szip hardening: a malformed shape
1753        // takes `num_values` straight into simple_packing::decode_with_threads.
1754        let values: Vec<f64> = (0..10).map(|i| i as f64).collect();
1755        let data: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
1756        let sp_params = simple_packing::compute_params(&values, 16, 0).unwrap();
1757
1758        let honest = PipelineConfig {
1759            encoding: EncodingType::SimplePacking(sp_params.clone()),
1760            filter: FilterType::None,
1761            compression: CompressionType::None,
1762            num_values: values.len(),
1763            byte_order: ByteOrder::Little,
1764            dtype_byte_width: 8,
1765            swap_unit_size: 8,
1766            compression_backend: CompressionBackend::default(),
1767            intra_codec_threads: 0,
1768            compute_hash: false,
1769        };
1770        let payload = encode_pipeline(&data, &honest).unwrap().encoded_bytes;
1771
1772        let hostile = PipelineConfig {
1773            num_values: usize::MAX,
1774            ..honest
1775        };
1776        let err = decode_pipeline(&payload, &hostile, false)
1777            .expect_err("pathological num_values on simple_packing must surface as an error");
1778        // With the structural descriptor-size gate in place, an
1779        // uncompressed simple_packing pipeline is now rejected *before*
1780        // any decode work, with the precise size-mismatch error rather
1781        // than a downstream overflow / allocation failure.
1782        assert!(
1783            matches!(err, PipelineError::DescriptorSizeMismatch { .. }),
1784            "expected DescriptorSizeMismatch, got: {err}"
1785        );
1786    }
1787
1788    #[cfg(feature = "zfp")]
1789    #[test]
1790    fn pipeline_rejects_malicious_num_values_zfp() {
1791        let values: Vec<f64> = (0..64).map(|i| (i as f64) * 0.1).collect();
1792        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
1793
1794        let honest = PipelineConfig {
1795            encoding: EncodingType::None,
1796            filter: FilterType::None,
1797            compression: CompressionType::Zfp {
1798                mode: ZfpMode::FixedRate { rate: 16.0 },
1799            },
1800            num_values: values.len(),
1801            byte_order: ByteOrder::native(),
1802            dtype_byte_width: 8,
1803            swap_unit_size: 8,
1804            compression_backend: CompressionBackend::default(),
1805            intra_codec_threads: 0,
1806            compute_hash: false,
1807        };
1808        let payload = encode_pipeline(&data, &honest).unwrap().encoded_bytes;
1809
1810        let hostile = PipelineConfig {
1811            num_values: usize::MAX,
1812            ..honest
1813        };
1814        let err = decode_pipeline(&payload, &hostile, false)
1815            .expect_err("pathological num_values on zfp must surface as an error");
1816        let msg = format!("{err}");
1817        assert!(
1818            msg.contains("failed to reserve"),
1819            "error should report allocation failure, got: {msg}"
1820        );
1821    }
1822
1823    // -----------------------------------------------------------------------
1824    // Structural descriptor ↔ payload size consistency gate
1825    // (`validate_descriptor_size`).  Exact-equality tiers only:
1826    // uncompressed `encoding=none` and uncompressed `simple_packing`.
1827    // A mismatch in either direction is categorically malformed input
1828    // and must be rejected before any decode work.
1829    // -----------------------------------------------------------------------
1830
1831    /// none / none config over `num_values` 1-byte elements.
1832    fn sizecheck_none_config(num_values: usize, dtype_byte_width: usize) -> PipelineConfig {
1833        PipelineConfig {
1834            encoding: EncodingType::None,
1835            filter: FilterType::None,
1836            compression: CompressionType::None,
1837            num_values,
1838            byte_order: ByteOrder::Little,
1839            dtype_byte_width,
1840            swap_unit_size: dtype_byte_width,
1841            compression_backend: CompressionBackend::default(),
1842            intra_codec_threads: 0,
1843            compute_hash: false,
1844        }
1845    }
1846
1847    #[test]
1848    fn descriptor_size_none_exact_match_succeeds() {
1849        // 4 elements × 4 bytes = 16-byte payload — exact, must decode.
1850        let payload = vec![0u8; 16];
1851        let cfg = sizecheck_none_config(4, 4);
1852        let out = decode_pipeline(&payload, &cfg, false).expect("exact size must decode");
1853        assert_eq!(out.len(), 16);
1854    }
1855
1856    #[test]
1857    fn descriptor_size_none_too_small_payload_rejected() {
1858        // Descriptor claims 4×4 = 16 bytes but payload is 15 (truncation).
1859        let payload = vec![0u8; 15];
1860        let cfg = sizecheck_none_config(4, 4);
1861        let err =
1862            decode_pipeline(&payload, &cfg, false).expect_err("short payload must be rejected");
1863        match err {
1864            PipelineError::DescriptorSizeMismatch {
1865                claimed_bytes,
1866                payload_bytes,
1867                ..
1868            } => {
1869                assert_eq!(claimed_bytes, 16);
1870                assert_eq!(payload_bytes, 15);
1871            }
1872            other => panic!("expected DescriptorSizeMismatch, got: {other}"),
1873        }
1874    }
1875
1876    #[test]
1877    fn descriptor_size_none_too_large_payload_rejected() {
1878        // Descriptor claims 16 bytes but payload is 17 (trailing junk /
1879        // wrong descriptor).  The too-much-data direction is malformed too.
1880        let payload = vec![0u8; 17];
1881        let cfg = sizecheck_none_config(4, 4);
1882        let err =
1883            decode_pipeline(&payload, &cfg, false).expect_err("over-long payload must be rejected");
1884        assert!(
1885            matches!(
1886                err,
1887                PipelineError::DescriptorSizeMismatch {
1888                    claimed_bytes: 16,
1889                    payload_bytes: 17,
1890                    ..
1891                }
1892            ),
1893            "expected 16-vs-17 mismatch, got: {err}"
1894        );
1895    }
1896
1897    #[test]
1898    fn descriptor_size_bitmask_uses_ceil_div_8() {
1899        // Bitmask dtype reports byte_width 0; decoded size is ceil(N/8).
1900        // 17 bits → ceil(17/8) = 3 bytes.
1901        let cfg_ok = sizecheck_none_config(17, 0);
1902        assert!(decode_pipeline(&[0u8; 3], &cfg_ok, false).is_ok());
1903        // 2 bytes is too small for 17 bits.
1904        let err = decode_pipeline(&[0u8; 2], &cfg_ok, false)
1905            .expect_err("bitmask short payload must be rejected");
1906        assert!(
1907            matches!(
1908                err,
1909                PipelineError::DescriptorSizeMismatch {
1910                    claimed_bytes: 3,
1911                    payload_bytes: 2,
1912                    ..
1913                }
1914            ),
1915            "expected 3-vs-2 mismatch, got: {err}"
1916        );
1917    }
1918
1919    #[test]
1920    fn descriptor_size_none_hostile_num_values_rejected_before_alloc() {
1921        // The headline case: a tiny payload with a descriptor claiming a
1922        // terabyte-scale tensor.  Must be rejected structurally — no
1923        // allocation, no panic — with the precise size-mismatch error.
1924        let payload = vec![0u8; 8];
1925        let cfg = sizecheck_none_config(usize::MAX, 8);
1926        let err = decode_pipeline(&payload, &cfg, false)
1927            .expect_err("hostile num_values must be rejected");
1928        assert!(
1929            matches!(err, PipelineError::DescriptorSizeMismatch { .. }),
1930            "expected DescriptorSizeMismatch, got: {err}"
1931        );
1932    }
1933
1934    #[test]
1935    fn descriptor_size_simple_packing_exact_and_mismatch() {
1936        // Honest round-trip first to obtain a real packed payload.
1937        let values: Vec<f64> = (0..10).map(|i| i as f64).collect();
1938        let data: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
1939        let params = simple_packing::compute_params(&values, 16, 0).unwrap();
1940        let cfg = PipelineConfig {
1941            encoding: EncodingType::SimplePacking(params),
1942            filter: FilterType::None,
1943            compression: CompressionType::None,
1944            num_values: values.len(),
1945            byte_order: ByteOrder::Little,
1946            dtype_byte_width: 8,
1947            swap_unit_size: 8,
1948            compression_backend: CompressionBackend::default(),
1949            intra_codec_threads: 0,
1950            compute_hash: false,
1951        };
1952        let payload = encode_pipeline(&data, &cfg).unwrap().encoded_bytes;
1953        // 10 values × 16 bits = 160 bits = 20 bytes.
1954        assert_eq!(payload.len(), 20);
1955        // Exact match decodes.
1956        assert!(decode_pipeline(&payload, &cfg, false).is_ok());
1957
1958        // Append one trailing byte → too-much-data, now rejected (this
1959        // closes the gap where the simple_packing decoder previously
1960        // tolerated extra trailing bytes).
1961        let mut over = payload.clone();
1962        over.push(0);
1963        let err = decode_pipeline(&over, &cfg, false)
1964            .expect_err("trailing-byte payload must be rejected");
1965        assert!(
1966            matches!(
1967                err,
1968                PipelineError::DescriptorSizeMismatch {
1969                    claimed_bytes: 20,
1970                    payload_bytes: 21,
1971                    ..
1972                }
1973            ),
1974            "expected 20-vs-21 mismatch, got: {err}"
1975        );
1976    }
1977
1978    #[test]
1979    fn descriptor_size_gate_is_noop_for_compressed() {
1980        // A compressed pipeline must NOT be gated by the exact-size
1981        // check (its on-disk length is the compressed length, not the
1982        // descriptor-derived decoded size).  An honest zstd round-trip
1983        // whose payload length differs from num_values × width must
1984        // still decode cleanly.
1985        #[cfg(any(feature = "zstd", feature = "zstd-pure"))]
1986        {
1987            // Use a large, trivially-compressible buffer (all zeros) so
1988            // the compressed payload is *guaranteed* shorter than the
1989            // decoded length on any zstd version — this deterministically
1990            // exercises the "compressed length != descriptor-derived
1991            // decoded length" case without relying on incidental codec
1992            // sizing for a small mixed input.
1993            let data: Vec<u8> = vec![0u8; 64 * 1024];
1994            let cfg = PipelineConfig {
1995                encoding: EncodingType::None,
1996                filter: FilterType::None,
1997                compression: CompressionType::Zstd { level: 3 },
1998                num_values: data.len(),
1999                byte_order: ByteOrder::Little,
2000                dtype_byte_width: 1,
2001                swap_unit_size: 1,
2002                compression_backend: CompressionBackend::default(),
2003                intra_codec_threads: 0,
2004                compute_hash: false,
2005            };
2006            let payload = encode_pipeline(&data, &cfg).unwrap().encoded_bytes;
2007            // 64 KiB of zeros compresses far below 64 KiB under any zstd
2008            // build, so the on-disk length provably differs from the
2009            // descriptor-derived decoded length — yet the gate must not
2010            // fire and the object must round-trip.
2011            assert!(
2012                payload.len() < data.len(),
2013                "64 KiB of zeros must compress smaller; got {} >= {}",
2014                payload.len(),
2015                data.len()
2016            );
2017            let out = decode_pipeline(&payload, &cfg, false).expect("compressed must decode");
2018            assert_eq!(out, data);
2019        }
2020    }
2021
2022    #[test]
2023    fn descriptor_size_gate_is_noop_for_shuffle_filter() {
2024        // A shuffle filter is size-preserving but the gate intentionally
2025        // skips any filtered pipeline.  An honest shuffle round-trip must
2026        // still decode (proving the gate didn't false-reject it).
2027        let values: Vec<f32> = (0..8).map(|i| i as f32).collect();
2028        let data: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
2029        let cfg = PipelineConfig {
2030            encoding: EncodingType::None,
2031            filter: FilterType::Shuffle { element_size: 4 },
2032            compression: CompressionType::None,
2033            num_values: values.len(),
2034            byte_order: ByteOrder::Little,
2035            dtype_byte_width: 4,
2036            swap_unit_size: 4,
2037            compression_backend: CompressionBackend::default(),
2038            intra_codec_threads: 0,
2039            compute_hash: false,
2040        };
2041        let payload = encode_pipeline(&data, &cfg).unwrap().encoded_bytes;
2042        let out = decode_pipeline(&payload, &cfg, false).expect("shuffle must decode");
2043        assert_eq!(out, data);
2044    }
2045
2046    #[test]
2047    fn descriptor_size_range_path_rejects_mismatch() {
2048        // The range decoder runs the same structural gate.  A
2049        // none/none descriptor claiming more elements than the payload
2050        // holds is rejected before any slicing, even though the
2051        // requested range itself is small.
2052        let cfg = sizecheck_none_config(/*num_values=*/ 100, /*width=*/ 4);
2053        // Payload only holds 4 elements (16 bytes), not the claimed 100.
2054        let payload = vec![0u8; 16];
2055        let err = decode_range_pipeline(&payload, &cfg, &[], 0, 2, false)
2056            .expect_err("range decode must reject a descriptor/payload size mismatch");
2057        assert!(
2058            matches!(
2059                err,
2060                PipelineError::DescriptorSizeMismatch {
2061                    claimed_bytes: 400,
2062                    payload_bytes: 16,
2063                    ..
2064                }
2065            ),
2066            "expected 400-vs-16 mismatch from the range path, got: {err}"
2067        );
2068    }
2069
2070    #[test]
2071    fn descriptor_size_range_path_accepts_exact_match() {
2072        // Counterpart: when the payload exactly matches the descriptor,
2073        // the range path decodes the requested slice normally.
2074        let cfg = sizecheck_none_config(/*num_values=*/ 4, /*width=*/ 4);
2075        let payload: Vec<u8> = (0..16).collect();
2076        let out = decode_range_pipeline(&payload, &cfg, &[], 1, 2, false)
2077            .expect("exact-size range decode must succeed");
2078        // 2 elements × 4 bytes, starting at element 1.
2079        assert_eq!(out, payload[4..12]);
2080    }
2081
2082    // -----------------------------------------------------------------------
2083    // encode_pipeline_f64 coverage: simple_packing encoding and shuffle
2084    // filter branches that the byte-oriented `encode_pipeline` tests do not
2085    // exercise.
2086    // -----------------------------------------------------------------------
2087
2088    #[test]
2089    fn encode_pipeline_f64_simple_packing_round_trip() {
2090        // Exercises the SimplePacking branch of `encode_pipeline_f64`
2091        // (the f64 entry point skips the bytes→f64 conversion and packs
2092        // directly).  Round-trips through decode_pipeline.
2093        let values: Vec<f64> = (0..64).map(|i| 250.0 + i as f64 * 0.25).collect();
2094        let params = simple_packing::compute_params(&values, 16, 0).unwrap();
2095        let config = PipelineConfig {
2096            encoding: EncodingType::SimplePacking(params),
2097            filter: FilterType::None,
2098            compression: CompressionType::None,
2099            num_values: values.len(),
2100            byte_order: ByteOrder::Little,
2101            dtype_byte_width: 8,
2102            swap_unit_size: 8,
2103            compression_backend: CompressionBackend::default(),
2104            intra_codec_threads: 0,
2105            compute_hash: false,
2106        };
2107        let result = encode_pipeline_f64(&values, &config).unwrap();
2108        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2109        let decoded_values = bytes_to_f64(&decoded, ByteOrder::Little).unwrap();
2110        for (orig, dec) in values.iter().zip(decoded_values.iter()) {
2111            assert!((orig - dec).abs() < 0.01, "orig={orig}, dec={dec}");
2112        }
2113    }
2114
2115    #[test]
2116    fn encode_pipeline_f64_shuffle_filter_round_trip() {
2117        // Exercises the Shuffle branch of `encode_pipeline_f64`.
2118        let values: Vec<f64> = (0..16).map(|i| i as f64 * 1.5).collect();
2119        let config = PipelineConfig {
2120            encoding: EncodingType::None,
2121            filter: FilterType::Shuffle { element_size: 8 },
2122            compression: CompressionType::None,
2123            num_values: values.len(),
2124            byte_order: ByteOrder::Little,
2125            dtype_byte_width: 8,
2126            swap_unit_size: 8,
2127            compression_backend: CompressionBackend::default(),
2128            intra_codec_threads: 0,
2129            compute_hash: false,
2130        };
2131        let result = encode_pipeline_f64(&values, &config).unwrap();
2132        // Shuffled bytes differ from the plain little-endian serialisation.
2133        let plain: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
2134        assert_ne!(result.encoded_bytes, plain);
2135        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2136        assert_eq!(decoded, plain);
2137    }
2138
2139    #[cfg(feature = "lz4")]
2140    #[test]
2141    fn encode_pipeline_f64_with_compression() {
2142        // Exercises the `Some(compressor)` arm of `encode_pipeline_f64`
2143        // (the f64 entry point feeding a real codec), which the
2144        // None-compression f64 tests do not reach.  Round-trips through
2145        // decode_pipeline.
2146        let values: Vec<f64> = (0..1000).map(|i| (i as f64).sqrt()).collect();
2147        let config = PipelineConfig {
2148            encoding: EncodingType::None,
2149            filter: FilterType::None,
2150            compression: CompressionType::Lz4,
2151            num_values: values.len(),
2152            byte_order: ByteOrder::Little,
2153            dtype_byte_width: 8,
2154            swap_unit_size: 8,
2155            compression_backend: CompressionBackend::default(),
2156            intra_codec_threads: 0,
2157            compute_hash: false,
2158        };
2159        let result = encode_pipeline_f64(&values, &config).unwrap();
2160        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2161        let plain: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
2162        assert_eq!(decoded, plain);
2163    }
2164
2165    #[test]
2166    fn encode_pipeline_f64_passthrough_none_encoding() {
2167        // The `EncodingType::None` branch of `encode_pipeline_f64`
2168        // serialises f64 values straight to the wire byte order.
2169        let values: Vec<f64> = vec![1.0, -2.0, 3.5, 4.25];
2170        let config = PipelineConfig {
2171            encoding: EncodingType::None,
2172            filter: FilterType::None,
2173            compression: CompressionType::None,
2174            num_values: values.len(),
2175            byte_order: ByteOrder::Big,
2176            dtype_byte_width: 8,
2177            swap_unit_size: 8,
2178            compression_backend: CompressionBackend::default(),
2179            intra_codec_threads: 0,
2180            compute_hash: false,
2181        };
2182        let result = encode_pipeline_f64(&values, &config).unwrap();
2183        let expected: Vec<u8> = values.iter().flat_map(|v| v.to_be_bytes()).collect();
2184        assert_eq!(result.encoded_bytes, expected);
2185    }
2186
2187    // -----------------------------------------------------------------------
2188    // decode_range_pipeline coverage: shuffle rejection and the
2189    // simple_packing range branch.
2190    // -----------------------------------------------------------------------
2191
2192    #[test]
2193    fn decode_range_pipeline_rejects_shuffle_filter() {
2194        // Partial range decode is explicitly unsupported with a shuffle
2195        // filter — it must fail fast with a Shuffle error rather than
2196        // produce wrong bytes.
2197        let cfg = PipelineConfig {
2198            encoding: EncodingType::None,
2199            filter: FilterType::Shuffle { element_size: 4 },
2200            compression: CompressionType::None,
2201            num_values: 4,
2202            byte_order: ByteOrder::Little,
2203            dtype_byte_width: 4,
2204            swap_unit_size: 4,
2205            compression_backend: CompressionBackend::default(),
2206            intra_codec_threads: 0,
2207            compute_hash: false,
2208        };
2209        let payload = vec![0u8; 16];
2210        let err = decode_range_pipeline(&payload, &cfg, &[], 0, 2, false)
2211            .expect_err("shuffle filter must be rejected by range decode");
2212        match err {
2213            PipelineError::Shuffle(msg) => {
2214                assert!(
2215                    msg.contains("not supported"),
2216                    "expected unsupported-shuffle message, got: {msg}"
2217                );
2218            }
2219            other => panic!("expected Shuffle error, got: {other}"),
2220        }
2221    }
2222
2223    #[test]
2224    fn decode_range_pipeline_simple_packing_uncompressed() {
2225        // Exercises the SimplePacking arm of `decode_range_pipeline`:
2226        // bit-offset computation, decode_range, then f64_to_bytes in the
2227        // target byte order.  No compressor → the uncompressed slice path.
2228        let values: Vec<f64> = (0..32).map(|i| i as f64).collect();
2229        let params = simple_packing::compute_params(&values, 16, 0).unwrap();
2230        let cfg = PipelineConfig {
2231            encoding: EncodingType::SimplePacking(params),
2232            filter: FilterType::None,
2233            compression: CompressionType::None,
2234            num_values: values.len(),
2235            byte_order: ByteOrder::Little,
2236            dtype_byte_width: 8,
2237            swap_unit_size: 8,
2238            compression_backend: CompressionBackend::default(),
2239            intra_codec_threads: 0,
2240            compute_hash: false,
2241        };
2242        let payload = encode_pipeline_f64(&values, &cfg).unwrap().encoded_bytes;
2243
2244        // Decode elements [8, 8+10) via the range path.
2245        let out = decode_range_pipeline(&payload, &cfg, &[], 8, 10, false)
2246            .expect("simple_packing range decode must succeed");
2247        let decoded: Vec<f64> = out
2248            .chunks_exact(8)
2249            .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
2250            .collect();
2251        assert_eq!(decoded.len(), 10);
2252        for (i, v) in decoded.iter().enumerate() {
2253            assert!(
2254                (v - (i + 8) as f64).abs() < 0.5,
2255                "range elem {i}: got {v}, want ~{}",
2256                i + 8
2257            );
2258        }
2259    }
2260
2261    #[test]
2262    fn decode_range_pipeline_simple_packing_native_byte_order() {
2263        // Same as above but with native_byte_order=true and a big-endian
2264        // wire order, so the SimplePacking range arm serialises to native.
2265        let values: Vec<f64> = (0..40).map(|i| 500.0 + i as f64).collect();
2266        let params = simple_packing::compute_params(&values, 16, 0).unwrap();
2267        let cfg = PipelineConfig {
2268            encoding: EncodingType::SimplePacking(params),
2269            filter: FilterType::None,
2270            compression: CompressionType::None,
2271            num_values: values.len(),
2272            byte_order: ByteOrder::Big,
2273            dtype_byte_width: 8,
2274            swap_unit_size: 8,
2275            compression_backend: CompressionBackend::default(),
2276            intra_codec_threads: 0,
2277            compute_hash: false,
2278        };
2279        let payload = encode_pipeline_f64(&values, &cfg).unwrap().encoded_bytes;
2280        let out = decode_range_pipeline(&payload, &cfg, &[], 0, 5, true)
2281            .expect("native-order simple_packing range decode must succeed");
2282        let decoded: Vec<f64> = out
2283            .chunks_exact(8)
2284            .map(|c| f64::from_ne_bytes(c.try_into().unwrap()))
2285            .collect();
2286        for (i, v) in decoded.iter().enumerate() {
2287            assert!((v - (500.0 + i as f64)).abs() < 1.0, "elem {i}: {v}");
2288        }
2289    }
2290
2291    #[test]
2292    fn decode_range_pipeline_none_native_byte_swap() {
2293        // encoding=none range decode with native_byte_order=true and a
2294        // cross-endian wire order exercises the byteswap arm of the
2295        // range path (Phase 3, EncodingType::None native branch).
2296        let values: Vec<u32> = vec![0x0102_0304, 0x0506_0708, 0x090A_0B0C, 0x0D0E_0F10];
2297        let data: Vec<u8> = values.iter().flat_map(|v| v.to_be_bytes()).collect();
2298        let cfg = PipelineConfig {
2299            encoding: EncodingType::None,
2300            filter: FilterType::None,
2301            compression: CompressionType::None,
2302            num_values: values.len(),
2303            byte_order: ByteOrder::Big,
2304            dtype_byte_width: 4,
2305            swap_unit_size: 4,
2306            compression_backend: CompressionBackend::default(),
2307            intra_codec_threads: 0,
2308            compute_hash: false,
2309        };
2310        // Decode elements [1, 3) with native byte order requested.
2311        let out = decode_range_pipeline(&data, &cfg, &[], 1, 2, true)
2312            .expect("native-order none range decode must succeed");
2313        let decoded: Vec<u32> = out
2314            .chunks_exact(4)
2315            .map(|c| u32::from_ne_bytes(c.try_into().unwrap()))
2316            .collect();
2317        assert_eq!(decoded, vec![0x0506_0708, 0x090A_0B0C]);
2318    }
2319
2320    #[cfg(any(feature = "szip", feature = "szip-pure"))]
2321    #[test]
2322    fn decode_range_pipeline_szip_compressed_range() {
2323        // Exercises the `Some(compressor).decompress_range(...)` arm of
2324        // `decode_range_pipeline` using szip, which supports random
2325        // access via block offsets.
2326        let data: Vec<u8> = (0..2048).map(|i| (i % 256) as u8).collect();
2327        let config = PipelineConfig {
2328            encoding: EncodingType::None,
2329            filter: FilterType::None,
2330            compression: CompressionType::Szip {
2331                rsi: 128,
2332                block_size: 16,
2333                flags: 8, // AEC_DATA_PREPROCESS
2334                bits_per_sample: 8,
2335            },
2336            num_values: data.len(),
2337            byte_order: ByteOrder::Little,
2338            dtype_byte_width: 1,
2339            swap_unit_size: 1,
2340            compression_backend: CompressionBackend::default(),
2341            intra_codec_threads: 0,
2342            compute_hash: false,
2343        };
2344        let result = encode_pipeline(&data, &config).unwrap();
2345        let block_offsets = result.block_offsets.expect("szip yields block offsets");
2346
2347        // Decode elements [256, 256+128) via the compressed range path.
2348        let out = decode_range_pipeline(
2349            &result.encoded_bytes,
2350            &config,
2351            &block_offsets,
2352            256,
2353            128,
2354            false,
2355        )
2356        .expect("szip compressed range decode must succeed");
2357        assert_eq!(out, data[256..384]);
2358    }
2359
2360    #[test]
2361    fn decode_range_pipeline_none_byte_range_exceeds_payload() {
2362        // The encoding=none arm computes a byte range directly; requesting
2363        // a range past the payload end must surface a Range error rather
2364        // than slice out of bounds.  The payload is exactly the claimed
2365        // size (4 values * 4 bytes = 16), so the descriptor-size gate
2366        // passes and it's the range-bounds check that we exercise here.
2367        let cfg = sizecheck_none_config(/*num_values=*/ 4, /*width=*/ 4);
2368        let payload: Vec<u8> = (0..16).collect();
2369        // Request element 3 .. 3+4 = past the 4-element payload.
2370        let err = decode_range_pipeline(&payload, &cfg, &[], 3, 4, false)
2371            .expect_err("out-of-range slice must be rejected");
2372        match err {
2373            PipelineError::Range(msg) => {
2374                assert!(
2375                    msg.contains("exceeds payload size"),
2376                    "expected payload-size error, got: {msg}"
2377                );
2378            }
2379            other => panic!("expected Range error, got: {other}"),
2380        }
2381    }
2382
2383    // -----------------------------------------------------------------------
2384    // bytes_to_f64 / f64_to_bytes direct error & branch coverage.
2385    // -----------------------------------------------------------------------
2386
2387    #[test]
2388    fn bytes_to_f64_rejects_non_multiple_of_8() {
2389        // A byte length that is not a multiple of 8 must be rejected
2390        // rather than silently dropping the trailing partial value.
2391        let data = vec![0u8; 12]; // 12 is not a multiple of 8
2392        let err = bytes_to_f64(&data, ByteOrder::Little)
2393            .expect_err("non-multiple-of-8 length must be rejected");
2394        match err {
2395            PipelineError::Range(msg) => {
2396                assert!(msg.contains("not a multiple of 8"), "got: {msg}");
2397            }
2398            other => panic!("expected Range error, got: {other}"),
2399        }
2400    }
2401
2402    #[test]
2403    fn bytes_to_f64_big_endian_branch() {
2404        // Exercise the big-endian deserialisation branch of bytes_to_f64.
2405        let values = [1.0f64, -2.5, 3.75];
2406        let data: Vec<u8> = values.iter().flat_map(|v| v.to_be_bytes()).collect();
2407        let out = bytes_to_f64(&data, ByteOrder::Big).unwrap();
2408        assert_eq!(out, values);
2409    }
2410
2411    #[test]
2412    fn f64_to_bytes_both_byte_orders() {
2413        // Cover both arms of f64_to_bytes' byte-order match.
2414        let values = [10.0f64, 20.0, 30.0];
2415        let le = f64_to_bytes(&values, ByteOrder::Little).unwrap();
2416        let be = f64_to_bytes(&values, ByteOrder::Big).unwrap();
2417        let expected_le: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
2418        let expected_be: Vec<u8> = values.iter().flat_map(|v| v.to_be_bytes()).collect();
2419        assert_eq!(le, expected_le);
2420        assert_eq!(be, expected_be);
2421    }
2422
2423    #[test]
2424    fn try_clone_bytes_round_trips_payload() {
2425        // The happy path of try_clone_bytes: a normal-sized slice clones
2426        // into an owned Vec byte-for-byte.
2427        let src: Vec<u8> = (0..37).collect();
2428        let cloned = try_clone_bytes(&src).unwrap();
2429        assert_eq!(cloned, src);
2430    }
2431
2432    // -----------------------------------------------------------------------
2433    // build_compressor branch coverage: per-codec round-trips through the
2434    // full encode → decode pipeline.  These exercise the codec-construction
2435    // arms of `build_compressor` (blosc2, sz3, zstd, rle, roaring) that the
2436    // other pipeline tests do not reach.
2437    // -----------------------------------------------------------------------
2438
2439    #[cfg(any(feature = "zstd", feature = "zstd-pure"))]
2440    #[test]
2441    fn pipeline_zstd_round_trip() {
2442        let data: Vec<u8> = (0..4096).map(|i| (i * 7 % 251) as u8).collect();
2443        let config = PipelineConfig {
2444            encoding: EncodingType::None,
2445            filter: FilterType::None,
2446            compression: CompressionType::Zstd { level: 5 },
2447            num_values: data.len(),
2448            byte_order: ByteOrder::Little,
2449            dtype_byte_width: 1,
2450            swap_unit_size: 1,
2451            compression_backend: CompressionBackend::default(),
2452            intra_codec_threads: 0,
2453            compute_hash: false,
2454        };
2455        let result = encode_pipeline(&data, &config).unwrap();
2456        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2457        assert_eq!(decoded, data);
2458    }
2459
2460    #[cfg(feature = "blosc2")]
2461    #[test]
2462    fn pipeline_blosc2_round_trip() {
2463        // Exercises the Blosc2 arm of build_compressor for every codec
2464        // variant, round-tripping through the full pipeline.
2465        let data: Vec<u8> = (0..8192).map(|i| (i % 64) as u8).collect();
2466        for codec in [
2467            Blosc2Codec::Blosclz,
2468            Blosc2Codec::Lz4,
2469            Blosc2Codec::Lz4hc,
2470            Blosc2Codec::Zlib,
2471            Blosc2Codec::Zstd,
2472        ] {
2473            let config = PipelineConfig {
2474                encoding: EncodingType::None,
2475                filter: FilterType::None,
2476                compression: CompressionType::Blosc2 {
2477                    codec,
2478                    clevel: 5,
2479                    typesize: 1,
2480                },
2481                num_values: data.len(),
2482                byte_order: ByteOrder::Little,
2483                dtype_byte_width: 1,
2484                swap_unit_size: 1,
2485                compression_backend: CompressionBackend::default(),
2486                intra_codec_threads: 0,
2487                compute_hash: false,
2488            };
2489            let result = encode_pipeline(&data, &config).unwrap();
2490            let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2491            assert_eq!(decoded, data, "blosc2 codec {codec:?} round-trip mismatch");
2492        }
2493    }
2494
2495    #[cfg(feature = "sz3")]
2496    #[test]
2497    fn pipeline_sz3_round_trip() {
2498        // Exercises the Sz3 arm of build_compressor across error-bound
2499        // variants.  sz3 is lossy, so check reconstruction within bound.
2500        let values: Vec<f64> = (0..1024).map(|i| (i as f64 * 0.01).sin()).collect();
2501        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
2502        for error_bound in [
2503            Sz3ErrorBound::Absolute(1e-3),
2504            Sz3ErrorBound::Relative(1e-3),
2505            Sz3ErrorBound::Psnr(80.0),
2506        ] {
2507            let config = PipelineConfig {
2508                encoding: EncodingType::None,
2509                filter: FilterType::None,
2510                compression: CompressionType::Sz3 {
2511                    error_bound: error_bound.clone(),
2512                },
2513                num_values: values.len(),
2514                byte_order: ByteOrder::native(),
2515                dtype_byte_width: 8,
2516                swap_unit_size: 8,
2517                compression_backend: CompressionBackend::default(),
2518                intra_codec_threads: 0,
2519                compute_hash: false,
2520            };
2521            let result = encode_pipeline(&data, &config).unwrap();
2522            let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2523            let decoded_values: Vec<f64> = decoded
2524                .chunks_exact(8)
2525                .map(|c| f64::from_ne_bytes(c.try_into().unwrap()))
2526                .collect();
2527            assert_eq!(decoded_values.len(), values.len());
2528        }
2529    }
2530
2531    #[cfg(feature = "zfp")]
2532    #[test]
2533    fn pipeline_zfp_round_trip_all_modes() {
2534        // Exercises the Zfp arm of build_compressor across its three modes.
2535        let values: Vec<f64> = (0..512).map(|i| 100.0 + (i as f64 * 0.05).cos()).collect();
2536        let data: Vec<u8> = values.iter().flat_map(|v| v.to_ne_bytes()).collect();
2537        for mode in [
2538            ZfpMode::FixedRate { rate: 16.0 },
2539            ZfpMode::FixedPrecision { precision: 20 },
2540            ZfpMode::FixedAccuracy { tolerance: 1e-4 },
2541        ] {
2542            let config = PipelineConfig {
2543                encoding: EncodingType::None,
2544                filter: FilterType::None,
2545                compression: CompressionType::Zfp { mode: mode.clone() },
2546                num_values: values.len(),
2547                byte_order: ByteOrder::native(),
2548                dtype_byte_width: 8,
2549                swap_unit_size: 8,
2550                compression_backend: CompressionBackend::default(),
2551                intra_codec_threads: 0,
2552                compute_hash: false,
2553            };
2554            let result = encode_pipeline(&data, &config).unwrap();
2555            let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2556            assert_eq!(
2557                decoded.len(),
2558                data.len(),
2559                "zfp mode {mode:?} length mismatch"
2560            );
2561        }
2562    }
2563
2564    #[cfg(feature = "lz4")]
2565    #[test]
2566    fn pipeline_lz4_round_trip() {
2567        // Exercises the Lz4 arm of build_compressor end-to-end (the hash
2568        // test only covers the encode direction).
2569        let data: Vec<u8> = (0..4000).map(|i| (i % 17) as u8).collect();
2570        let config = PipelineConfig {
2571            encoding: EncodingType::None,
2572            filter: FilterType::None,
2573            compression: CompressionType::Lz4,
2574            num_values: data.len(),
2575            byte_order: ByteOrder::Little,
2576            dtype_byte_width: 1,
2577            swap_unit_size: 1,
2578            compression_backend: CompressionBackend::default(),
2579            intra_codec_threads: 0,
2580            compute_hash: false,
2581        };
2582        let result = encode_pipeline(&data, &config).unwrap();
2583        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2584        assert_eq!(decoded, data);
2585    }
2586
2587    #[test]
2588    fn pipeline_rle_round_trip_bitmask() {
2589        // RLE is bitmask-only; build_compressor constructs an RleCompressor.
2590        // Use a run-heavy bit pattern so the codec has something to compress.
2591        let data: Vec<u8> = vec![0xFFu8, 0xFF, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0xFF];
2592        let config = PipelineConfig {
2593            encoding: EncodingType::None,
2594            filter: FilterType::None,
2595            compression: CompressionType::Rle,
2596            num_values: data.len() * 8,
2597            byte_order: ByteOrder::Little,
2598            dtype_byte_width: 0, // bitmask
2599            swap_unit_size: 1,
2600            compression_backend: CompressionBackend::default(),
2601            intra_codec_threads: 0,
2602            compute_hash: false,
2603        };
2604        let result = encode_pipeline(&data, &config).unwrap();
2605        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2606        assert_eq!(decoded, data);
2607    }
2608
2609    #[test]
2610    fn pipeline_roaring_round_trip_bitmask() {
2611        // Roaring is bitmask-only; build_compressor constructs a
2612        // RoaringCompressor.
2613        let data: Vec<u8> = vec![0b1010_1010u8, 0x00, 0xFF, 0b0101_0101];
2614        let config = PipelineConfig {
2615            encoding: EncodingType::None,
2616            filter: FilterType::None,
2617            compression: CompressionType::Roaring,
2618            num_values: data.len() * 8,
2619            byte_order: ByteOrder::Little,
2620            dtype_byte_width: 0, // bitmask
2621            swap_unit_size: 1,
2622            compression_backend: CompressionBackend::default(),
2623            intra_codec_threads: 0,
2624            compute_hash: false,
2625        };
2626        let result = encode_pipeline(&data, &config).unwrap();
2627        let decoded = decode_pipeline(&result.encoded_bytes, &config, false).unwrap();
2628        assert_eq!(decoded, data);
2629    }
2630}