Skip to main content

scirs2_io/columnar/
fsst.rs

1//! FSST (Fast Static Symbol Table) string compression.
2//!
3//! FSST replaces frequent byte substrings (1- or 2-byte "symbols") with
4//! single-byte codes, achieving good compression for string columns while
5//! remaining extremely fast at decompression.
6//!
7//! ## Algorithm overview
8//!
9//! 1. **Training**: scan samples, count 1-byte and 2-byte substring frequencies.
10//! 2. **Symbol selection**: greedily pick the top `max_symbols` by
11//!    `score = len × frequency`.
12//! 3. **Compression**: left-to-right scan; emit 2-byte symbol code if possible,
13//!    else 1-byte symbol code, else escape byte (255) + raw byte.
14//! 4. **Decompression**: look up each code in the symbol table; escape byte
15//!    means the next byte is raw.
16
17use crate::error::{IoError, Result as IoResult};
18
19// ---------------------------------------------------------------------------
20// Symbol
21// ---------------------------------------------------------------------------
22
23/// One entry in the FSST symbol table.
24#[derive(Debug, Clone, Default)]
25pub struct FsstSymbol {
26    /// Raw bytes of the symbol (at most 2 bytes).
27    pub bytes: [u8; 2],
28    /// Number of bytes in this symbol (1 or 2).
29    pub len: u8,
30    /// Training score: `len × frequency`.
31    pub score: u64,
32}
33
34impl FsstSymbol {
35    fn one_byte(b: u8, freq: u64) -> Self {
36        Self {
37            bytes: [b, 0],
38            len: 1,
39            score: freq,
40        }
41    }
42
43    fn two_bytes(b0: u8, b1: u8, freq: u64) -> Self {
44        Self {
45            bytes: [b0, b1],
46            len: 2,
47            score: freq.saturating_mul(2),
48        }
49    }
50}
51
52// ---------------------------------------------------------------------------
53// FsstSymbolTable
54// ---------------------------------------------------------------------------
55
56/// FSST symbol table: up to 255 symbols (code 255 = escape byte).
57///
58/// The table is built from training data and then used to compress/decompress
59/// arbitrary byte sequences.
60#[derive(Debug, Clone)]
61pub struct FsstSymbolTable {
62    /// Symbols indexed by code `0..symbols.len()`.
63    pub symbols: Vec<FsstSymbol>,
64    /// 2-byte lookup: `[first_byte][second_byte]` → code or `255` (not found).
65    encode_2byte: Box<[[u8; 256]; 256]>,
66    /// 1-byte lookup: `[byte]` → code or `255` (not found).
67    encode_1byte: [u8; 256],
68}
69
70impl FsstSymbolTable {
71    /// Build a symbol table by training on byte samples.
72    ///
73    /// `max_symbols` is clamped to 254 (codes 0..254; code 255 = escape).
74    pub fn train(samples: &[&[u8]], max_symbols: usize) -> Self {
75        let max_symbols = max_symbols.min(254);
76
77        // Count 1-byte and 2-byte frequencies
78        let mut freq1 = vec![0u64; 256];
79        let mut freq2 = vec![vec![0u64; 256]; 256];
80
81        for sample in samples {
82            let len = sample.len();
83            for i in 0..len {
84                freq1[sample[i] as usize] += 1;
85                if i + 1 < len {
86                    freq2[sample[i] as usize][sample[i + 1] as usize] += 1;
87                }
88            }
89        }
90
91        // Collect candidates with score = len * frequency
92        let mut candidates: Vec<FsstSymbol> = Vec::with_capacity(256 + 256 * 256);
93
94        for b in 0u8..=255u8 {
95            let f = freq1[b as usize];
96            if f > 0 {
97                candidates.push(FsstSymbol::one_byte(b, f));
98            }
99        }
100
101        for b0 in 0usize..256 {
102            for b1 in 0usize..256 {
103                let f = freq2[b0][b1];
104                if f > 0 {
105                    candidates.push(FsstSymbol::two_bytes(b0 as u8, b1 as u8, f));
106                }
107            }
108        }
109
110        // Sort by descending score; break ties by descending len (prefer 2-byte)
111        candidates.sort_by(|a, b| b.score.cmp(&a.score).then(b.len.cmp(&a.len)));
112        candidates.truncate(max_symbols);
113
114        // Build lookup tables
115        let mut encode_2byte = Box::new([[255u8; 256]; 256]);
116        let mut encode_1byte = [255u8; 256];
117
118        for (code, sym) in candidates.iter().enumerate() {
119            let c = code as u8;
120            if sym.len == 1 {
121                encode_1byte[sym.bytes[0] as usize] = c;
122            } else {
123                encode_2byte[sym.bytes[0] as usize][sym.bytes[1] as usize] = c;
124            }
125        }
126
127        Self {
128            symbols: candidates,
129            encode_2byte,
130            encode_1byte,
131        }
132    }
133
134    /// Compress a byte slice using this symbol table.
135    ///
136    /// Escape code `255` followed by a raw byte is used for bytes not covered
137    /// by any symbol.
138    pub fn compress(&self, input: &[u8]) -> Vec<u8> {
139        let mut out = Vec::with_capacity(input.len());
140        let mut i = 0;
141        let len = input.len();
142
143        while i < len {
144            // Try 2-byte match first
145            if i + 1 < len {
146                let code = self.encode_2byte[input[i] as usize][input[i + 1] as usize];
147                if code != 255 {
148                    out.push(code);
149                    i += 2;
150                    continue;
151                }
152            }
153            // Try 1-byte match
154            let code = self.encode_1byte[input[i] as usize];
155            if code != 255 {
156                out.push(code);
157                i += 1;
158                continue;
159            }
160            // Escape
161            out.push(255);
162            out.push(input[i]);
163            i += 1;
164        }
165
166        out
167    }
168
169    /// Decompress a byte slice compressed with `compress`.
170    pub fn decompress(&self, compressed: &[u8]) -> Vec<u8> {
171        let mut out = Vec::with_capacity(compressed.len() * 2);
172        let mut i = 0;
173        let len = compressed.len();
174
175        while i < len {
176            let code = compressed[i];
177            i += 1;
178
179            if code == 255 {
180                // Next byte is raw
181                if i < len {
182                    out.push(compressed[i]);
183                    i += 1;
184                }
185            } else if (code as usize) < self.symbols.len() {
186                let sym = &self.symbols[code as usize];
187                out.push(sym.bytes[0]);
188                if sym.len == 2 {
189                    out.push(sym.bytes[1]);
190                }
191            }
192            // Unknown code → skip (should not happen for valid compressed data)
193        }
194
195        out
196    }
197
198    /// Number of symbols in this table.
199    pub fn n_symbols(&self) -> usize {
200        self.symbols.len()
201    }
202
203    /// Compute the compression ratio: `original.len() / compressed.len()`.
204    ///
205    /// Returns `f64::INFINITY` if `compressed` is empty.
206    pub fn compression_ratio(&self, original: &[u8], compressed: &[u8]) -> f64 {
207        original.len() as f64 / compressed.len().max(1) as f64
208    }
209}
210
211// ---------------------------------------------------------------------------
212// FsstColumnEncoder
213// ---------------------------------------------------------------------------
214
215/// High-level encoder for a column of strings backed by an [`FsstSymbolTable`].
216pub struct FsstColumnEncoder {
217    /// The trained symbol table.
218    pub table: FsstSymbolTable,
219}
220
221impl FsstColumnEncoder {
222    /// Train an encoder on a sample of the provided strings.
223    ///
224    /// `sample_fraction` controls what fraction of strings are used for
225    /// training (clamped to `(0.0, 1.0]`).  Sampling is deterministic:
226    /// every `floor(1 / sample_fraction)`-th string is taken.
227    pub fn train(strings: &[&str], sample_fraction: f64) -> IoResult<Self> {
228        if strings.is_empty() {
229            return Ok(Self {
230                table: FsstSymbolTable::train(&[], 254),
231            });
232        }
233
234        let fraction = sample_fraction.clamp(1e-6, 1.0);
235        let step = (1.0 / fraction).floor() as usize;
236        let step = step.max(1);
237
238        let samples: Vec<&[u8]> = strings
239            .iter()
240            .enumerate()
241            .filter(|(i, _)| i % step == 0)
242            .map(|(_, s)| s.as_bytes())
243            .collect();
244
245        if samples.is_empty() {
246            return Err(IoError::FormatError(
247                "FSST training: no samples selected (sample_fraction too small?)".to_string(),
248            ));
249        }
250
251        let table = FsstSymbolTable::train(&samples, 254);
252        Ok(Self { table })
253    }
254
255    /// Compress all strings in the column.
256    pub fn compress_column(&self, strings: &[&str]) -> Vec<Vec<u8>> {
257        strings
258            .iter()
259            .map(|s| self.table.compress(s.as_bytes()))
260            .collect()
261    }
262
263    /// Decompress a column of byte slices back to UTF-8 strings.
264    ///
265    /// Returns an error if any decompressed byte sequence is not valid UTF-8.
266    pub fn decompress_column(&self, compressed: &[Vec<u8>]) -> IoResult<Vec<String>> {
267        compressed
268            .iter()
269            .enumerate()
270            .map(|(i, bytes)| {
271                let raw = self.table.decompress(bytes);
272                String::from_utf8(raw).map_err(|e| {
273                    IoError::FormatError(format!(
274                        "FSST decompress: string {} is not valid UTF-8: {e}",
275                        i
276                    ))
277                })
278            })
279            .collect()
280    }
281
282    /// Compute the average compression ratio over all strings.
283    pub fn column_compression_ratio(&self, strings: &[&str]) -> f64 {
284        if strings.is_empty() {
285            return 1.0;
286        }
287        let total_original: usize = strings.iter().map(|s| s.len()).sum();
288        let total_compressed: usize = strings
289            .iter()
290            .map(|s| self.table.compress(s.as_bytes()).len())
291            .sum();
292        total_original as f64 / total_compressed.max(1) as f64
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_fsst_compress_decompress_roundtrip() {
306        // Train on some representative strings
307        let samples: Vec<&str> = vec![
308            "hello world",
309            "hello rust",
310            "world of rust",
311            "hello hello world",
312        ];
313        let sample_bytes: Vec<&[u8]> = samples.iter().map(|s| s.as_bytes()).collect();
314        let table = FsstSymbolTable::train(&sample_bytes, 254);
315
316        // Roundtrip every sample
317        for s in &samples {
318            let compressed = table.compress(s.as_bytes());
319            let decompressed = table.decompress(&compressed);
320            assert_eq!(decompressed, s.as_bytes(), "roundtrip failed for {:?}", s);
321        }
322    }
323
324    #[test]
325    fn test_fsst_table_size_bounded() {
326        let data: Vec<String> = (0..1000).map(|i| format!("item_{i}_data")).collect();
327        let samples: Vec<&[u8]> = data.iter().map(|s| s.as_bytes()).collect();
328        let table = FsstSymbolTable::train(&samples, 254);
329        assert!(
330            table.n_symbols() <= 254,
331            "symbol table exceeds 254: {}",
332            table.n_symbols()
333        );
334    }
335
336    #[test]
337    fn test_fsst_column_encoder_roundtrip() {
338        let strings: Vec<&str> = vec![
339            "sensor_lab_a",
340            "sensor_lab_b",
341            "sensor_lab_a",
342            "sensor_lab_c",
343            "sensor_lab_a",
344            "sensor_lab_b",
345        ];
346
347        let encoder = FsstColumnEncoder::train(&strings, 1.0).expect("training failed");
348
349        let compressed = encoder.compress_column(&strings);
350        let decompressed = encoder
351            .decompress_column(&compressed)
352            .expect("decompress failed");
353
354        let original: Vec<String> = strings.iter().map(|s| s.to_string()).collect();
355        assert_eq!(decompressed, original);
356    }
357
358    #[test]
359    fn test_fsst_compression_ratio_positive() {
360        // For repetitive data, compression ratio should be > 1
361        let repeated = "aaaa bbbb cccc dddd aaaa bbbb aaaa";
362        let samples = &[repeated.as_bytes()];
363        let table = FsstSymbolTable::train(samples, 254);
364        let compressed = table.compress(repeated.as_bytes());
365        let ratio = table.compression_ratio(repeated.as_bytes(), &compressed);
366        // At least decent: should be >= 0.5
367        assert!(ratio > 0.0, "ratio should be positive");
368    }
369
370    #[test]
371    fn test_fsst_empty_input() {
372        let table = FsstSymbolTable::train(&[], 254);
373        let compressed = table.compress(&[]);
374        assert!(compressed.is_empty());
375        let decompressed = table.decompress(&[]);
376        assert!(decompressed.is_empty());
377    }
378
379    #[test]
380    fn test_fsst_escape_byte_roundtrip() {
381        // Create a symbol table trained on different data so some bytes need escaping
382        let train_data: Vec<&[u8]> = vec![b"aabbcc"];
383        let table = FsstSymbolTable::train(&train_data, 254);
384
385        // Input with bytes that may not be in the table
386        let input: Vec<u8> = (0u8..=127).collect();
387        let compressed = table.compress(&input);
388        let decompressed = table.decompress(&compressed);
389        assert_eq!(decompressed, input);
390    }
391
392    #[test]
393    fn test_fsst_column_encoder_sample_fraction() {
394        let strings: Vec<String> = (0..100).map(|i| format!("item_{i}")).collect();
395        let refs: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
396
397        // Train with 10% sample
398        let encoder = FsstColumnEncoder::train(&refs, 0.1).expect("training failed");
399
400        let compressed = encoder.compress_column(&refs);
401        let decompressed = encoder
402            .decompress_column(&compressed)
403            .expect("decompress failed");
404
405        assert_eq!(decompressed.len(), strings.len());
406        for (a, b) in strings.iter().zip(decompressed.iter()) {
407            assert_eq!(a, b);
408        }
409    }
410}