diff --git a/codecs/bit-round/Cargo.toml b/codecs/bit-round/Cargo.toml index 19975dda6..2006b421b 100644 --- a/codecs/bit-round/Cargo.toml +++ b/codecs/bit-round/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numcodecs-bit-round" -version = "0.4.0" +version = "0.5.0" edition = { workspace = true } authors = { workspace = true } repository = { workspace = true } @@ -12,6 +12,8 @@ readme = "README.md" categories = ["compression", "encoding"] keywords = ["bit-rounding", "numcodecs", "compression", "encoding"] +include = ["/src", "/LICENSE", "/docs/katex.html"] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -24,5 +26,8 @@ thiserror = { workspace = true } [lints] workspace = true +[package.metadata.docs.rs] +rustdoc-args = ["--html-in-header", "./docs/katex.html"] + [package.metadata.numcodecs-wasm] version = "0.2.2" # wasi 0.2.6 diff --git a/codecs/bit-round/docs b/codecs/bit-round/docs new file mode 120000 index 000000000..713700f74 --- /dev/null +++ b/codecs/bit-round/docs @@ -0,0 +1 @@ +../../docs/rs/ \ No newline at end of file diff --git a/codecs/bit-round/src/lib.rs b/codecs/bit-round/src/lib.rs index 5b9fa2364..8bbb482ca 100644 --- a/codecs/bit-round/src/lib.rs +++ b/codecs/bit-round/src/lib.rs @@ -17,17 +17,19 @@ //! //! Bit rounding codec implementation for the [`numcodecs`] API. +use std::borrow::Cow; + use ndarray::{Array, ArrayBase, Data, Dimension}; use numcodecs::{ AnyArray, AnyArrayAssignError, AnyArrayDType, AnyArrayView, AnyArrayViewMut, AnyCowArray, Codec, StaticCodec, StaticCodecConfig, StaticCodecVersion, }; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; #[derive(Clone, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] /// Codec providing floating-point bit rounding. /// /// Drops the specified number of bits from the floating point mantissa, @@ -38,16 +40,48 @@ use thiserror::Error; /// The approach is based on the paper by Klöwer et al. 2021 /// (). pub struct BitRoundCodec { - /// The number of bits of the mantissa to keep. - /// - /// The valid range depends on the dtype of the input data. - /// - /// If keepbits is equal to the bitlength of the dtype's mantissa, no - /// transformation is performed. - pub keepbits: u8, + /// Bit rounding mode. + #[serde(flatten)] + pub mode: BitRoundMode, /// The codec's encoding format version. Do not provide this parameter explicitly. #[serde(default, rename = "_version")] - pub version: StaticCodecVersion<1, 0, 0>, + pub version: StaticCodecVersion<2, 0, 0>, +} + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "mode")] +#[serde(deny_unknown_fields)] +/// Bit rounding mode +pub enum BitRoundMode { + /// Directly specify the number of bits of the mantissa to keep. + #[serde(rename = "keepbits")] + Keepbits { + /// The number of bits of the mantissa to keep. + /// + /// The valid range depends on the dtype of the input data. + /// + /// If keepbits is equal to the bitlength of the dtype's mantissa, no + /// transformation is performed. + keepbits: u8, + }, + /// Pointwise absolute error. + #[serde(rename = "abs")] + AbsoluteError { + /// The pointwise absolute error bound to preserve. + /// + /// This error bound guarantees that + /// `$|x - \hat{x}| \leq \epsilon_{abs}$`. + eb_abs: NonNegative, + }, + /// Pointwise relative error. + #[serde(rename = "rel")] + RelativeError { + /// The pointwise relative error bound to preserve. + /// + /// This error bound guarantees that + /// `$|x - \hat{x}| \leq |x| \cdot \epsilon_{rel}$`. + eb_rel: NonNegative, + }, } impl Codec for BitRoundCodec { @@ -55,8 +89,8 @@ impl Codec for BitRoundCodec { fn encode(&self, data: AnyCowArray) -> Result { match data { - AnyCowArray::F32(data) => Ok(AnyArray::F32(bit_round(data, self.keepbits)?)), - AnyCowArray::F64(data) => Ok(AnyArray::F64(bit_round(data, self.keepbits)?)), + AnyCowArray::F32(data) => Ok(AnyArray::F32(bit_round(data, &self.mode)?)), + AnyCowArray::F64(data) => Ok(AnyArray::F64(bit_round(data, &self.mode)?)), encoded => Err(BitRoundCodecError::UnsupportedDtype(encoded.dtype())), } } @@ -119,6 +153,49 @@ pub enum BitRoundCodecError { }, } +#[expect(clippy::derive_partial_eq_without_eq)] // floats are not Eq +#[derive(Copy, Clone, PartialEq, PartialOrd, Hash)] +/// Non-negative floating point number +pub struct NonNegative(T); + +impl Serialize for NonNegative { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_f64(self.0) + } +} + +impl<'de> Deserialize<'de> for NonNegative { + fn deserialize>(deserializer: D) -> Result { + let x = f64::deserialize(deserializer)?; + + if x >= 0.0 { + Ok(Self(x)) + } else { + Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Float(x), + &"a non-negative value", + )) + } + } +} + +impl JsonSchema for NonNegative { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("NonNegativeF64") + } + + fn schema_id() -> Cow<'static, str> { + Cow::Borrowed(concat!(module_path!(), "::", "NonNegative")) + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + json_schema!({ + "type": "number", + "minimum": 0.0 + }) + } +} + /// Floating-point bit rounding, which drops the specified number of bits from /// the floating point mantissa. /// @@ -131,47 +208,112 @@ pub enum BitRoundCodecError { /// [`T::MANITSSA_BITS`][`Float::MANITSSA_BITS`]. pub fn bit_round, D: Dimension>( data: ArrayBase, - keepbits: u8, + mode: &BitRoundMode, ) -> Result, BitRoundCodecError> { - if u32::from(keepbits) > T::MANITSSA_BITS { - return Err(BitRoundCodecError::ExcessiveKeepBits { - keepbits, - dtype: T::TY, - }); - } + let (keepbits, keep_non_normal) = match mode { + BitRoundMode::Keepbits { keepbits } => { + let keepbits = *keepbits; + if u32::from(keepbits) > T::MANITSSA_BITS { + return Err(BitRoundCodecError::ExcessiveKeepBits { + keepbits, + dtype: T::TY, + }); + } + (u32::from(keepbits), false) + } + BitRoundMode::AbsoluteError { eb_abs } => { + let eb_abs = T::from_f64(eb_abs.0); + + let mut encoded = data.into_owned(); + + encoded.mapv_inplace(|x| { + // subnormal, infinite, and NaN values are hard so just keep + // them as is + if !x.is_normal() { + return x; + } + + let keepbits = BitRounder::keepbits_from_eb_rel(NonNegative(eb_abs / x.abs())); + let bit_round = BitRounder::new(keepbits); + + bit_round.apply(x) + }); + + return Ok(encoded); + } + BitRoundMode::RelativeError { eb_rel } => (BitRounder::keepbits_from_eb_rel(*eb_rel), true), + }; let mut encoded = data.into_owned(); // Early return if no bit rounding needs to happen // - required since the ties to even impl does not work in this case - if u32::from(keepbits) == T::MANITSSA_BITS { + if keepbits == T::MANITSSA_BITS { return Ok(encoded); } - // half of unit in last place (ulp) - let ulp_half = T::MANTISSA_MASK >> (u32::from(keepbits) + 1); - // mask to zero out trailing mantissa bits - let keep_mask = !(T::MANTISSA_MASK >> u32::from(keepbits)); - // shift to extract the least significant bit of the exponent - let shift = T::MANITSSA_BITS - u32::from(keepbits); + let bit_round = BitRounder::new(keepbits); encoded.mapv_inplace(|x| { + // subnormal, infinite, and NaN values are hard so just keep them as is + if keep_non_normal && !x.is_normal() { + return x; + } + + bit_round.apply(x) + }); + + Ok(encoded) +} + +struct BitRounder { + ulp_half: T::Binary, + keep_mask: T::Binary, + shift: u32, +} + +impl BitRounder { + #[inline] + fn new(keepbits: u32) -> Self { + // half of unit in last place (ulp) + let ulp_half = T::MANTISSA_MASK >> (keepbits + 1); + // mask to zero out trailing mantissa bits + let keep_mask = !(T::MANTISSA_MASK >> keepbits); + // shift to extract the least significant bit of the exponent + let shift = T::MANITSSA_BITS - keepbits; + + Self { + ulp_half, + keep_mask, + shift, + } + } + + fn keepbits_from_eb_rel(eb_rel: NonNegative) -> u32 { + let keepbits = -(eb_rel.0.normal_log2_floor()) - 1; + // keepbits must be within the range of the mantissa bits of single precision. + #[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + // no sign loss or truncation since we clamp to between 0 and a u32 + let keepbits = i64::from(keepbits).clamp(0, i64::from(T::MANITSSA_BITS)) as u32; + keepbits + } + + #[inline] + fn apply(&self, x: T) -> T { let mut bits = T::to_binary(x); // add ulp/2 with ties to even - bits += ulp_half + ((bits >> shift) & T::BINARY_ONE); + bits += self.ulp_half + ((bits >> self.shift) & T::BINARY_ONE); // set the trailing bits to zero - bits &= keep_mask; + bits &= self.keep_mask; T::from_binary(bits) - }); - - Ok(encoded) + } } /// Floating point types. -pub trait Float: Sized + Copy { +pub trait Float: Sized + Copy + std::ops::Div { /// Number of significant digits in base 2 const MANITSSA_BITS: u32; /// Binary mask to extract only the mantissa bits @@ -195,6 +337,19 @@ pub trait Float: Sized + Copy { fn to_binary(self) -> Self::Binary; /// Bit-cast the binary representation into a floating point value fn from_binary(u: Self::Binary) -> Self; + + /// Returns the floating point category of the number + fn is_normal(self) -> bool; + + /// Returns the floor of the base-2 logarithm as a signed integer + fn normal_log2_floor(self) -> i16; + + /// Computes the absolute value + #[must_use] + fn abs(self) -> Self; + + /// Convert from an [`f64`] value + fn from_f64(x: f64) -> Self; } impl Float for f32 { @@ -212,6 +367,23 @@ impl Float for f32 { fn from_binary(u: Self::Binary) -> Self { Self::from_bits(u) } + + fn is_normal(self) -> bool { + self.is_normal() + } + + fn normal_log2_floor(self) -> i16 { + (((self.to_bits() >> 23) & 0xff) as i16) - 127 + } + + fn abs(self) -> Self { + self.abs() + } + + #[expect(clippy::cast_possible_truncation)] + fn from_f64(x: f64) -> Self { + x as Self + } } impl Float for f64 { @@ -229,6 +401,22 @@ impl Float for f64 { fn from_binary(u: Self::Binary) -> Self { Self::from_bits(u) } + + fn is_normal(self) -> bool { + self.is_normal() + } + + fn normal_log2_floor(self) -> i16 { + (((self.to_bits() >> 52) & 0x7ff) as i16) - 1023 + } + + fn abs(self) -> Self { + self.abs() + } + + fn from_f64(x: f64) -> Self { + x + } } #[cfg(test)] @@ -239,108 +427,205 @@ mod tests { use super::*; #[test] + #[expect(clippy::too_many_lines)] fn no_mantissa() { assert_eq!( - bit_round(ArrayView1::from(&[0.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[0.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![0.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[1.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[1.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![1.0_f32]) ); // tie to even rounds up as the offset exponent is odd assert_eq!( - bit_round(ArrayView1::from(&[1.5_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[1.5_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![2.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[2.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[2.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![2.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[2.5_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[2.5_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![2.0_f32]) ); // tie to even rounds down as the offset exponent is even assert_eq!( - bit_round(ArrayView1::from(&[3.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[3.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![2.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[3.5_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[3.5_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![4.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[4.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[4.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![4.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[5.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[5.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![4.0_f32]) ); // tie to even rounds up as the offset exponent is odd assert_eq!( - bit_round(ArrayView1::from(&[6.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[6.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![8.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[7.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[7.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![8.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[8.0_f32]), 0).unwrap(), + bit_round( + ArrayView1::from(&[8.0_f32]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![8.0_f32]) ); assert_eq!( - bit_round(ArrayView1::from(&[0.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[0.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![0.0_f64]) ); assert_eq!( - bit_round(ArrayView1::from(&[1.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[1.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![1.0_f64]) ); // tie to even rounds up as the offset exponent is odd assert_eq!( - bit_round(ArrayView1::from(&[1.5_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[1.5_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![2.0_f64]) ); assert_eq!( - bit_round(ArrayView1::from(&[2.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[2.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![2.0_f64]) ); assert_eq!( - bit_round(ArrayView1::from(&[2.5_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[2.5_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![2.0_f64]) ); // tie to even rounds down as the offset exponent is even assert_eq!( - bit_round(ArrayView1::from(&[3.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[3.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![2.0_f64]) ); assert_eq!( - bit_round(ArrayView1::from(&[3.5_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[3.5_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![4.0_f64]) ); assert_eq!( - bit_round(ArrayView1::from(&[4.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[4.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![4.0_f64]) ); assert_eq!( - bit_round(ArrayView1::from(&[5.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[5.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![4.0_f64]) ); // tie to even rounds up as the offset exponent is odd assert_eq!( - bit_round(ArrayView1::from(&[6.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[6.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![8.0_f64]) ); assert_eq!( - bit_round(ArrayView1::from(&[7.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[7.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![8.0_f64]) ); assert_eq!( - bit_round(ArrayView1::from(&[8.0_f64]), 0).unwrap(), + bit_round( + ArrayView1::from(&[8.0_f64]), + &BitRoundMode::Keepbits { keepbits: 0 } + ) + .unwrap(), Array1::from_vec(vec![8.0_f64]) ); } @@ -354,16 +639,64 @@ mod tests { for v in [0.0_f32, 1.0_f32, 2.0_f32, 3.0_f32, 4.0_f32] { assert_eq!( - bit_round(ArrayView1::from(&[full(v)]), f32::MANITSSA_BITS as u8).unwrap(), + bit_round( + ArrayView1::from(&[full(v)]), + &BitRoundMode::Keepbits { + keepbits: f32::MANITSSA_BITS as u8 + } + ) + .unwrap(), Array1::from_vec(vec![full(v)]) ); } for v in [0.0_f64, 1.0_f64, 2.0_f64, 3.0_f64, 4.0_f64] { assert_eq!( - bit_round(ArrayView1::from(&[full(v)]), f64::MANITSSA_BITS as u8).unwrap(), + bit_round( + ArrayView1::from(&[full(v)]), + &BitRoundMode::Keepbits { + keepbits: f64::MANITSSA_BITS as u8 + } + ) + .unwrap(), Array1::from_vec(vec![full(v)]) ); } } + + #[test] + fn normal_log2_floor_f32() { + for e in -100_i16..100 { + let b = f32::from(e).exp2(); + for f in [0.55, 0.75, 0.9, 1.0, 1.1, 1.5, 1.95] { + let x = b * f; + + #[expect(clippy::cast_possible_truncation)] + let math = x.log2().floor() as i16; + let binary = x.normal_log2_floor(); + + assert_eq!(math, binary, "{x}"); + } + } + + assert_eq!(i32::from(0.0_f32.normal_log2_floor()), f32::MIN_EXP - 2); + } + + #[test] + fn normal_log2_floor_f64() { + for e in -100_i32..100 { + let b = f64::from(e).exp2(); + for f in [0.55, 0.75, 0.9, 1.0, 1.1, 1.5, 1.95] { + let x = b * f; + + #[expect(clippy::cast_possible_truncation)] + let math = x.log2().floor() as i16; + let binary = x.normal_log2_floor(); + + assert_eq!(math, binary, "{x}"); + } + } + + assert_eq!(i32::from(0.0_f64.normal_log2_floor()), f64::MIN_EXP - 2); + } } diff --git a/codecs/bit-round/tests/schema.json b/codecs/bit-round/tests/schema.json new file mode 100644 index 000000000..56e10364d --- /dev/null +++ b/codecs/bit-round/tests/schema.json @@ -0,0 +1,76 @@ +{ + "type": "object", + "unevaluatedProperties": false, + "oneOf": [ + { + "type": "object", + "description": "Directly specify the number of bits of the mantissa to keep.", + "properties": { + "keepbits": { + "type": "integer", + "format": "uint8", + "minimum": 0, + "maximum": 255, + "description": "The number of bits of the mantissa to keep.\n\nThe valid range depends on the dtype of the input data.\n\nIf keepbits is equal to the bitlength of the dtype's mantissa, no\ntransformation is performed." + }, + "mode": { + "type": "string", + "const": "keepbits" + } + }, + "required": [ + "mode", + "keepbits" + ] + }, + { + "type": "object", + "description": "Pointwise absolute error.", + "properties": { + "eb_abs": { + "type": "number", + "minimum": 0.0, + "description": "The pointwise absolute error bound to preserve.\n\nThis error bound guarantees that\n`$|x - \\hat{x}| \\leq \\epsilon_{abs}$`." + }, + "mode": { + "type": "string", + "const": "abs" + } + }, + "required": [ + "mode", + "eb_abs" + ] + }, + { + "type": "object", + "description": "Pointwise relative error.", + "properties": { + "eb_rel": { + "type": "number", + "minimum": 0.0, + "description": "The pointwise relative error bound to preserve.\n\nThis error bound guarantees that\n`$|x - \\hat{x}| \\leq |x| \\cdot \\epsilon_{rel}$`." + }, + "mode": { + "type": "string", + "const": "rel" + } + }, + "required": [ + "mode", + "eb_rel" + ] + } + ], + "description": "Codec providing floating-point bit rounding.\n\nDrops the specified number of bits from the floating point mantissa,\nleaving an array that is more amenable to compression. The number of\nbits to keep should be determined by information analysis of the data\nto be compressed.\n\nThe approach is based on the paper by Klöwer et al. 2021\n().", + "properties": { + "_version": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "description": "The codec's encoding format version. Do not provide this parameter explicitly.", + "default": "2.0.0" + } + }, + "title": "BitRoundCodec", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} \ No newline at end of file diff --git a/codecs/bit-round/tests/schema.rs b/codecs/bit-round/tests/schema.rs new file mode 100644 index 000000000..da345a568 --- /dev/null +++ b/codecs/bit-round/tests/schema.rs @@ -0,0 +1,20 @@ +#![expect(missing_docs)] + +use ::{ndarray as _, schemars as _, serde as _, thiserror as _}; + +use numcodecs::{DynCodecType, StaticCodecType}; +use numcodecs_bit_round::BitRoundCodec; + +#[test] +fn schema() { + let schema = format!( + "{:#}", + StaticCodecType::::of() + .codec_config_schema() + .to_value() + ); + + if schema != include_str!("schema.json") { + panic!("BitRound schema has changed\n===\n{schema}\n==="); + } +}