Skip to main content

tensogram_encodings/bitmask/
mod.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
9//! Compressed bitmasks for the NaN / Inf bitmask companion frame.
10//!
11//! Used by `NTensorFrame` (wire type 9, see
12//! `plans/WIRE_FORMAT.md` §6.5) to record the positions of NaN / +Inf /
13//! -Inf values in a float payload.  The payload itself has those
14//! positions substituted with `0.0`; the bitmask tells the decoder
15//! where to restore the non-finite values on decode.
16//!
17//! # Mask method registry
18//!
19//! Six methods, configurable per-mask via the [`MaskMethod`] enum:
20//!
21//! | Method | When to pick | Notes |
22//! |---|---|---|
23//! | [`MaskMethod::Roaring`] (default) | always | Hybrid array / bitmap / RLE containers.  Best all-rounder; [Roaring Portable Serialization Format](https://github.com/RoaringBitmap/RoaringFormatSpec). |
24//! | [`MaskMethod::Rle`] | highly-clustered masks (land / sea masks, swath gaps) | Bandwidth-bound encode / decode; see [`rle`] for on-wire format. |
25//! | [`MaskMethod::Blosc2`] | dense dtype-aligned masks | Reuses `BLOSC_BITSHUFFLE` filter + sub-codec; feature-gated. |
26//! | [`MaskMethod::Zstd`] | generic good-ratio path | Reuses the main codec; always available. |
27//! | [`MaskMethod::Lz4`] | decode-speed priority | Reuses the main codec. |
28//! | [`MaskMethod::None`] | tiny masks (≤ small-mask threshold) | Raw packed bytes, no compression. |
29//!
30//! The small-mask fallback (auto-switch to `None` when the
31//! uncompressed byte count is ≤ `small_mask_threshold_bytes`, default
32//! 128) lives at the encoder-integration layer rather than here — this
33//! module encodes whichever method the caller picks.
34
35pub mod codecs;
36pub mod packing;
37pub mod rle;
38pub mod roaring;
39
40use thiserror::Error;
41
42/// Bitmask compression method selector.  Serialised in the CBOR
43/// descriptor's `masks[kind].method` field; see `plans/WIRE_FORMAT.md`
44/// §6.5.1 for the schema.
45#[derive(Debug, Clone, PartialEq, Eq, Default)]
46pub enum MaskMethod {
47    /// Bit-level run-length encoding.  See [`rle`] for on-wire layout.
48    ///
49    /// Best on highly-clustered masks (land / sea, swath gaps).  Worst
50    /// on random alternating bits — the small-mask fallback at the
51    /// encoder-integration layer redirects to [`MaskMethod::None`] in
52    /// that case.
53    Rle,
54    /// Roaring Bitmap, serialised via the standard
55    /// [Roaring Portable Serialization Format](https://github.com/RoaringBitmap/RoaringFormatSpec).
56    ///
57    /// Default mask method on every platform (including `wasm32`).
58    /// Hybrid containers (array / bitmap / RLE) adapt to mask density
59    /// automatically.
60    #[default]
61    Roaring,
62    /// Blosc2 with `BLOSC_BITSHUFFLE` filter and a sub-codec (default
63    /// LZ4).  Feature-gated on `blosc2`.
64    #[cfg(feature = "blosc2")]
65    Blosc2 {
66        /// Inner codec after the bit-shuffle.
67        codec: crate::pipeline::Blosc2Codec,
68        /// Codec-specific level (1..=9 for most).
69        level: i32,
70    },
71    /// Zstandard on the bit-packed bytes.
72    Zstd {
73        /// Optional level; `None` uses the codec's default (3).
74        level: Option<i32>,
75    },
76    /// LZ4 on the bit-packed bytes.
77    Lz4,
78    /// Uncompressed raw packed bytes — chosen automatically for tiny
79    /// masks where compression overhead exceeds the savings.
80    None,
81}
82
83impl MaskMethod {
84    /// Canonical string name used in the CBOR `masks[kind].method`
85    /// field.  Stable across the wire; do not change without a format
86    /// version bump.
87    pub fn name(&self) -> &'static str {
88        match self {
89            Self::Rle => "rle",
90            Self::Roaring => "roaring",
91            #[cfg(feature = "blosc2")]
92            Self::Blosc2 { .. } => "blosc2",
93            Self::Zstd { .. } => "zstd",
94            Self::Lz4 => "lz4",
95            Self::None => "none",
96        }
97    }
98
99    /// Inverse of [`name`].  Does not parse the `params` sub-map —
100    /// method-specific parameters are handled by the encoder /
101    /// decoder integration layer.  Returns [`MaskError::UnknownMethod`]
102    /// if the name is not one of the known values.  Returns a
103    /// sub-codec feature-disabled error for blosc2 if the feature is
104    /// off at build time.
105    pub fn from_name(name: &str) -> Result<Self, MaskError> {
106        match name {
107            "rle" => Ok(Self::Rle),
108            "roaring" => Ok(Self::Roaring),
109            "blosc2" => {
110                #[cfg(feature = "blosc2")]
111                {
112                    Ok(Self::Blosc2 {
113                        codec: crate::pipeline::Blosc2Codec::Lz4,
114                        level: 5,
115                    })
116                }
117                #[cfg(not(feature = "blosc2"))]
118                {
119                    Err(MaskError::FeatureDisabled { method: "blosc2" })
120                }
121            }
122            "zstd" => Ok(Self::Zstd { level: None }),
123            "lz4" => Ok(Self::Lz4),
124            "none" => Ok(Self::None),
125            other => Err(MaskError::UnknownMethod(other.to_string())),
126        }
127    }
128}
129
130/// Errors surfaced by the bitmask compress / decompress path.
131///
132/// Mapped to [`crate::TensogramError::Encoding`] /
133/// [`crate::TensogramError::Compression`] by the encoder integration
134/// layer.
135#[derive(Debug, Error)]
136#[non_exhaustive]
137pub enum MaskError {
138    #[error(
139        "unknown mask method {0:?} (expected \"none\" | \"rle\" | \"roaring\" | \"lz4\" | \"zstd\" | \"blosc2\")"
140    )]
141    UnknownMethod(String),
142    #[error("mask method {method:?} requires feature {method:?} which is not compiled in")]
143    FeatureDisabled { method: &'static str },
144    #[error("bitmask length mismatch: expected {expected} elements, got {actual}")]
145    LengthMismatch { expected: usize, actual: usize },
146    #[error("malformed mask payload: {0}")]
147    Malformed(String),
148    #[error("RLE decode error: {0}")]
149    Rle(String),
150    #[error("Roaring decode error: {0}")]
151    Roaring(String),
152    #[error("underlying codec error: {0}")]
153    Codec(String),
154    /// Fallible bitmask-buffer reservation failed — a descriptor-derived
155    /// `n_elements` (on decode) or `bits.len()` (on the repack path that
156    /// compression codecs call after decode) is too large for the
157    /// allocator to satisfy. Surfaced instead of the process-abort that
158    /// would otherwise come from an infallible `Vec::with_capacity` /
159    /// `vec![false; N]` / `vec![0u8; N]`.
160    #[error("failed to reserve {bytes} bytes for bitmask buffer: {reason}")]
161    AllocationFailed { bytes: usize, reason: String },
162}
163
164pub(crate) fn try_reserve_mask(v: &mut Vec<bool>, n: usize) -> Result<(), MaskError> {
165    v.try_reserve_exact(n)
166        .map_err(|e| MaskError::AllocationFailed {
167            bytes: n,
168            reason: e.to_string(),
169        })
170}
171
172/// A bitmask is represented in memory as a vector of `bool` with length
173/// equal to the element count of the tensor it masks.  `true` at index
174/// `i` means "position `i` holds the masked non-finite kind"; `false`
175/// means "position `i` is finite (or a different kind of non-finite)".
176///
177/// Conversion to / from the bit-packed wire representation is handled
178/// by [`packing`].
179pub type Bitmask = Vec<bool>;
180
181#[cfg(test)]
182mod allocation_tests {
183    use super::*;
184
185    #[test]
186    fn try_reserve_mask_rejects_pathological_capacity() {
187        let mut v: Vec<bool> = Vec::new();
188        let err = try_reserve_mask(&mut v, isize::MAX as usize + 1)
189            .expect_err("reservation beyond isize::MAX must fail capacity check");
190        match err {
191            MaskError::AllocationFailed { .. } => {}
192            other => panic!("expected AllocationFailed, got {other:?}"),
193        }
194    }
195
196    #[test]
197    fn roaring_decode_rejects_addressable_limit() {
198        // The pre-existing `> u32::MAX` guard fires for n_elements one
199        // past the addressable range, producing a `Malformed` error
200        // rather than an allocation abort.  After the hardening the
201        // guard still dominates for this sentinel, and the
202        // allocation-helper path is covered by the test above.
203        let err = roaring::decode(&[], u32::MAX as usize + 1)
204            .expect_err("roaring must reject n_elements > u32::MAX");
205        match err {
206            MaskError::Malformed(_) => {}
207            other => panic!("expected Malformed, got {other:?}"),
208        }
209    }
210}
211
212#[cfg(test)]
213mod method_registry_tests {
214    use super::*;
215
216    /// Every feature-independent method round-trips through
217    /// `name()` → `from_name()` unchanged.  Pins the wire-stable
218    /// string mapping in both directions.
219    #[test]
220    fn method_name_round_trip() {
221        for method in [
222            MaskMethod::Rle,
223            MaskMethod::Roaring,
224            MaskMethod::Zstd { level: None },
225            MaskMethod::Lz4,
226            MaskMethod::None,
227        ] {
228            let name = method.name();
229            let parsed = MaskMethod::from_name(name).expect("known name must parse");
230            assert_eq!(parsed, method, "round-trip mismatch for {name:?}");
231        }
232    }
233
234    /// `Zstd` carries a level but its canonical name is level-agnostic;
235    /// `from_name("zstd")` yields the default (`None`) level, and the
236    /// name is the same regardless of the level the variant holds.
237    #[test]
238    fn zstd_name_is_level_agnostic() {
239        assert_eq!(MaskMethod::Zstd { level: Some(9) }.name(), "zstd");
240        assert_eq!(MaskMethod::Zstd { level: None }.name(), "zstd");
241        assert_eq!(
242            MaskMethod::from_name("zstd").unwrap(),
243            MaskMethod::Zstd { level: None }
244        );
245    }
246
247    /// The default method is Roaring, and it names to "roaring".
248    #[test]
249    fn default_method_is_roaring() {
250        assert_eq!(MaskMethod::default(), MaskMethod::Roaring);
251        assert_eq!(MaskMethod::default().name(), "roaring");
252    }
253
254    /// An unrecognised method name surfaces `UnknownMethod` carrying the
255    /// offending string, and its Display lists the accepted values.
256    #[test]
257    fn from_name_rejects_unknown() {
258        let err = MaskMethod::from_name("brotli").expect_err("unknown method must be rejected");
259        match &err {
260            MaskError::UnknownMethod(name) => assert_eq!(name, "brotli"),
261            other => panic!("expected UnknownMethod, got {other:?}"),
262        }
263        let msg = err.to_string();
264        assert!(
265            msg.contains("brotli"),
266            "Display must name the bad method: {msg}"
267        );
268        assert!(
269            msg.contains("roaring"),
270            "Display must list accepted values: {msg}"
271        );
272    }
273
274    /// `blosc2` parses to the LZ4 sub-codec default when the feature is
275    /// compiled in; when it is not, it surfaces `FeatureDisabled`.
276    #[test]
277    fn blosc2_name_depends_on_feature() {
278        let result = MaskMethod::from_name("blosc2");
279        #[cfg(feature = "blosc2")]
280        {
281            let m = result.expect("blosc2 must parse when the feature is on");
282            assert_eq!(m.name(), "blosc2");
283        }
284        #[cfg(not(feature = "blosc2"))]
285        {
286            match result.expect_err("blosc2 must be rejected when the feature is off") {
287                MaskError::FeatureDisabled { method } => assert_eq!(method, "blosc2"),
288                other => panic!("expected FeatureDisabled, got {other:?}"),
289            }
290        }
291    }
292
293    /// Spot-check the remaining `MaskError` Display strings so the
294    /// `#[error(...)]` format args are exercised (mutation-resistant).
295    #[test]
296    fn mask_error_display_strings() {
297        assert!(
298            MaskError::LengthMismatch {
299                expected: 10,
300                actual: 7
301            }
302            .to_string()
303            .contains("expected 10 elements, got 7")
304        );
305        assert!(
306            MaskError::Malformed("bad header".into())
307                .to_string()
308                .contains("bad header")
309        );
310        assert!(MaskError::Rle("eof".into()).to_string().contains("eof"));
311        assert!(
312            MaskError::Roaring("bad container".into())
313                .to_string()
314                .contains("bad container")
315        );
316        assert!(
317            MaskError::Codec("zstd boom".into())
318                .to_string()
319                .contains("zstd boom")
320        );
321    }
322}