Skip to main content

hdf5_pure/
scaleoffset.rs

1//! HDF5 Scale-Offset filter (filter id 6).
2//!
3//! Two modes, matching the only two the reference HDF5 library implements:
4//!
5//! * **Integer** — lossless. Each chunk's minimum is subtracted and the
6//!   residuals are packed into the fewest bits that cover the chunk's range.
7//! * **Float D-scale** — lossy. Values are multiplied by `10^decimals`,
8//!   rounded to integers, then compressed like the integer mode.
9//!
10//! Ported faithfully from the reference `H5Zscaleoffset.c` so files we write
11//! are read by the C library and vice versa. The on-disk compressed chunk is
12//! a fixed 21-byte header followed by an MSB-first bitstream of per-element
13//! offsets:
14//!
15//! | bytes   | meaning                                  |
16//! |---------|------------------------------------------|
17//! | `0..4`  | `minbits` (u32, little-endian)           |
18//! | `4`     | size of the `minval` field, always `8`   |
19//! | `5..13` | `minval` (u64, little-endian)            |
20//! | `13..21`| zero padding                             |
21//! | `21..`  | payload: each offset in `minbits` bits   |
22//!
23//! The payload bitstream is endianness-independent (it encodes the integer
24//! offset MSB-first); the dataset byte order only governs how reconstructed
25//! values are serialized.
26
27#[cfg(not(feature = "std"))]
28extern crate alloc;
29
30#[cfg(not(feature = "std"))]
31use alloc::{string::ToString, vec, vec::Vec};
32
33use crate::convert::TryToUsize;
34use crate::datatype::{Datatype, DatatypeByteOrder};
35use crate::error::FormatError;
36use crate::filter_pipeline::FilterDescription;
37
38// cd_values indices (H5Z_SCALEOFFSET_PARM_*).
39const PARM_SCALETYPE: usize = 0;
40const PARM_SCALEFACTOR: usize = 1;
41const PARM_NELMTS: usize = 2;
42const PARM_CLASS: usize = 3;
43const PARM_SIZE: usize = 4;
44const PARM_SIGN: usize = 5;
45const PARM_ORDER: usize = 6;
46const PARM_FILAVAIL: usize = 7;
47const PARM_FILVAL: usize = 8;
48/// Total number of filter parameters (`H5Z_SCALEOFFSET_TOTAL_NPARMS`).
49const TOTAL_NPARMS: usize = 20;
50/// The first `PARM_FILVAL` entries are the always-present "core" parameters.
51const CORE_NPARMS: usize = PARM_FILVAL;
52
53// Scale types (`H5Z_SO_scale_type_t`).
54const SO_FLOAT_DSCALE: u32 = 0;
55const SO_FLOAT_ESCALE: u32 = 1;
56const SO_INT: u32 = 2;
57
58// Datatype classes.
59const CLS_INTEGER: u32 = 0;
60const CLS_FLOAT: u32 = 1;
61
62// Integer sign.
63const SGN_NONE: u32 = 0;
64const SGN_2: u32 = 1;
65
66// Byte order.
67const ORDER_LE: u32 = 0;
68const ORDER_BE: u32 = 1;
69
70// Fill-value availability.
71const FILL_UNDEFINED: u32 = 0;
72const FILL_DEFINED: u32 = 1;
73
74/// Length of the fixed parameter header that precedes the bit-packed payload
75/// (`buf_offset` in the reference filter). This is also the most this filter can
76/// expand a chunk on the forward path: when the data does not pack smaller it
77/// falls back to storing the input verbatim after the header.
78pub(crate) const HEADER_LEN: usize = 21;
79
80/// Scale-offset compression mode requested by the writer.
81///
82/// Mirrors the two variants the reference HDF5 library exposes.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ScaleOffset {
85    /// Integer scale-offset (lossless). `0` lets the encoder auto-compute the
86    /// minimum bit width from each chunk's value range (the usual choice); a
87    /// positive value would force a fixed minimum bit width.
88    Integer(u32),
89    /// Floating-point decimal scaling (lossy). The value is the number of
90    /// decimal digits of precision retained (the "D" scale factor).
91    FloatDScale(i32),
92}
93
94/// Datatype facts the writer needs to assemble scale-offset `cd_values`.
95///
96/// Derived from a dataset's [`Datatype`] via
97/// [`scale_offset_type_from_datatype`]. Carried on
98/// [`ChunkContext`](crate::filters::ChunkContext) the same way ZFP's scalar
99/// type is, so the write path can build the filter parameters without
100/// re-deriving them.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct ScaleOffsetType {
103    /// `CLS_INTEGER` or `CLS_FLOAT`.
104    class: u32,
105    /// `SGN_NONE` or `SGN_2` (only meaningful for integers).
106    sign: u32,
107    /// `ORDER_LE` or `ORDER_BE`.
108    order: u32,
109}
110
111/// Map an HDF5 `Datatype` to the facts scale-offset needs, if the type is a
112/// fixed-point or floating-point scalar with a definite byte order. Returns
113/// `None` for any other class (compound, string, …) or an indeterminate order.
114pub fn scale_offset_type_from_datatype(dt: &Datatype) -> Option<ScaleOffsetType> {
115    match dt {
116        Datatype::FixedPoint {
117            size,
118            byte_order,
119            signed,
120            ..
121        } if matches!(*size, 1 | 2 | 4 | 8) => Some(ScaleOffsetType {
122            class: CLS_INTEGER,
123            sign: if *signed { SGN_2 } else { SGN_NONE },
124            order: order_code(byte_order)?,
125        }),
126        Datatype::FloatingPoint {
127            size, byte_order, ..
128        } if matches!(*size, 4 | 8) => Some(ScaleOffsetType {
129            class: CLS_FLOAT,
130            sign: SGN_NONE,
131            order: order_code(byte_order)?,
132        }),
133        _ => None,
134    }
135}
136
137fn order_code(order: &DatatypeByteOrder) -> Option<u32> {
138    match order {
139        DatatypeByteOrder::LittleEndian => Some(ORDER_LE),
140        DatatypeByteOrder::BigEndian => Some(ORDER_BE),
141        // Scale-offset only supports definite little/big endian, matching the
142        // reference filter's `can_apply` check.
143        DatatypeByteOrder::Vax => None,
144    }
145}
146
147/// Build the 20-entry `cd_values` array for a scale-offset filter.
148///
149/// `nelmts` is the number of elements in one chunk. Validates that the
150/// requested [`ScaleOffset`] mode matches the datatype class (integer mode on
151/// integer data, float D-scale on float data).
152pub fn build_cd_values(
153    mode: ScaleOffset,
154    ty: ScaleOffsetType,
155    size: u32,
156    nelmts: u32,
157) -> Result<Vec<u32>, FormatError> {
158    let (scale_type, scale_factor) = match (mode, ty.class) {
159        (ScaleOffset::Integer(minbits), CLS_INTEGER) => (SO_INT, minbits),
160        (ScaleOffset::FloatDScale(decimals), CLS_FLOAT) => (SO_FLOAT_DSCALE, decimals as u32),
161        (ScaleOffset::Integer(_), _) => {
162            return Err(FormatError::FilterError(
163                "scaleoffset: integer mode requires an integer dataset".to_string(),
164            ));
165        }
166        (ScaleOffset::FloatDScale(_), _) => {
167            return Err(FormatError::FilterError(
168                "scaleoffset: float D-scale mode requires a floating-point dataset".to_string(),
169            ));
170        }
171    };
172
173    let mut cd = vec![0u32; TOTAL_NPARMS];
174    cd[PARM_SCALETYPE] = scale_type;
175    cd[PARM_SCALEFACTOR] = scale_factor;
176    cd[PARM_NELMTS] = nelmts;
177    cd[PARM_CLASS] = ty.class;
178    cd[PARM_SIZE] = size;
179    cd[PARM_SIGN] = ty.sign;
180    cd[PARM_ORDER] = ty.order;
181    cd[PARM_FILAVAIL] = FILL_UNDEFINED;
182    Ok(cd)
183}
184
185/// Recover the [`ScaleOffset`] mode a parsed filter encodes from its
186/// `cd_values`, so a tool like [`repack`](crate::repack) can re-apply it.
187///
188/// Returns `None` if the parameter array is too short, names a scale type this
189/// crate never writes (the reference library's float *E*-scale), or carries a
190/// **defined fill value**: this crate's writer only ever emits the
191/// fill-undefined form, so re-emitting a fill-defined filter would silently drop
192/// its chunk-fill semantics. Refusing keeps repack faithful rather than
193/// approximate.
194pub(crate) fn scale_offset_mode(cd_values: &[u32]) -> Option<ScaleOffset> {
195    let scale_type = *cd_values.get(PARM_SCALETYPE)?;
196    let scale_factor = *cd_values.get(PARM_SCALEFACTOR)?;
197    if cd_values.get(PARM_FILAVAIL).copied() != Some(FILL_UNDEFINED) {
198        return None;
199    }
200    match scale_type {
201        SO_INT => Some(ScaleOffset::Integer(scale_factor)),
202        #[expect(
203            clippy::cast_possible_wrap,
204            reason = "scale_factor is a small decimal scale factor (D-scale parameter); the reference treats it as a signed int"
205        )]
206        SO_FLOAT_DSCALE => Some(ScaleOffset::FloatDScale(scale_factor as i32)),
207        // SO_FLOAT_ESCALE (and anything unrecognized) is never written by this
208        // crate and cannot be reproduced.
209        _ => None,
210    }
211}
212
213/// Decoded scale-offset parameters shared by the compress and decompress paths.
214struct Parms {
215    scale_type: u32,
216    scale_factor: i32,
217    nelmts: usize,
218    class: u32,
219    size: usize,
220    order: u32,
221    filavail: u32,
222}
223
224impl Parms {
225    fn parse(cd: &[u32]) -> Result<Parms, FormatError> {
226        if cd.len() < CORE_NPARMS {
227            return Err(FormatError::FilterError(
228                "scaleoffset: too few cd_values".to_string(),
229            ));
230        }
231        let class = cd[PARM_CLASS];
232        if class != CLS_INTEGER && class != CLS_FLOAT {
233            return Err(FormatError::FilterError(
234                "scaleoffset: unsupported datatype class".to_string(),
235            ));
236        }
237        let size = cd[PARM_SIZE] as usize;
238        if size == 0 || size > 8 {
239            return Err(FormatError::FilterError(
240                "scaleoffset: unsupported datatype size".to_string(),
241            ));
242        }
243        if class == CLS_FLOAT && size != 4 && size != 8 {
244            return Err(FormatError::FilterError(
245                "scaleoffset: float size must be 4 or 8".to_string(),
246            ));
247        }
248        let order = cd[PARM_ORDER];
249        if order != ORDER_LE && order != ORDER_BE {
250            return Err(FormatError::FilterError(
251                "scaleoffset: bad byte order".to_string(),
252            ));
253        }
254        #[expect(
255            clippy::cast_possible_wrap,
256            reason = "scale_factor is a small filter parameter (decimal scale factor or bit width); the reference stores it in a signed int"
257        )]
258        Ok(Parms {
259            scale_type: cd[PARM_SCALETYPE],
260            scale_factor: cd[PARM_SCALEFACTOR] as i32,
261            nelmts: cd[PARM_NELMTS].to_usize()?,
262            class,
263            size,
264            order,
265            filavail: cd[PARM_FILAVAIL],
266        })
267    }
268
269    /// Bit mask covering the datatype's full width.
270    fn width_mask(&self) -> u64 {
271        if self.size >= 8 {
272            u64::MAX
273        } else {
274            (1u64 << (self.size * 8)) - 1
275        }
276    }
277}
278
279/// Decompress one scale-offset chunk into raw element bytes (in the dataset's
280/// stored byte order).
281pub fn decompress(input: &[u8], filter: &FilterDescription) -> Result<Vec<u8>, FormatError> {
282    let cd = &filter.client_data;
283    let p = Parms::parse(cd)?;
284
285    if p.scale_type == SO_FLOAT_ESCALE {
286        return Err(FormatError::FilterError(
287            "scaleoffset: float E-scale method is not supported".to_string(),
288        ));
289    }
290
291    #[expect(
292        clippy::cast_possible_truncation,
293        reason = "p.size is validated to 1..=8, so p.size * 8 is 8..=64 and fits in u32"
294    )]
295    let full_bits = (p.size * 8) as u32;
296    // `nelmts` is file-derived; `nelmts * size` is an allocation size and a
297    // slice bound. The product is computed in `u64` (it cannot overflow there:
298    // `nelmts` originates from a `u32` and `size` is 1..=8) and narrowed to
299    // `usize`, which errors instead of truncating on a 32-bit host.
300    let size_out = (p.nelmts as u64 * p.size as u64).to_usize()?;
301
302    // No-op mode: integer (non-DSCALE) datasets created with scale_factor equal
303    // to the full bit width store the chunk verbatim, with no header.
304    #[expect(
305        clippy::cast_possible_wrap,
306        reason = "full_bits is 8..=64 (p.size is 1..=8), so it fits in a non-negative i32"
307    )]
308    if p.scale_type != SO_FLOAT_DSCALE && p.scale_factor == full_bits as i32 {
309        return Ok(input.to_vec());
310    }
311
312    // Header: minbits + minval.
313    if input.len() < 5 {
314        return Err(FormatError::FilterError(
315            "scaleoffset: chunk shorter than header".to_string(),
316        ));
317    }
318    let minbits = u32::from_le_bytes([input[0], input[1], input[2], input[3]]);
319    if minbits > full_bits {
320        return Err(FormatError::FilterError(
321            "scaleoffset: minbits exceeds datatype size".to_string(),
322        ));
323    }
324    let minval_size = (input[4] as usize).min(8);
325    if input.len() < 5 + minval_size {
326        return Err(FormatError::FilterError(
327            "scaleoffset: chunk too short for minval".to_string(),
328        ));
329    }
330    let mut minval_bytes = [0u8; 8];
331    minval_bytes[..minval_size].copy_from_slice(&input[5..5 + minval_size]);
332    let minval = u64::from_le_bytes(minval_bytes);
333
334    // Raw payload (no per-element packing): minbits at full precision.
335    if minbits == full_bits {
336        let start = HEADER_LEN;
337        if input.len() < start + size_out {
338            return Err(FormatError::FilterError(
339                "scaleoffset: chunk too short for raw payload".to_string(),
340            ));
341        }
342        return Ok(input[start..start + size_out].to_vec());
343    }
344
345    // minbits == 0 means every element equals `minval`. Short-circuit so we
346    // never enter the sentinel-matching path, which would otherwise misread
347    // a `FILL_DEFINED` chunk's stored values as fill values (the all-ones
348    // sentinel collapses to 0 when minbits == 0, the same value every zero
349    // offset carries). The reference C decoder handles this identically.
350    let mut out = Vec::with_capacity(size_out);
351    if minbits == 0 {
352        let mask = p.width_mask();
353        for _ in 0..p.nelmts {
354            write_value(&mut out, minval & mask, p.size, p.order);
355        }
356        return Ok(out);
357    }
358
359    if input.len() < HEADER_LEN {
360        return Err(FormatError::FilterError(
361            "scaleoffset: chunk too short for packed payload".to_string(),
362        ));
363    }
364    let payload = &input[HEADER_LEN..];
365    // Validate payload length once, up front; the BitReader trusts the
366    // caller to not read past the end. Compare in `u64` so the bit counts
367    // (`nelmts` is file-derived, `minbits` up to 64) cannot overflow a 32-bit
368    // `usize` and falsely pass the check.
369    if (payload.len() as u64) * 8 < p.nelmts as u64 * minbits as u64 {
370        return Err(FormatError::FilterError(
371            "scaleoffset: payload too short for packed data".to_string(),
372        ));
373    }
374    let mut reader = BitReader::new(payload);
375
376    if p.class == CLS_INTEGER {
377        reconstruct_integer(&mut out, &mut reader, &p, minbits, minval, cd)?;
378    } else {
379        reconstruct_float(&mut out, &mut reader, &p, minbits, minval, cd)?;
380    }
381    Ok(out)
382}
383
384/// Compress one full chunk of raw element bytes with scale-offset.
385///
386/// `input` must be exactly `nelmts * size` bytes (the chunk writer always pads
387/// edge chunks to full size). Only the fill-value-undefined path is produced,
388/// which is what this crate's writer emits.
389pub fn compress(input: &[u8], filter: &FilterDescription) -> Result<Vec<u8>, FormatError> {
390    let cd = &filter.client_data;
391    let p = Parms::parse(cd)?;
392
393    if p.filavail == FILL_DEFINED {
394        return Err(FormatError::FilterError(
395            "scaleoffset: encoding with a defined fill value is not supported".to_string(),
396        ));
397    }
398    if p.class == CLS_INTEGER && p.scale_type != SO_INT {
399        return Err(FormatError::FilterError(
400            "scaleoffset: integer class requires integer scale type".to_string(),
401        ));
402    }
403    if p.class == CLS_FLOAT && p.scale_type != SO_FLOAT_DSCALE {
404        return Err(FormatError::FilterError(
405            "scaleoffset: float class requires D-scale scale type".to_string(),
406        ));
407    }
408
409    // `nelmts * size` in `u64` (cannot overflow there) narrowed to `usize`,
410    // so a file-derived `nelmts` cannot wrap a 32-bit `usize` and spuriously
411    // equal `input.len()`.
412    let expected = (p.nelmts as u64 * p.size as u64).to_usize()?;
413    if input.len() != expected {
414        return Err(FormatError::CompressionError(
415            "scaleoffset: chunk size does not match nelmts * datatype size".to_string(),
416        ));
417    }
418    if p.nelmts == 0 {
419        return Ok(emit(0, 0, &[]));
420    }
421
422    let signed = cd[PARM_SIGN] == SGN_2;
423    #[expect(
424        clippy::cast_possible_truncation,
425        reason = "p.size is validated to 1..=8, so p.size * 8 is 8..=64 and fits in u32"
426    )]
427    let full_bits = (p.size * 8) as u32;
428
429    let (minbits, minval, offsets) = if p.class == CLS_INTEGER {
430        precompress_integer(input, &p, signed)
431    } else {
432        precompress_float(input, &p)
433    };
434
435    // Raw path: store the original element bytes after the header.
436    if minbits >= full_bits {
437        return Ok(emit_raw(full_bits, minval, input));
438    }
439    let payload = pack_offsets(&offsets, minbits, p.nelmts)?;
440    Ok(emit(minbits, minval, &payload))
441}
442
443// --- integer reconstruction / pre-compression ----------------------------
444
445fn reconstruct_integer(
446    out: &mut Vec<u8>,
447    reader: &mut BitReader<'_>,
448    p: &Parms,
449    minbits: u32,
450    minval: u64,
451    cd: &[u32],
452) -> Result<(), FormatError> {
453    let mask = p.width_mask();
454    let sentinel = sentinel(minbits);
455    // Hoist `fv & mask` out of the per-element branch.
456    let filval = if p.filavail == FILL_DEFINED {
457        Some(read_fill_bits(cd, p.size)? & mask)
458    } else {
459        None
460    };
461    for _ in 0..p.nelmts {
462        let d = reader.read(minbits);
463        let bits = match filval {
464            Some(fv) if d == sentinel => fv,
465            _ => d.wrapping_add(minval) & mask,
466        };
467        write_value(out, bits, p.size, p.order);
468    }
469    Ok(())
470}
471
472fn precompress_integer(input: &[u8], p: &Parms, signed: bool) -> (u32, u64, Vec<u64>) {
473    // Stream the input twice (min/max, then offsets) instead of materializing
474    // a Vec<i128>. The intermediate dominated cache pressure on large chunks
475    // (16 B/element vs the source's 1-8 B/element); two cache-warm passes
476    // come out ahead.
477    #[expect(
478        clippy::cast_possible_truncation,
479        reason = "p.size is validated to 1..=8, so p.size * 8 is 8..=64 and fits in u32"
480    )]
481    let full_bits = (p.size * 8) as u32;
482    let read_as_i128 = |i: usize| -> i128 {
483        let bits = read_value(&input[i * p.size..(i + 1) * p.size], p.size, p.order);
484        if signed {
485            sign_extend(bits, p.size) as i128
486        } else {
487            bits as i128
488        }
489    };
490
491    let first = read_as_i128(0);
492    let (mut min, mut max) = (first, first);
493    for i in 1..p.nelmts {
494        let v = read_as_i128(i);
495        if v < min {
496            min = v;
497        }
498        if v > max {
499            max = v;
500        }
501    }
502
503    #[expect(
504        clippy::cast_possible_truncation,
505        reason = "min is an element value read from at most 8 bytes (p.size 1..=8), so it fits in i64/u64 despite the i128 accumulator type"
506    )]
507    let minval = if signed {
508        (min as i64) as u64
509    } else {
510        min as u64
511    };
512
513    // Overflow guard mirrors `H5Z_scaleoffset_check_{1,2}`: a span within two
514    // of the full range can't gain from packing, so store at full precision.
515    let width_max: u128 = p.width_mask() as u128;
516    let spread = (max - min) as u128;
517    if spread > width_max.saturating_sub(2) {
518        return (full_bits, minval, Vec::new());
519    }
520
521    #[expect(
522        clippy::cast_possible_truncation,
523        reason = "the guard above returns early unless spread <= width_max - 2, and width_max <= u64::MAX, so spread fits in u64"
524    )]
525    let minbits = ceil_log2((spread as u64) + 1);
526    if minbits >= full_bits {
527        return (full_bits, minval, Vec::new());
528    }
529
530    #[expect(
531        clippy::cast_possible_truncation,
532        reason = "each offset (value - min) is at most spread <= width_max <= u64::MAX, so it fits in u64"
533    )]
534    let offsets = (0..p.nelmts)
535        .map(|i| (read_as_i128(i) - min) as u64)
536        .collect();
537    (minbits, minval, offsets)
538}
539
540// --- float (D-scale) reconstruction / pre-compression ---------------------
541
542fn reconstruct_float(
543    out: &mut Vec<u8>,
544    reader: &mut BitReader<'_>,
545    p: &Parms,
546    minbits: u32,
547    minval: u64,
548    cd: &[u32],
549) -> Result<(), FormatError> {
550    let sentinel = sentinel(minbits);
551    let decimals = p.scale_factor;
552    let filval = if p.filavail == FILL_DEFINED {
553        Some(read_fill_bits(cd, p.size)?)
554    } else {
555        None
556    };
557    // Dispatch on width once: pow, min, and the size/order args are all
558    // loop-invariant. The compiler won't hoist the inner `match p.size`
559    // across both branches by itself.
560    if p.size == 4 {
561        #[expect(
562            clippy::cast_possible_truncation,
563            reason = "size == 4 branch: minval holds a 4-byte f32 bit pattern in its low 32 bits, so narrowing to u32 reconstructs that pattern"
564        )]
565        let min = f32::from_bits(minval as u32);
566        let pow = pow10_f32(decimals);
567        for _ in 0..p.nelmts {
568            let d = reader.read(minbits);
569            #[expect(
570                clippy::cast_possible_wrap,
571                reason = "d is a minbits-wide offset (minbits < full_bits <= 64); reinterpreting it as i64 matches the reference's signed reconstruction"
572            )]
573            let bits = if let Some(fv) = filval.filter(|_| d == sentinel) {
574                fv
575            } else {
576                ((d as i64 as f32) / pow + min).to_bits() as u64
577            };
578            write_value(out, bits, 4, p.order);
579        }
580    } else {
581        let min = f64::from_bits(minval);
582        let pow = pow10_f64(decimals);
583        for _ in 0..p.nelmts {
584            let d = reader.read(minbits);
585            #[expect(
586                clippy::cast_possible_wrap,
587                reason = "d is a minbits-wide offset (minbits < full_bits <= 64); reinterpreting it as i64 matches the reference's signed reconstruction"
588            )]
589            let bits = if let Some(fv) = filval.filter(|_| d == sentinel) {
590                fv
591            } else {
592                ((d as i64 as f64) / pow + min).to_bits()
593            };
594            write_value(out, bits, 8, p.order);
595        }
596    }
597    Ok(())
598}
599
600fn precompress_float(input: &[u8], p: &Parms) -> (u32, u64, Vec<u64>) {
601    // Two streaming passes over the input bytes (min/max, then offsets),
602    // no intermediate Vec<f32>/Vec<f64>. `min_scaled = min * pow` is hoisted
603    // out of the per-element loop so we don't recompute the same product N
604    // times — bit-identical to the previous `v * pow - min * pow`.
605    let decimals = p.scale_factor;
606    #[expect(
607        clippy::cast_possible_truncation,
608        reason = "p.size is validated to 1..=8, so p.size * 8 is 8..=64 and fits in u32"
609    )]
610    let full_bits = (p.size * 8) as u32;
611    if p.size == 4 {
612        #[expect(
613            clippy::cast_possible_truncation,
614            reason = "read_value with size 4 returns a u64 whose meaningful bits are the low 32, so narrowing to u32 reconstructs the f32 bit pattern"
615        )]
616        let read_f32 = |i: usize| -> f32 {
617            f32::from_bits(read_value(&input[i * 4..i * 4 + 4], 4, p.order) as u32)
618        };
619        let first = read_f32(0);
620        let (mut min, mut max) = (first, first);
621        for i in 1..p.nelmts {
622            let v = read_f32(i);
623            if v < min {
624                min = v;
625            }
626            if v > max {
627                max = v;
628            }
629        }
630        let pow = pow10_f32(decimals);
631        let min_scaled = min * pow;
632        // check_3: residual span beyond signed 32-bit range → store raw.
633        let residual = max * pow - min_scaled;
634        let minval = min.to_bits() as u64;
635        if residual > (1u64 << 31) as f32 {
636            return (full_bits, minval, Vec::new());
637        }
638        let minbits = ceil_log2((round_half_away_f32(residual) as u64) + 1);
639        if minbits >= full_bits {
640            return (full_bits, minval, Vec::new());
641        }
642        let offsets = (0..p.nelmts)
643            .map(|i| round_half_away_f32(read_f32(i) * pow - min_scaled) as u64)
644            .collect();
645        (minbits, minval, offsets)
646    } else {
647        let read_f64 =
648            |i: usize| -> f64 { f64::from_bits(read_value(&input[i * 8..i * 8 + 8], 8, p.order)) };
649        let first = read_f64(0);
650        let (mut min, mut max) = (first, first);
651        for i in 1..p.nelmts {
652            let v = read_f64(i);
653            if v < min {
654                min = v;
655            }
656            if v > max {
657                max = v;
658            }
659        }
660        let pow = pow10_f64(decimals);
661        let min_scaled = min * pow;
662        let residual = max * pow - min_scaled;
663        let minval = min.to_bits();
664        if residual > (1u64 << 63) as f64 {
665            return (full_bits, minval, Vec::new());
666        }
667        let minbits = ceil_log2((round_half_away_f64(residual) as u64) + 1);
668        if minbits >= full_bits {
669            return (full_bits, minval, Vec::new());
670        }
671        let offsets = (0..p.nelmts)
672            .map(|i| round_half_away_f64(read_f64(i) * pow - min_scaled) as u64)
673            .collect();
674        (minbits, minval, offsets)
675    }
676}
677
678// --- header emit helpers --------------------------------------------------
679
680/// Assemble a compressed chunk: 21-byte header + bit-packed `payload`.
681/// The trailing safety byte built into `payload` matches the reference layout.
682fn emit(minbits: u32, minval: u64, payload: &[u8]) -> Vec<u8> {
683    let mut out = Vec::with_capacity(HEADER_LEN + payload.len().max(1));
684    write_header(&mut out, minbits, minval);
685    if payload.is_empty() {
686        // minbits == 0: the reference still reserves one trailing byte.
687        out.push(0);
688    } else {
689        out.extend_from_slice(payload);
690    }
691    out
692}
693
694/// Assemble a full-precision (raw) chunk: header + verbatim element bytes.
695fn emit_raw(full_bits: u32, minval: u64, input: &[u8]) -> Vec<u8> {
696    let mut out = Vec::with_capacity(HEADER_LEN + input.len());
697    write_header(&mut out, full_bits, minval);
698    out.extend_from_slice(input);
699    out
700}
701
702fn write_header(out: &mut Vec<u8>, minbits: u32, minval: u64) {
703    out.extend_from_slice(&minbits.to_le_bytes()); // bytes 0..4
704    out.push(8); // byte 4: sizeof(minval)
705    out.extend_from_slice(&minval.to_le_bytes()); // bytes 5..13
706    out.extend_from_slice(&[0u8; HEADER_LEN - 13]); // bytes 13..21: padding
707}
708
709// --- bit packing ----------------------------------------------------------
710
711/// Pack `nelmts` offsets, `minbits` bits each, MSB-first. The buffer length
712/// matches the reference `nelmts * minbits / 8 + 1`: enough for the bit
713/// stream plus, when the stream is a multiple of 8 bits, one trailing zero
714/// the reference always reserves.
715///
716/// Implementation maintains a `u64` accumulator with the most recently
717/// written bits at its bottom, flushing whole bytes from the top as the
718/// accumulator fills. This processes a chunk of `nelmts * minbits` bits
719/// with O(nelmts) iterations rather than O(nelmts * minbits) — roughly an
720/// order of magnitude fewer instructions in the hot path for typical
721/// minbits.
722fn pack_offsets(offsets: &[u64], minbits: u32, nelmts: usize) -> Result<Vec<u8>, FormatError> {
723    // `nelmts * minbits` is a bit count that sizes the payload buffer. Compute
724    // it in `u64` (cannot overflow there: `nelmts` originates from a `u32` and
725    // `minbits` is <= 64) and narrow the resulting byte length to `usize`,
726    // erroring instead of truncating on a 32-bit host.
727    let payload_len = (nelmts as u64 * minbits as u64 / 8 + 1).to_usize()?;
728    if minbits == 0 {
729        // All offsets are zero; the reference layout is just `payload_len`
730        // zero bytes.
731        return Ok(vec![0u8; payload_len]);
732    }
733    let mut buf = Vec::with_capacity(payload_len);
734    let mask: u64 = if minbits == 64 {
735        u64::MAX
736    } else {
737        (1u64 << minbits) - 1
738    };
739    let mut acc: u64 = 0;
740    let mut nbits: u32 = 0;
741    for &v in offsets {
742        // Drain whole bytes already buffered so nbits ∈ 0..=7 going in.
743        while nbits >= 8 {
744            nbits -= 8;
745            buf.push(((acc >> nbits) & 0xFF) as u8);
746        }
747        // Merge in `minbits` bits. Work in u128 because `acc << 64` would be
748        // UB in u64 (we'd hit it when minbits == 64 and nbits == 0).
749        let combined = ((acc as u128) << minbits) | (v & mask) as u128;
750        let total = nbits + minbits;
751        if total <= 64 {
752            #[expect(
753                clippy::cast_possible_truncation,
754                reason = "this branch is guarded by total <= 64, and combined occupies exactly `total` bits, so it fits in u64"
755            )]
756            {
757                acc = combined as u64;
758            }
759            nbits = total;
760        } else {
761            // The merged value spans more than a u64; emit 8 bytes from the
762            // top and keep the spillover (always 1..=7 bits) for next round.
763            let drop = total - 64;
764            #[expect(
765                clippy::cast_possible_truncation,
766                reason = "combined >> drop keeps the high 64 bits of a (64 + drop)-bit value, which fits in u64"
767            )]
768            let top = (combined >> drop) as u64;
769            buf.extend_from_slice(&top.to_be_bytes());
770            #[expect(
771                clippy::cast_possible_truncation,
772                reason = "combined & ((1 << drop) - 1) masks to the low `drop` bits (drop is 1..=7 here), which fits in u64"
773            )]
774            {
775                acc = (combined & ((1u128 << drop) - 1)) as u64;
776            }
777            nbits = drop;
778        }
779    }
780    while nbits >= 8 {
781        nbits -= 8;
782        buf.push(((acc >> nbits) & 0xFF) as u8);
783    }
784    if nbits > 0 {
785        // Left-align the remaining bits into a final byte; the low bits are
786        // zero padding within the partial byte.
787        buf.push(((acc << (8 - nbits)) & 0xFF) as u8);
788    }
789    // For bit streams that end on a byte boundary, the reference still
790    // reserves one trailing zero. The formula above sized the buffer for
791    // either case; pad here.
792    while buf.len() < payload_len {
793        buf.push(0);
794    }
795    Ok(buf)
796}
797
798/// Streaming MSB-first bit reader over a packed payload. Fused with the
799/// reconstruction loop so we don't have to materialize a `Vec<u64>` of
800/// intermediate offsets.
801struct BitReader<'a> {
802    payload: &'a [u8],
803    bit_pos: usize,
804}
805
806impl<'a> BitReader<'a> {
807    fn new(payload: &'a [u8]) -> Self {
808        Self {
809            payload,
810            bit_pos: 0,
811        }
812    }
813
814    /// Read the next `minbits` bits (1..=64) MSB-first. Caller validates
815    /// that the payload holds enough bits up front.
816    #[inline]
817    fn read(&mut self, minbits: u32) -> u64 {
818        debug_assert!((1..=64).contains(&minbits));
819        let byte = self.bit_pos >> 3;
820        #[expect(
821            clippy::cast_possible_truncation,
822            reason = "bit_pos & 7 masks to 0..=7, which trivially fits in u32"
823        )]
824        let off = (self.bit_pos & 7) as u32;
825
826        // Load a 72-bit window at `byte`. Hot path: payload has ≥ 9 bytes
827        // remaining and we can read directly. Tail: zero-pad a stack buffer.
828        let (hi, lo) = if byte + 9 <= self.payload.len() {
829            let hi = u64::from_be_bytes(self.payload[byte..byte + 8].try_into().unwrap());
830            let lo = self.payload[byte + 8] as u64;
831            (hi, lo)
832        } else {
833            let mut window = [0u8; 9];
834            let take = self.payload.len().saturating_sub(byte).min(9);
835            window[..take].copy_from_slice(&self.payload[byte..byte + take]);
836            let hi = u64::from_be_bytes(window[..8].try_into().unwrap());
837            let lo = window[8] as u64;
838            (hi, lo)
839        };
840
841        // Align the window so the desired `minbits` bits sit at the top of
842        // a 64-bit value, then shift them down. The off == 0 branch avoids
843        // the otherwise-suspicious `lo >> 8` on a value with only 8 set bits.
844        let combined = if off == 0 {
845            hi
846        } else {
847            (hi << off) | (lo >> (8 - off))
848        };
849        self.bit_pos += minbits as usize;
850        combined >> (64 - minbits)
851    }
852}
853
854/// Test-only helper: collect a full bitstream into a `Vec<u64>`. The main
855/// decompress path drives the reconstructor directly off [`BitReader`].
856#[cfg(test)]
857fn unpack_bits(payload: &[u8], nelmts: usize, minbits: u32) -> Result<Vec<u64>, FormatError> {
858    let total_bits = nelmts * minbits as usize;
859    if payload.len() * 8 < total_bits {
860        return Err(FormatError::FilterError(
861            "scaleoffset: payload too short for packed data".to_string(),
862        ));
863    }
864    let mut reader = BitReader::new(payload);
865    Ok((0..nelmts).map(|_| reader.read(minbits)).collect())
866}
867
868// --- value (de)serialization ----------------------------------------------
869
870/// Read a `size`-byte element as a u64 (low `size` bytes meaningful),
871/// normalizing to little-endian regardless of stored `order`.
872fn read_value(chunk: &[u8], size: usize, order: u32) -> u64 {
873    let mut bytes = [0u8; 8];
874    if order == ORDER_LE {
875        bytes[..size].copy_from_slice(&chunk[..size]);
876    } else {
877        for (k, &b) in chunk[..size].iter().enumerate() {
878            bytes[size - 1 - k] = b;
879        }
880    }
881    u64::from_le_bytes(bytes)
882}
883
884/// Write the low `size` bytes of `bits` in the dataset's byte order.
885fn write_value(out: &mut Vec<u8>, bits: u64, size: usize, order: u32) {
886    let le = bits.to_le_bytes();
887    if order == ORDER_LE {
888        out.extend_from_slice(&le[..size]);
889    } else {
890        for k in (0..size).rev() {
891            out.push(le[k]);
892        }
893    }
894}
895
896#[expect(
897    clippy::cast_possible_wrap,
898    reason = "size >= 8: reinterpreting all 64 stored bits as i64 is the intentional sign reinterpretation of a full-width value; size < 8: ((bits << shift) as i64) >> shift sign-extends the `size`-byte value, an intentional wrap"
899)]
900fn sign_extend(bits: u64, size: usize) -> i64 {
901    if size >= 8 {
902        bits as i64
903    } else {
904        let shift = 64 - size * 8;
905        ((bits << shift) as i64) >> shift
906    }
907}
908
909/// `1 << minbits - 1`, the all-ones offset that flags a fill value.
910fn sentinel(minbits: u32) -> u64 {
911    (1u64 << minbits).wrapping_sub(1)
912}
913
914/// Reassemble a `size`-byte fill value from `cd_values[8..]` (stored
915/// least-significant 4 bytes per entry).
916fn read_fill_bits(cd: &[u32], size: usize) -> Result<u64, FormatError> {
917    let entries = size.div_ceil(4);
918    if cd.len() < PARM_FILVAL + entries {
919        return Err(FormatError::FilterError(
920            "scaleoffset: cd_values missing fill value".to_string(),
921        ));
922    }
923    let mut bytes = [0u8; 8];
924    let mut off = 0;
925    let mut idx = PARM_FILVAL;
926    while off < size {
927        let take = (size - off).min(4);
928        bytes[off..off + take].copy_from_slice(&cd[idx].to_le_bytes()[..take]);
929        off += take;
930        idx += 1;
931    }
932    Ok(u64::from_le_bytes(bytes))
933}
934
935/// `10^exp` as `f64`, computed without `std` (the float `powf`/`powi` methods
936/// require `std`). `exp` is a small decimal scale factor; exponentiation by
937/// squaring keeps this both cheap and accurate.
938fn pow10_f64(exp: i32) -> f64 {
939    let mut result = 1.0f64;
940    let mut base = 10.0f64;
941    let mut n = exp.unsigned_abs();
942    while n > 0 {
943        if n & 1 == 1 {
944            result *= base;
945        }
946        base *= base;
947        n >>= 1;
948    }
949    if exp < 0 { 1.0 / result } else { result }
950}
951
952/// `10^exp` as `f32` (computed in `f64` for accuracy, then narrowed).
953#[expect(
954    clippy::cast_possible_truncation,
955    reason = "narrowing 10^exp from f64 to f32 is the intended value conversion; out-of-range magnitudes saturate to +/-inf, matching the C reference"
956)]
957fn pow10_f32(exp: i32) -> f32 {
958    pow10_f64(exp) as f32
959}
960
961/// Round half away from zero to the nearest integer, matching C `llround`.
962/// Float-to-int `as` casts saturate in Rust, so out-of-range inputs are safe.
963#[expect(
964    clippy::cast_possible_truncation,
965    reason = "float-to-int `as` saturates in Rust, so rounding x +/- 0.5 to i64 is safe even for out-of-range inputs (matches C llround)"
966)]
967fn round_half_away_f64(x: f64) -> i64 {
968    if x >= 0.0 {
969        (x + 0.5) as i64
970    } else {
971        (x - 0.5) as i64
972    }
973}
974
975/// `f32` counterpart of [`round_half_away_f64`] (matches C `lroundf`).
976#[expect(
977    clippy::cast_possible_truncation,
978    reason = "float-to-int `as` saturates in Rust, so rounding x +/- 0.5 to i64 is safe even for out-of-range inputs (matches C lroundf)"
979)]
980fn round_half_away_f32(x: f32) -> i64 {
981    if x >= 0.0 {
982        (x + 0.5) as i64
983    } else {
984        (x - 0.5) as i64
985    }
986}
987
988/// Ceiling of log2, matching the reference `H5Z__scaleoffset_log2`
989/// (`log2(0) == 1`, `log2(1) == 0`).
990fn ceil_log2(num: u64) -> u32 {
991    let mut v = 0u32;
992    let mut lower_bound = 1u64;
993    let mut val = num;
994    loop {
995        val >>= 1;
996        if val == 0 {
997            break;
998        }
999        v += 1;
1000        lower_bound <<= 1;
1001    }
1002    if num == lower_bound { v } else { v + 1 }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    fn int_filter(size: u32, signed: bool, order: u32, nelmts: u32) -> FilterDescription {
1010        let ty = ScaleOffsetType {
1011            class: CLS_INTEGER,
1012            sign: if signed { SGN_2 } else { SGN_NONE },
1013            order,
1014        };
1015        FilterDescription {
1016            filter_id: crate::filter_pipeline::FILTER_SCALEOFFSET,
1017            name: None,
1018            flags: 0,
1019            client_data: build_cd_values(ScaleOffset::Integer(0), ty, size, nelmts).unwrap(),
1020        }
1021    }
1022
1023    fn float_filter(size: u32, decimals: i32, order: u32, nelmts: u32) -> FilterDescription {
1024        let ty = ScaleOffsetType {
1025            class: CLS_FLOAT,
1026            sign: SGN_NONE,
1027            order,
1028        };
1029        FilterDescription {
1030            filter_id: crate::filter_pipeline::FILTER_SCALEOFFSET,
1031            name: None,
1032            flags: 0,
1033            client_data: build_cd_values(ScaleOffset::FloatDScale(decimals), ty, size, nelmts)
1034                .unwrap(),
1035        }
1036    }
1037
1038    #[test]
1039    fn scale_offset_mode_recovers_and_refuses() {
1040        // Lossless integer is recovered (the only mode repack will re-emit).
1041        let f = int_filter(4, true, ORDER_LE, 16);
1042        assert_eq!(
1043            scale_offset_mode(&f.client_data),
1044            Some(ScaleOffset::Integer(0))
1045        );
1046
1047        // Lossy float D-scale is recognized as such; repack refuses it upstream.
1048        let f = float_filter(8, 3, ORDER_LE, 16);
1049        assert_eq!(
1050            scale_offset_mode(&f.client_data),
1051            Some(ScaleOffset::FloatDScale(3))
1052        );
1053
1054        // A defined fill value cannot be reproduced faithfully -> None.
1055        let mut fill_defined = int_filter(4, true, ORDER_LE, 16);
1056        fill_defined.client_data[PARM_FILAVAIL] = FILL_DEFINED;
1057        assert_eq!(scale_offset_mode(&fill_defined.client_data), None);
1058
1059        // Too-short parameter arrays -> None, never a panic.
1060        assert_eq!(scale_offset_mode(&[]), None);
1061        assert_eq!(scale_offset_mode(&[SO_INT, 0]), None);
1062    }
1063
1064    #[test]
1065    fn ceil_log2_matches_reference() {
1066        assert_eq!(ceil_log2(0), 1);
1067        assert_eq!(ceil_log2(1), 0);
1068        assert_eq!(ceil_log2(2), 1);
1069        assert_eq!(ceil_log2(3), 2);
1070        assert_eq!(ceil_log2(4), 2);
1071        assert_eq!(ceil_log2(5), 3);
1072        assert_eq!(ceil_log2(255), 8);
1073        assert_eq!(ceil_log2(256), 8);
1074        assert_eq!(ceil_log2(257), 9);
1075    }
1076
1077    fn roundtrip_u32(vals: &[u32], order: u32) {
1078        let mut raw = Vec::new();
1079        for &v in vals {
1080            if order == ORDER_LE {
1081                raw.extend_from_slice(&v.to_le_bytes());
1082            } else {
1083                raw.extend_from_slice(&v.to_be_bytes());
1084            }
1085        }
1086        let f = int_filter(4, false, order, vals.len() as u32);
1087        let comp = compress(&raw, &f).unwrap();
1088        let dec = decompress(&comp, &f).unwrap();
1089        assert_eq!(dec, raw);
1090    }
1091
1092    #[test]
1093    fn integer_unsigned_roundtrip_le_and_be() {
1094        let vals = [100u32, 105, 101, 110, 100, 128];
1095        roundtrip_u32(&vals, ORDER_LE);
1096        roundtrip_u32(&vals, ORDER_BE);
1097    }
1098
1099    #[test]
1100    fn integer_signed_roundtrip_with_negatives() {
1101        let vals: [i16; 6] = [-100, -50, -100, 0, 27, -99];
1102        for &order in &[ORDER_LE, ORDER_BE] {
1103            let mut raw = Vec::new();
1104            for &v in &vals {
1105                if order == ORDER_LE {
1106                    raw.extend_from_slice(&v.to_le_bytes());
1107                } else {
1108                    raw.extend_from_slice(&v.to_be_bytes());
1109                }
1110            }
1111            let f = int_filter(2, true, order, vals.len() as u32);
1112            let comp = compress(&raw, &f).unwrap();
1113            let dec = decompress(&comp, &f).unwrap();
1114            assert_eq!(dec, raw, "order {order}");
1115        }
1116    }
1117
1118    #[test]
1119    fn integer_all_equal_uses_minbits_zero() {
1120        let vals = [7u32; 5];
1121        let mut raw = Vec::new();
1122        for &v in &vals {
1123            raw.extend_from_slice(&v.to_le_bytes());
1124        }
1125        let f = int_filter(4, false, ORDER_LE, vals.len() as u32);
1126        let comp = compress(&raw, &f).unwrap();
1127        // minbits == 0 -> 21-byte header + 1 trailing byte.
1128        assert_eq!(comp.len(), HEADER_LEN + 1);
1129        assert_eq!(u32::from_le_bytes([comp[0], comp[1], comp[2], comp[3]]), 0);
1130        let dec = decompress(&comp, &f).unwrap();
1131        assert_eq!(dec, raw);
1132    }
1133
1134    #[test]
1135    fn integer_full_range_uses_raw_path() {
1136        // 0 and u32::MAX force the full-precision (raw) path.
1137        let vals = [0u32, u32::MAX, 123];
1138        let mut raw = Vec::new();
1139        for &v in &vals {
1140            raw.extend_from_slice(&v.to_le_bytes());
1141        }
1142        let f = int_filter(4, false, ORDER_LE, vals.len() as u32);
1143        let comp = compress(&raw, &f).unwrap();
1144        assert_eq!(comp.len(), HEADER_LEN + raw.len());
1145        assert_eq!(u32::from_le_bytes([comp[0], comp[1], comp[2], comp[3]]), 32);
1146        let dec = decompress(&comp, &f).unwrap();
1147        assert_eq!(dec, raw);
1148    }
1149
1150    #[test]
1151    fn integer_u8_roundtrip() {
1152        let raw = vec![10u8, 11, 12, 250, 10, 200];
1153        let f = int_filter(1, false, ORDER_LE, raw.len() as u32);
1154        let comp = compress(&raw, &f).unwrap();
1155        let dec = decompress(&comp, &f).unwrap();
1156        assert_eq!(dec, raw);
1157    }
1158
1159    #[test]
1160    fn integer_i64_roundtrip() {
1161        let vals: [i64; 4] = [-1_000_000, 5, -999_999, 42];
1162        let mut raw = Vec::new();
1163        for &v in &vals {
1164            raw.extend_from_slice(&v.to_le_bytes());
1165        }
1166        let f = int_filter(8, true, ORDER_LE, vals.len() as u32);
1167        let comp = compress(&raw, &f).unwrap();
1168        let dec = decompress(&comp, &f).unwrap();
1169        assert_eq!(dec, raw);
1170    }
1171
1172    #[test]
1173    fn float_dscale_roundtrip_within_tolerance() {
1174        let vals = [1.234f64, 1.235, 1.250, 1.111, 1.234, 1.999];
1175        let decimals = 3;
1176        let mut raw = Vec::new();
1177        for &v in &vals {
1178            raw.extend_from_slice(&v.to_le_bytes());
1179        }
1180        let f = float_filter(8, decimals, ORDER_LE, vals.len() as u32);
1181        let comp = compress(&raw, &f).unwrap();
1182        let dec = decompress(&comp, &f).unwrap();
1183        let got: Vec<f64> = dec
1184            .chunks_exact(8)
1185            .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
1186            .collect();
1187        let tol = 0.5 * 10f64.powi(-decimals);
1188        for (g, v) in got.iter().zip(vals.iter()) {
1189            assert!((g - v).abs() <= tol, "got {g}, want {v}");
1190        }
1191    }
1192
1193    #[test]
1194    fn float32_dscale_roundtrip_be() {
1195        let vals = [10.25f32, 10.50, 10.75, 10.00, 10.25];
1196        let decimals = 2;
1197        let mut raw = Vec::new();
1198        for &v in &vals {
1199            raw.extend_from_slice(&v.to_be_bytes());
1200        }
1201        let f = float_filter(4, decimals, ORDER_BE, vals.len() as u32);
1202        let comp = compress(&raw, &f).unwrap();
1203        let dec = decompress(&comp, &f).unwrap();
1204        let got: Vec<f32> = dec
1205            .chunks_exact(4)
1206            .map(|c| f32::from_be_bytes(c.try_into().unwrap()))
1207            .collect();
1208        let tol = 0.5 * 10f32.powi(-decimals);
1209        for (g, v) in got.iter().zip(vals.iter()) {
1210            assert!((g - v).abs() <= tol, "got {g}, want {v}");
1211        }
1212    }
1213
1214    #[test]
1215    fn truncated_chunk_errors_not_panics() {
1216        // A chunk whose header claims minbits=3 but is shorter than the 21-byte
1217        // header region must error rather than panic when slicing the payload.
1218        let f = int_filter(4, false, ORDER_LE, 4);
1219        let mut bad = Vec::new();
1220        bad.extend_from_slice(&3u32.to_le_bytes()); // minbits = 3
1221        bad.push(8); // minval size
1222        bad.extend_from_slice(&0u64.to_le_bytes()); // minval (bytes 5..13)
1223        // Only 13 bytes total: shorter than the 21-byte header.
1224        assert!(matches!(
1225            decompress(&bad, &f),
1226            Err(FormatError::FilterError(_))
1227        ));
1228    }
1229
1230    #[test]
1231    fn header_byte_layout() {
1232        // Two u32 values {min=5, max=9} -> span 5 -> minbits 3.
1233        let vals = [5u32, 9, 6, 5];
1234        let mut raw = Vec::new();
1235        for &v in &vals {
1236            raw.extend_from_slice(&v.to_le_bytes());
1237        }
1238        let f = int_filter(4, false, ORDER_LE, vals.len() as u32);
1239        let comp = compress(&raw, &f).unwrap();
1240        assert_eq!(u32::from_le_bytes([comp[0], comp[1], comp[2], comp[3]]), 3);
1241        assert_eq!(comp[4], 8); // sizeof(minval)
1242        let minval = u64::from_le_bytes(comp[5..13].try_into().unwrap());
1243        assert_eq!(minval, 5);
1244        assert_eq!(&comp[13..21], &[0u8; 8]); // padding
1245    }
1246
1247    #[test]
1248    fn build_cd_values_rejects_mismatched_mode() {
1249        let int_ty = ScaleOffsetType {
1250            class: CLS_INTEGER,
1251            sign: SGN_2,
1252            order: ORDER_LE,
1253        };
1254        assert!(build_cd_values(ScaleOffset::FloatDScale(2), int_ty, 4, 10).is_err());
1255        let float_ty = ScaleOffsetType {
1256            class: CLS_FLOAT,
1257            sign: SGN_NONE,
1258            order: ORDER_LE,
1259        };
1260        assert!(build_cd_values(ScaleOffset::Integer(0), float_ty, 8, 10).is_err());
1261    }
1262
1263    /// Deterministic xorshift64 used to drive the property-style tests below.
1264    /// Keeps the dev-dependency footprint minimal while still covering a wide
1265    /// input space; seeds are fixed so failures are reproducible.
1266    struct Rng(u64);
1267    impl Rng {
1268        fn new(seed: u64) -> Self {
1269            Self(seed | 1)
1270        }
1271        fn next(&mut self) -> u64 {
1272            let mut x = self.0;
1273            x ^= x << 13;
1274            x ^= x >> 7;
1275            x ^= x << 17;
1276            self.0 = x;
1277            x
1278        }
1279        fn range(&mut self, hi: u64) -> u64 {
1280            // Modulo bias is fine for our coverage purposes.
1281            self.next() % hi
1282        }
1283    }
1284
1285    #[test]
1286    fn pack_unpack_equivalence_random() {
1287        // For every minbits in 1..=64 and across many random widths/lengths,
1288        // pack(offsets) followed by unpack must return the original offsets.
1289        let mut rng = Rng::new(0x00C0_FFEE_F00D_1234);
1290        for _ in 0..400 {
1291            let minbits = (rng.range(64) + 1) as u32; // 1..=64
1292            let nelmts = (rng.range(257) + 1) as usize; // 1..=257
1293            let mask = if minbits == 64 {
1294                u64::MAX
1295            } else {
1296                (1u64 << minbits) - 1
1297            };
1298            let offsets: Vec<u64> = (0..nelmts).map(|_| rng.next() & mask).collect();
1299            let packed = pack_offsets(&offsets, minbits, nelmts).unwrap();
1300            // Reference layout reserves at least one trailing byte.
1301            assert_eq!(packed.len(), nelmts * minbits as usize / 8 + 1);
1302            let unpacked = unpack_bits(&packed, nelmts, minbits).unwrap();
1303            assert_eq!(
1304                unpacked, offsets,
1305                "minbits={minbits}, nelmts={nelmts}, seed=0xC0FFEEF00D1234"
1306            );
1307        }
1308    }
1309
1310    fn roundtrip_random<T: Copy>(
1311        seed: u64,
1312        size: u32,
1313        signed: bool,
1314        order: u32,
1315        encode: impl Fn(&mut Rng) -> T,
1316        to_bytes: impl Fn(T, u32) -> Vec<u8>,
1317    ) {
1318        let mut rng = Rng::new(seed);
1319        for trial in 0..40 {
1320            let nelmts = (rng.range(199) + 1) as usize;
1321            let mut raw = Vec::with_capacity(nelmts * size as usize);
1322            for _ in 0..nelmts {
1323                raw.extend_from_slice(&to_bytes(encode(&mut rng), order));
1324            }
1325            let f = int_filter(size, signed, order, nelmts as u32);
1326            let comp = compress(&raw, &f).unwrap();
1327            let dec = decompress(&comp, &f).unwrap();
1328            assert_eq!(
1329                dec, raw,
1330                "trial {trial}: size={size}, signed={signed}, order={order}"
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn integer_roundtrip_random_u8() {
1337        roundtrip_random::<u8>(0x11, 1, false, ORDER_LE, |r| r.next() as u8, |v, _| vec![v]);
1338    }
1339
1340    #[test]
1341    fn integer_roundtrip_random_u16_le_and_be() {
1342        for &order in &[ORDER_LE, ORDER_BE] {
1343            roundtrip_random::<u16>(
1344                0x22 ^ order as u64,
1345                2,
1346                false,
1347                order,
1348                |r| r.next() as u16,
1349                |v, o| {
1350                    if o == ORDER_LE {
1351                        v.to_le_bytes().to_vec()
1352                    } else {
1353                        v.to_be_bytes().to_vec()
1354                    }
1355                },
1356            );
1357        }
1358    }
1359
1360    #[test]
1361    fn integer_roundtrip_random_i32_narrow_and_wide() {
1362        // Mix narrow (small span -> small minbits) and wide (forces raw path).
1363        let mut rng = Rng::new(0x33);
1364        for trial in 0..40 {
1365            let nelmts = (rng.range(199) + 1) as usize;
1366            let narrow = rng.next() & 1 == 0;
1367            let mut raw = Vec::with_capacity(nelmts * 4);
1368            for _ in 0..nelmts {
1369                let v: i32 = if narrow {
1370                    (rng.range(1024) as i32) - 512 // span ~= 1024
1371                } else {
1372                    rng.next() as i32 // full range
1373                };
1374                raw.extend_from_slice(&v.to_le_bytes());
1375            }
1376            let f = int_filter(4, true, ORDER_LE, nelmts as u32);
1377            let comp = compress(&raw, &f).unwrap();
1378            let dec = decompress(&comp, &f).unwrap();
1379            assert_eq!(dec, raw, "trial {trial}: narrow={narrow}");
1380        }
1381    }
1382
1383    #[test]
1384    fn integer_roundtrip_random_i64() {
1385        roundtrip_random::<i64>(
1386            0x44,
1387            8,
1388            true,
1389            ORDER_LE,
1390            |r| {
1391                // Bias toward narrow spans so we exercise the packing path,
1392                // but include the full-range tail too.
1393                let span = 1u64 << (r.range(48) as u32 + 1);
1394                let v = (r.next() % span) as i64;
1395                let off = (r.next() as i64) >> 1;
1396                v.wrapping_add(off)
1397            },
1398            |v, _| v.to_le_bytes().to_vec(),
1399        );
1400    }
1401
1402    #[test]
1403    fn float_dscale_roundtrip_random_within_tolerance() {
1404        let mut rng = Rng::new(0x55);
1405        for trial in 0..30 {
1406            let nelmts = (rng.range(99) + 1) as usize;
1407            let decimals = (rng.range(5) as i32) + 1; // 1..=5
1408            // Keep magnitudes modest so f64 ULP stays well below the D-scale
1409            // tolerance. Avoid generating values pre-quantized to the same
1410            // precision (decimals) we then round to, otherwise the input
1411            // sits exactly on a rounding boundary.
1412            let mut raw = Vec::with_capacity(nelmts * 8);
1413            let mut vals = Vec::with_capacity(nelmts);
1414            for _ in 0..nelmts {
1415                let v = (rng.next() as i32 as f64) * 1e-9; // |v| <= ~2.1
1416                vals.push(v);
1417                raw.extend_from_slice(&v.to_le_bytes());
1418            }
1419            let f = float_filter(8, decimals, ORDER_LE, nelmts as u32);
1420            let comp = compress(&raw, &f).unwrap();
1421            let dec = decompress(&comp, &f).unwrap();
1422            let got: Vec<f64> = dec
1423                .chunks_exact(8)
1424                .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
1425                .collect();
1426            // 0.5 ULP rounding + a few ULP of float arithmetic noise.
1427            let tol = 0.501 * 10f64.powi(-decimals);
1428            for (g, v) in got.iter().zip(vals.iter()) {
1429                assert!(
1430                    (g - v).abs() <= tol,
1431                    "trial {trial}: got {g}, want {v} (tol {tol})"
1432                );
1433            }
1434        }
1435    }
1436
1437    /// Build a synthetic chunk where `minbits == 0`, the compressed header
1438    /// declares `minval = 7`, but the filter's `cd_values` say
1439    /// `filavail = FILL_DEFINED` with `filval = 99`. The reference C decoder
1440    /// short-circuits the `minbits == 0` path to emit `minval` for every
1441    /// element. The Rust port must match: every element should reconstruct
1442    /// to 7, not 99. Catches the sentinel-collision divergence flagged in
1443    /// the scale-offset review.
1444    #[test]
1445    fn minbits_zero_with_fill_defined_emits_minval_not_filval() {
1446        let nelmts = 5u32;
1447        let size = 4u32;
1448        let mut cd = vec![0u32; TOTAL_NPARMS];
1449        cd[PARM_SCALETYPE] = SO_INT;
1450        cd[PARM_SCALEFACTOR] = 0;
1451        cd[PARM_NELMTS] = nelmts;
1452        cd[PARM_CLASS] = CLS_INTEGER;
1453        cd[PARM_SIZE] = size;
1454        cd[PARM_SIGN] = SGN_NONE;
1455        cd[PARM_ORDER] = ORDER_LE;
1456        cd[PARM_FILAVAIL] = FILL_DEFINED;
1457        cd[PARM_FILVAL] = 99;
1458        let f = FilterDescription {
1459            filter_id: crate::filter_pipeline::FILTER_SCALEOFFSET,
1460            name: None,
1461            flags: 0,
1462            client_data: cd,
1463        };
1464        // 21-byte header (minbits=0, minval=7) + 1-byte trailing pad.
1465        let mut chunk = Vec::with_capacity(HEADER_LEN + 1);
1466        chunk.extend_from_slice(&0u32.to_le_bytes());
1467        chunk.push(8);
1468        chunk.extend_from_slice(&7u64.to_le_bytes());
1469        chunk.extend_from_slice(&[0u8; HEADER_LEN - 13]);
1470        chunk.push(0);
1471        let out = decompress(&chunk, &f).unwrap();
1472        let got: Vec<u32> = out
1473            .chunks_exact(4)
1474            .map(|c| u32::from_le_bytes(c.try_into().unwrap()))
1475            .collect();
1476        assert_eq!(
1477            got,
1478            vec![7u32; nelmts as usize],
1479            "minbits==0 with FILL_DEFINED must emit minval, not filval"
1480        );
1481    }
1482
1483    /// A chunk with `minbits > 0` and `FILL_DEFINED`: the offsets at the
1484    /// all-ones sentinel must reconstruct to `filval`, while every other
1485    /// offset reconstructs to `minval + offset`. Companion to the
1486    /// `minbits == 0` regression above — together they cover both sides of
1487    /// the sentinel branch.
1488    #[test]
1489    fn fill_defined_sentinel_emits_filval_not_minval() {
1490        let nelmts = 4u32;
1491        let size = 4u32;
1492        let mut cd = vec![0u32; TOTAL_NPARMS];
1493        cd[PARM_SCALETYPE] = SO_INT;
1494        cd[PARM_SCALEFACTOR] = 0;
1495        cd[PARM_NELMTS] = nelmts;
1496        cd[PARM_CLASS] = CLS_INTEGER;
1497        cd[PARM_SIZE] = size;
1498        cd[PARM_SIGN] = SGN_NONE;
1499        cd[PARM_ORDER] = ORDER_LE;
1500        cd[PARM_FILAVAIL] = FILL_DEFINED;
1501        cd[PARM_FILVAL] = 999;
1502        let f = FilterDescription {
1503            filter_id: crate::filter_pipeline::FILTER_SCALEOFFSET,
1504            name: None,
1505            flags: 0,
1506            client_data: cd,
1507        };
1508        // minbits = 3 → sentinel = 7. Offsets [0, 1, 7, 2] expect
1509        // [minval+0, minval+1, filval, minval+2] = [10, 11, 999, 12].
1510        let mut chunk = Vec::new();
1511        chunk.extend_from_slice(&3u32.to_le_bytes());
1512        chunk.push(8);
1513        chunk.extend_from_slice(&10u64.to_le_bytes());
1514        chunk.extend_from_slice(&[0u8; HEADER_LEN - 13]);
1515        chunk.extend_from_slice(&pack_offsets(&[0, 1, 7, 2], 3, 4).unwrap());
1516        let out = decompress(&chunk, &f).unwrap();
1517        let got: Vec<u32> = out
1518            .chunks_exact(4)
1519            .map(|c| u32::from_le_bytes(c.try_into().unwrap()))
1520            .collect();
1521        assert_eq!(got, vec![10u32, 11, 999, 12]);
1522    }
1523
1524    /// The writer doesn't support encoding with a defined fill value (the
1525    /// builder doesn't expose one). If someone hands it a `FILL_DEFINED`
1526    /// `cd_values` directly, it must error rather than silently miscompress.
1527    #[test]
1528    fn compress_rejects_fill_defined() {
1529        let nelmts = 4u32;
1530        let size = 4u32;
1531        let mut cd = vec![0u32; TOTAL_NPARMS];
1532        cd[PARM_SCALETYPE] = SO_INT;
1533        cd[PARM_NELMTS] = nelmts;
1534        cd[PARM_CLASS] = CLS_INTEGER;
1535        cd[PARM_SIZE] = size;
1536        cd[PARM_SIGN] = SGN_NONE;
1537        cd[PARM_ORDER] = ORDER_LE;
1538        cd[PARM_FILAVAIL] = FILL_DEFINED;
1539        cd[PARM_FILVAL] = 0;
1540        let f = FilterDescription {
1541            filter_id: crate::filter_pipeline::FILTER_SCALEOFFSET,
1542            name: None,
1543            flags: 0,
1544            client_data: cd,
1545        };
1546        let raw = vec![0u8; nelmts as usize * size as usize];
1547        assert!(matches!(
1548            compress(&raw, &f),
1549            Err(FormatError::FilterError(_))
1550        ));
1551    }
1552
1553    /// Float E-scale is forbidden by the reference library too; we error
1554    /// before touching the payload.
1555    #[test]
1556    fn decompress_rejects_escale() {
1557        let mut cd = vec![0u32; TOTAL_NPARMS];
1558        cd[PARM_SCALETYPE] = SO_FLOAT_ESCALE;
1559        cd[PARM_NELMTS] = 4;
1560        cd[PARM_CLASS] = CLS_FLOAT;
1561        cd[PARM_SIZE] = 4;
1562        cd[PARM_ORDER] = ORDER_LE;
1563        let f = FilterDescription {
1564            filter_id: crate::filter_pipeline::FILTER_SCALEOFFSET,
1565            name: None,
1566            flags: 0,
1567            client_data: cd,
1568        };
1569        let chunk = vec![0u8; HEADER_LEN + 4];
1570        assert!(matches!(
1571            decompress(&chunk, &f),
1572            Err(FormatError::FilterError(_))
1573        ));
1574    }
1575
1576    /// A corrupt header that claims `minbits > size * 8` must error rather
1577    /// than reach the bit reader and panic.
1578    #[test]
1579    fn decompress_rejects_oversized_minbits_header() {
1580        let f = int_filter(4, false, ORDER_LE, 4);
1581        // size=4 → full_bits=32. Set minbits=33.
1582        let mut bad = Vec::new();
1583        bad.extend_from_slice(&33u32.to_le_bytes());
1584        bad.push(8);
1585        bad.extend_from_slice(&0u64.to_le_bytes());
1586        bad.extend_from_slice(&[0u8; HEADER_LEN - 13]);
1587        bad.extend_from_slice(&[0u8; 16]); // dummy payload
1588        assert!(matches!(
1589            decompress(&bad, &f),
1590            Err(FormatError::FilterError(_))
1591        ));
1592    }
1593
1594    /// Signed 1-byte ints: covers the `size == 1` arm of `sign_extend`,
1595    /// which the unsigned `u8` round-trip skips.
1596    #[test]
1597    fn integer_i8_roundtrip_with_negatives() {
1598        let vals: [i8; 6] = [-100, -50, 0, 27, -99, 100];
1599        let raw: Vec<u8> = vals.iter().map(|&v| v as u8).collect();
1600        let f = int_filter(1, true, ORDER_LE, vals.len() as u32);
1601        let comp = compress(&raw, &f).unwrap();
1602        let dec = decompress(&comp, &f).unwrap();
1603        assert_eq!(dec, raw);
1604    }
1605
1606    /// `nelmts == 1` exercises the streaming min/max loops in
1607    /// `precompress_{integer,float}` with an empty body (the `1..nelmts`
1608    /// range yields nothing). Span is 0, minbits is 0 — round-trips both
1609    /// integer and float through the all-equal short-circuit.
1610    #[test]
1611    fn single_element_chunk_roundtrip() {
1612        let raw = 42u32.to_le_bytes().to_vec();
1613        let f = int_filter(4, false, ORDER_LE, 1);
1614        let comp = compress(&raw, &f).unwrap();
1615        let dec = decompress(&comp, &f).unwrap();
1616        assert_eq!(dec, raw);
1617
1618        let raw = 3.14f64.to_le_bytes().to_vec();
1619        let f = float_filter(8, 3, ORDER_LE, 1);
1620        let comp = compress(&raw, &f).unwrap();
1621        let dec = decompress(&comp, &f).unwrap();
1622        let got = f64::from_le_bytes(dec.as_slice().try_into().unwrap());
1623        assert!((got - 3.14).abs() <= 0.5e-3);
1624    }
1625
1626    #[test]
1627    fn scale_offset_type_from_datatype_classes() {
1628        let i32_ty = Datatype::FixedPoint {
1629            size: 4,
1630            byte_order: DatatypeByteOrder::LittleEndian,
1631            signed: true,
1632            bit_offset: 0,
1633            bit_precision: 32,
1634        };
1635        let so = scale_offset_type_from_datatype(&i32_ty).unwrap();
1636        assert_eq!(so.class, CLS_INTEGER);
1637        assert_eq!(so.sign, SGN_2);
1638        assert_eq!(so.order, ORDER_LE);
1639
1640        let f64_ty = Datatype::FloatingPoint {
1641            size: 8,
1642            byte_order: DatatypeByteOrder::BigEndian,
1643            bit_offset: 0,
1644            bit_precision: 64,
1645            exponent_location: 52,
1646            exponent_size: 11,
1647            mantissa_location: 0,
1648            mantissa_size: 52,
1649            exponent_bias: 1023,
1650        };
1651        let so = scale_offset_type_from_datatype(&f64_ty).unwrap();
1652        assert_eq!(so.class, CLS_FLOAT);
1653        assert_eq!(so.order, ORDER_BE);
1654    }
1655}