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}