Skip to main content

limnifs_write/
classifier.rs

1//! Drop classifier (seine): entropy + magic-byte heuristics.
2//!
3//! Labels each drop's plaintext with a class so the deepening stage
4//! can pick a class-appropriate codec. Pure-functional, stateless,
5//! deterministic — same input always yields the same class.
6//!
7//! ## Classes
8//!
9//! - `Text` — UTF-8 printable, mostly ASCII
10//! - `Code` — executable container (ELF, Mach-O, PE)
11//! - `Compressed` — already-compressed bytes (gzip, zstd, xz, bz2)
12//! - `Media` — image / audio / video container (JPEG, PNG, GIF, MP3, MP4)
13//! - `Sparse` — dominated by zero bytes
14//! - `Binary` — fallback when no other class fits
15//! - `Incompressible` — high entropy, no magic: random/encrypted; skip codec
16//!
17//! ## Algorithm
18//!
19//! 1. If the first bytes match a known magic, return that class
20//!    immediately. Magic detection is the highest-confidence signal.
21//! 2. Otherwise, compute Shannon entropy over a sample (first 4 KiB):
22//!    - < 0.5 → `Sparse` if zero-byte ratio is also high, else `Text`
23//!    - 0.5–6.5 → `Text` if mostly printable, else `Binary`
24//!    - 6.5–7.5 → `Incompressible` (likely random/encrypted; skip codec)
25//!    - ≥ 7.5 → `Incompressible` (very high entropy, no magic: same)
26//! 3. Fall back to `Binary`.
27//!
28//! Note: `Compressed` is only ever returned by the magic-byte path
29//! (gzip/zstd/xz streams). High-entropy data without a recognised
30//! magic is `Incompressible`, not `Compressed` — the previous label
31//! was a misnomer that caused the writer to attempt (and fail)
32//! compression on random/encrypted input.
33
34/// Number of bytes at the drop's start used for classification.
35/// The full drop can be megabytes; the first 4 KiB is enough signal.
36pub const CLASSIFIER_SAMPLE_SIZE: usize = 4 * 1024;
37
38/// Shannon entropy threshold below which data is "low entropy".
39const LOW_ENTROPY_THRESHOLD: f32 = 0.5;
40/// Shannon entropy threshold above which data is "high entropy"
41/// (typical of compressed or encrypted bytes).
42const HIGH_ENTROPY_THRESHOLD: f32 = 7.5;
43/// Shannon entropy above which mid-entropy data with no magic match
44/// is almost certainly random/encrypted and uncompressible. Below
45/// `HIGH_ENTROPY_THRESHOLD` so genuinely-compressed streams (which
46/// have very high entropy AND magic bytes) still get caught by the
47/// magic-byte check first.
48const INCOMPRESSIBLE_THRESHOLD: f32 = 6.5;
49/// Zero-byte ratio above which low-entropy data is labelled `Sparse`.
50const SPARSE_ZERO_RATIO_THRESHOLD: f32 = 0.8;
51/// Printable-ASCII ratio above which mid-entropy data is labelled `Text`.
52const TEXT_PRINTABLE_RATIO_THRESHOLD: f32 = 0.85;
53
54/// One of the content classes the seine classifier emits. Each chunk
55/// gets exactly one class; the writer's drop-packing layer routes
56/// classes to codecs.
57#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
58pub enum Class {
59    Text,
60    Code,
61    Binary,
62    Compressed,
63    Media,
64    Sparse,
65    /// High-entropy data with no recognised magic. Almost certainly
66    /// random bytes (CSPRNG output, encrypted payloads, /dev/urandom
67    /// samples) or already-compressed data with no header. Either way
68    /// compression won't help; route to STORE without trying.
69    Incompressible,
70}
71
72impl Class {
73    /// Stable 1-byte encoding for the class (used in the slab's
74    /// per-class solid-window index, future deepening records, etc.).
75    #[must_use]
76    pub const fn to_id(self) -> u8 {
77        match self {
78            Self::Text => 0x01,
79            Self::Code => 0x02,
80            Self::Binary => 0x03,
81            Self::Compressed => 0x04,
82            Self::Media => 0x05,
83            Self::Sparse => 0x06,
84            Self::Incompressible => 0x07,
85        }
86    }
87
88    /// Human-readable name.
89    #[must_use]
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::Text => "text",
93            Self::Code => "code",
94            Self::Binary => "binary",
95            Self::Compressed => "compressed",
96            Self::Media => "media",
97            Self::Sparse => "sparse",
98            Self::Incompressible => "incompressible",
99        }
100    }
101}
102
103/// A stateless classifier. Holding it in a struct (rather than a free
104/// function) leaves room for future configuration without changing
105/// the call sites (OCP).
106#[derive(Copy, Clone, Debug, Default)]
107pub struct Classifier;
108
109impl Classifier {
110    /// Classify `data` by sampling its first `CLASSIFIER_SAMPLE_SIZE`
111    /// bytes. Empty input classifies as `Sparse`.
112    #[must_use]
113    pub fn classify(&self, data: &[u8]) -> Class {
114        if data.is_empty() {
115            return Class::Sparse;
116        }
117        let sample = if data.len() <= CLASSIFIER_SAMPLE_SIZE {
118            data
119        } else {
120            &data[..CLASSIFIER_SAMPLE_SIZE]
121        };
122        // Magic bytes win first — they're the highest-confidence signal.
123        if let Some(class) = detect_magic(sample) {
124            return class;
125        }
126        let entropy = shannon_entropy(sample);
127        let zero_ratio = zero_byte_ratio(sample);
128        if entropy < LOW_ENTROPY_THRESHOLD && zero_ratio > SPARSE_ZERO_RATIO_THRESHOLD {
129            return Class::Sparse;
130        }
131        if entropy >= HIGH_ENTROPY_THRESHOLD {
132            // Very high entropy + no magic match: either random/
133            // encrypted, or a compressed stream whose header wasn't
134            // recognised. Either way compression won't help.
135            return Class::Incompressible;
136        }
137        if entropy >= INCOMPRESSIBLE_THRESHOLD {
138            // High-but-not-very-high entropy with no magic: probably
139            // random/encrypted. Compression attempt wastes CPU for
140            // no ratio gain. Route straight to STORE.
141            return Class::Incompressible;
142        }
143        let printable_ratio = printable_ascii_ratio(sample);
144        if printable_ratio > TEXT_PRINTABLE_RATIO_THRESHOLD {
145            return Class::Text;
146        }
147        Class::Binary
148    }
149}
150
151/// Check the first bytes against known magic constants. Returns
152/// `Some(class)` if a magic matches, `None` otherwise.
153fn detect_magic(data: &[u8]) -> Option<Class> {
154    if data.starts_with(&[0x1F, 0x8B]) {
155        return Some(Class::Compressed); // gzip
156    }
157    if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
158        return Some(Class::Compressed); // zstd
159    }
160    if data.starts_with(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]) {
161        return Some(Class::Compressed); // xz
162    }
163    if data.starts_with(&[0x42, 0x5A, 0x68]) {
164        return Some(Class::Compressed); // bz2
165    }
166    if data.starts_with(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]) {
167        return Some(Class::Compressed); // 7z
168    }
169    if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
170        return Some(Class::Media); // JPEG
171    }
172    if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
173        return Some(Class::Media); // PNG
174    }
175    if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
176        return Some(Class::Media); // GIF
177    }
178    if data.starts_with(&[0x52, 0x49, 0x46, 0x46]) && data.len() >= 12 && &data[8..12] == b"WEBP" {
179        return Some(Class::Media); // WebP
180    }
181    if data.starts_with(&[0xFF, 0xFB]) || data.starts_with(&[0x49, 0x44, 0x33]) {
182        return Some(Class::Media); // MP3 (ID3 or frame sync)
183    }
184    if data.starts_with(&[0x66, 0x4C, 0x61, 0x43]) {
185        return Some(Class::Media); // FLAC
186    }
187    if data.len() >= 12 && &data[4..8] == b"ftyp" {
188        return Some(Class::Media); // ISO BMFF (MP4, MOV, HEIF)
189    }
190    if data.starts_with(&[0x4F, 0x67, 0x67, 0x53]) {
191        return Some(Class::Media); // Ogg
192    }
193    if data.starts_with(&[0x7F, 0x45, 0x4C, 0x46]) {
194        return Some(Class::Code); // ELF
195    }
196    if data.len() >= 4
197        && (data.starts_with(&[0xFE, 0xED, 0xFA, 0xCE])
198            || data.starts_with(&[0xFE, 0xED, 0xFA, 0xCF])
199            || data.starts_with(&[0xCE, 0xFA, 0xED, 0xFE])
200            || data.starts_with(&[0xCF, 0xFA, 0xED, 0xFE]))
201    {
202        return Some(Class::Code); // Mach-O
203    }
204    if data.len() >= 2 && data[0] == 0x4D && data[1] == 0x5A {
205        return Some(Class::Code); // PE / DOS MZ
206    }
207    None
208}
209
210/// Shannon entropy in bits per byte, computed over `data`.
211///
212/// We cast `usize -> f64` for the byte count; the sample is at most
213/// `CLASSIFIER_SAMPLE_SIZE` (4 KiB) so the precision loss clippy
214/// warns about is not a concern here.
215#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
216fn shannon_entropy(data: &[u8]) -> f32 {
217    if data.is_empty() {
218        return 0.0;
219    }
220    let mut counts = [0u32; 256];
221    for &b in data {
222        counts[usize::from(b)] += 1;
223    }
224    let total = data.len() as f64;
225    let mut entropy = 0.0_f64;
226    for &count in &counts {
227        if count == 0 {
228            continue;
229        }
230        let p = f64::from(count) / total;
231        entropy -= p * p.log2();
232    }
233    entropy as f32
234}
235
236/// Fraction of bytes that are zero.
237#[allow(
238    clippy::cast_precision_loss,
239    clippy::naive_bytecount,
240    clippy::cast_possible_truncation
241)]
242fn zero_byte_ratio(data: &[u8]) -> f32 {
243    if data.is_empty() {
244        return 0.0;
245    }
246    let zeros = data.iter().filter(|&&b| b == 0).count();
247    (zeros as f64 / data.len() as f64) as f32
248}
249
250/// Fraction of bytes that are printable ASCII (0x20..0x7E) plus
251/// common whitespace (newline, tab, carriage return).
252#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
253fn printable_ascii_ratio(data: &[u8]) -> f32 {
254    if data.is_empty() {
255        return 0.0;
256    }
257    let printable = data
258        .iter()
259        .filter(|&&b| (0x20..=0x7E).contains(&b) || b == b'\n' || b == b'\r' || b == b'\t')
260        .count();
261    (printable as f64 / data.len() as f64) as f32
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn empty_classifies_as_sparse() {
270        assert_eq!(Classifier.classify(&[]), Class::Sparse);
271    }
272
273    #[test]
274    fn gzip_magic_wins_over_entropy() {
275        let mut data = vec![0x1F, 0x8B, 0x08];
276        data.extend(std::iter::repeat(0).take(100));
277        assert_eq!(Classifier.classify(&data), Class::Compressed);
278    }
279
280    #[test]
281    fn zstd_magic_detected() {
282        let data = [0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00];
283        assert_eq!(Classifier.classify(&data), Class::Compressed);
284    }
285
286    #[test]
287    fn xz_magic_detected() {
288        let data = [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, 0x00, 0x00];
289        assert_eq!(Classifier.classify(&data), Class::Compressed);
290    }
291
292    #[test]
293    fn jpeg_magic_detected() {
294        let data = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
295        assert_eq!(Classifier.classify(&data), Class::Media);
296    }
297
298    #[test]
299    fn png_magic_detected() {
300        let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
301        assert_eq!(Classifier.classify(&data), Class::Media);
302    }
303
304    #[test]
305    fn gif_magic_detected() {
306        assert_eq!(Classifier.classify(b"GIF89a..."), Class::Media);
307    }
308
309    #[test]
310    fn webp_magic_detected() {
311        let data = [
312            0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, b'W', b'E', b'B', b'P',
313        ];
314        assert_eq!(Classifier.classify(&data), Class::Media);
315    }
316
317    #[test]
318    fn mp3_id3_magic_detected() {
319        let data = [b'I', b'D', b'3', 0x03, 0x00, 0x00, 0x00];
320        assert_eq!(Classifier.classify(&data), Class::Media);
321    }
322
323    #[test]
324    fn elf_magic_detected() {
325        let data = [0x7F, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00];
326        assert_eq!(Classifier.classify(&data), Class::Code);
327    }
328
329    #[test]
330    fn macho_magic_detected() {
331        let data = [0xFE, 0xED, 0xFA, 0xCF, 0x00, 0x00, 0x00, 0x01];
332        assert_eq!(Classifier.classify(&data), Class::Code);
333    }
334
335    #[test]
336    fn pe_magic_detected() {
337        let data = [0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00];
338        assert_eq!(Classifier.classify(&data), Class::Code);
339    }
340
341    #[test]
342    fn plain_text_classifies_as_text() {
343        let text = b"Hello, world!\nThis is a plain text file.\nLines of prose.\n";
344        assert_eq!(Classifier.classify(text), Class::Text);
345    }
346
347    #[test]
348    fn code_like_source_classifies_as_text() {
349        let source = b"fn main() {\n    println!(\"hello\");\n}\n";
350        assert_eq!(Classifier.classify(source), Class::Text);
351    }
352
353    #[test]
354    fn mostly_zeros_classifies_as_sparse() {
355        let mut data = vec![0u8; 4096];
356        data[0] = 0x42;
357        data[100] = 0x99;
358        assert_eq!(Classifier.classify(&data), Class::Sparse);
359    }
360
361    #[test]
362    fn high_entropy_random_classifies_as_compressed() {
363        let mut data = Vec::with_capacity(4096);
364        let mut state: u64 = 1;
365        for _ in 0..4096 {
366            state = state
367                .wrapping_mul(6_364_136_223_846_793_005)
368                .wrapping_add(1_442_695_040_888_963_407);
369            data.push(u8::try_from(state >> 56).expect("fits u8"));
370        }
371        let class = Classifier.classify(&data);
372        // High-entropy random with no magic match must route to
373        // Incompressible so the writer skips the (futile) compression
374        // attempt. Previously this returned Compressed/Binary, both of
375        // which caused LZ4/Brotli to be tried on random bytes — wasted
376        // CPU for zero ratio gain.
377        assert_eq!(
378            class,
379            Class::Incompressible,
380            "expected Incompressible for high-entropy random, got {class:?}"
381        );
382    }
383
384    #[test]
385    fn mid_entropy_non_printable_classifies_as_binary() {
386        // Mix of non-printable bytes that doesn't match any magic
387        // and doesn't have enough printable content to be text.
388        let mut data = Vec::with_capacity(4096);
389        for i in 0..4096u32 {
390            data.push(u8::try_from((i * 7 + 0x80) & 0xFF).expect("fits"));
391        }
392        let class = Classifier.classify(&data);
393        // Should not be Text or Sparse. Code/Compressed/Media/Binary all OK.
394        assert!(
395            class != Class::Text && class != Class::Sparse,
396            "expected non-text non-sparse for mid-entropy non-printable, got {class:?}"
397        );
398    }
399
400    #[test]
401    fn class_to_id_round_trips() {
402        for class in [
403            Class::Text,
404            Class::Code,
405            Class::Binary,
406            Class::Compressed,
407            Class::Media,
408            Class::Sparse,
409        ] {
410            assert_eq!(class.as_str().len(), class.as_str().len());
411            assert_ne!(class.to_id(), 0);
412        }
413    }
414
415    #[test]
416    fn classifier_is_deterministic() {
417        let data: Vec<u8> = (0..1024u32)
418            .map(|i| u8::try_from(i & 0xFF).expect("fits"))
419            .collect();
420        let a = Classifier.classify(&data);
421        let b = Classifier.classify(&data);
422        assert_eq!(a, b);
423    }
424}