Skip to main content

cram_codec/
lib.rs

1//! Error-bounded compression of scientific arrays with a **bit-accounted
2//! quantized tensor train (QTT)** codec.
3//!
4//! A grid of `N` samples is tensorized into a `d`-way tensor with mode sizes
5//! of mostly 2 ([`tensorize`]), decomposed by TT-SVD in the canonical
6//! left-orthogonal gauge ([`tt`]), and coded by [`codec`]: per-column
7//! equal-slope quantization *with error feedback* (each core is refit against
8//! the already-quantized prefix), ranks picked by rate–distortion rather than
9//! a separate tolerance, and static rANS over transmitted frequency tables
10//! ([`rans`]). The stream is self-describing and every byte counts toward
11//! the reported rate — header, shape, ranks, step exponents, tables, payload,
12//! and (in max-abs mode) the sparse residual-correction stream.
13//!
14//! ```
15//! use cram_codec::{decode, encode_f32, Config, Decoded, ErrorBound};
16//! let x: Vec<f32> = (0..4096).map(|i| (i as f32 * 0.01).sin()).collect();
17//! let (bytes, stats) = encode_f32(&x, &[64, 64], &Config::new(ErrorBound::MaxAbs(1e-3)));
18//! let Decoded::F32 { data, .. } = decode(&bytes).unwrap() else { unreachable!() };
19//! assert!(x.iter().zip(&data).all(|(a, b)| (a - b).abs() <= 1e-3));
20//! assert_eq!(stats.bytes, bytes.len());
21//! ```
22//!
23//! Other codecs — wavelet ([`wavcodec`]), Tucker ([`tucker`]),
24//! interpolation ([`interp`]) and the brick-parallel GPU layouts
25//! ([`gpuwav`], [`gpuwav3`]) — share the [`ErrorBound`] vocabulary, and
26//! [`decode_any`] decodes any of them by magic.
27//!
28//! # Features and targets
29//!
30//! - Default features are empty: CPU encode and decode only.
31//! - `gpu` adds `gpudec::GpuDecoder`, a wgpu decoder for tc-gpu / TCG3
32//!   streams whose output is bit-identical to the CPU decoder.
33//! - The crate builds for `wasm32-unknown-unknown`; the wasm-bindgen browser
34//!   front end is the (unpublished) `cram-codec-wasm` crate in the tang
35//!   repository.
36//!
37//! # Numerical notes
38//!
39//! Truncated SVDs use the Gram matrix of the smaller unfolding side with
40//! `tang_la::SymmetricEigen` and `tang_la` GEMMs (row-major, NEON on
41//! aarch64), rather than `tang_la::Svd` (one-sided Jacobi), which is far too
42//! slow on `2 × 2^23` unfoldings. Squaring the spectrum resolves singular
43//! values down to roughly `1e-7·σ₁` in f64 — finer than any step the codec
44//! uses at PSNR ≤ ~120 dB. Encoding and decoding are single threaded and
45//! deterministic: the same input yields the same bytes, and decoding the same
46//! bytes yields the same array, run to run.
47
48#![warn(missing_docs)]
49// Dense index loops read more clearly than iterator chains here; tang CI
50// allows this lint workspace-wide too.
51#![allow(clippy::needless_range_loop)]
52
53pub mod abac;
54pub mod bitio;
55pub mod codec;
56pub mod coefcode;
57#[cfg(feature = "gpu")]
58pub mod gpudec;
59pub mod gpuwav;
60pub mod gpuwav3;
61pub mod img;
62#[cfg(feature = "gpu")]
63pub mod imggpu;
64pub mod interp;
65pub mod irdec;
66pub mod kvcodec;
67pub mod kvgpu;
68pub mod learned;
69pub mod linalg;
70pub mod lmodel3;
71pub mod lorenzo;
72pub mod rans;
73pub mod shipdec;
74pub mod tensorize;
75pub mod tt;
76pub mod tucker;
77pub mod wavcodec;
78pub mod wavelet;
79
80pub use codec::{decode, encode_f32, encode_f64, Config, Decoded, Dtype, EncodeStats, ErrorBound};
81pub use tensorize::{DigitOrder, Layout, PadPolicy};
82pub use tt::{tt_svd, TensorTrain, Truncation};
83
84/// Decode any cram-codec stream (QTT, wavelet, Tucker, interpolation or Lorenzo),
85/// by magic; a leading portfolio tag byte is skipped.
86pub fn decode_any(bytes: &[u8]) -> Result<Decoded, bitio::DecodeError> {
87    if let Some(&t) = bytes.first() {
88        if (TAG_WAVELET..=TAG_LORENZO).contains(&t) {
89            return decode_any(&bytes[1..]);
90        }
91    }
92    match bytes.get(..4) {
93        Some(m) if m == wavcodec::MAGIC => wavcodec::decode(bytes),
94        Some(m) if m == tucker::MAGIC => tucker::decode(bytes),
95        Some(m) if m == interp::MAGIC => interp::decode(bytes),
96        Some(m) if m == lorenzo::MAGIC => lorenzo::decode(bytes),
97        _ => codec::decode(bytes),
98    }
99}
100
101/// Portfolio tag: wavelet stream follows.
102pub const TAG_WAVELET: u8 = 0xA0;
103/// Portfolio tag: Tucker stream follows.
104pub const TAG_TUCKER: u8 = 0xA1;
105/// Portfolio tag: QTT stream follows.
106pub const TAG_QTT: u8 = 0xA2;
107/// Portfolio tag: interpolation stream follows.
108pub const TAG_INTERP: u8 = 0xA3;
109/// Portfolio tag: Lorenzo stream follows.
110pub const TAG_LORENZO: u8 = 0xA4;
111
112/// A portfolio candidate.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum Candidate {
115    /// [`wavcodec`] with the configured coefficient model.
116    Wavelet,
117    /// [`tucker`] (PSNR/RMSE targets, 2D/3D).
118    Tucker,
119    /// The QTT [`codec`] in row-major digit order.
120    Qtt,
121    /// [`interp`].
122    Interp,
123    /// The QTT codec's residual-only stream (max-abs; QTT tag and format).
124    QttSparse,
125    /// [`lorenzo`].
126    Lorenzo,
127}
128
129impl Candidate {
130    /// Stream tag.
131    pub fn tag(self) -> u8 {
132        match self {
133            Candidate::Wavelet => TAG_WAVELET,
134            Candidate::Tucker => TAG_TUCKER,
135            Candidate::Qtt => TAG_QTT,
136            Candidate::Interp => TAG_INTERP,
137            Candidate::QttSparse => TAG_QTT,
138            Candidate::Lorenzo => TAG_LORENZO,
139        }
140    }
141    /// Short name.
142    pub fn name(self) -> &'static str {
143        match self {
144            Candidate::Wavelet => "wavelet",
145            Candidate::Tucker => "tucker",
146            Candidate::Qtt => "qtt",
147            Candidate::Interp => "interp",
148            Candidate::QttSparse => "qtt-sparse",
149            Candidate::Lorenzo => "lorenzo",
150        }
151    }
152}
153
154/// What the portfolio encoder did.
155#[derive(Clone, Debug)]
156pub struct AutoReport {
157    /// The candidate whose stream was kept.
158    pub pick: Candidate,
159    /// Every candidate encoded: `(candidate, stream bytes, encode seconds)`.
160    pub tried: Vec<(Candidate, usize, f64)>,
161    /// Candidates the pre-screen skipped.
162    pub skipped: Vec<Candidate>,
163    /// Pre-screen QTT probe max rank (see [`prescreen`]).
164    pub probe_rank: usize,
165}
166
167/// Every-`stride`-th sample along each axis longer than `4·stride`
168/// (row-major), with the new shape.
169pub fn decimate(x: &[f32], dims: &[usize], stride: usize) -> (Vec<f32>, Vec<usize>) {
170    let st: Vec<usize> = dims
171        .iter()
172        .map(|&d| if d >= 4 * stride { stride } else { 1 })
173        .collect();
174    let nd: Vec<usize> = dims.iter().zip(&st).map(|(&d, &s)| d.div_ceil(s)).collect();
175    let n: usize = nd.iter().product();
176    let mut out = Vec::with_capacity(n);
177    let mut idx = vec![0usize; dims.len()];
178    for _ in 0..n {
179        let mut off = 0;
180        for (k, &i) in idx.iter().enumerate() {
181            off = off * dims[k] + i * st[k];
182        }
183        out.push(x[off]);
184        for k in (0..dims.len()).rev() {
185            idx[k] += 1;
186            if idx[k] < nd[k] {
187                break;
188            }
189            idx[k] = 0;
190        }
191    }
192    (out, nd)
193}
194
195/// QTT rate probe: row-major QTT on a `stride`-decimated copy at the same
196/// absolute bound (PSNR is turned into the full field's RMSE), ranks capped
197/// at `cap`. Returns `(probe bytes, max rank, seconds)`.
198pub fn qtt_probe(
199    x: &[f32],
200    dims: &[usize],
201    bound: codec::ErrorBound,
202    stride: usize,
203    cap: usize,
204) -> (usize, usize, f64) {
205    let t = std::time::Instant::now();
206    let b = match bound {
207        codec::ErrorBound::Psnr(p) => {
208            let (lo, hi) = x
209                .iter()
210                .fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), &v| {
211                    (a.min(v), b.max(v))
212                });
213            codec::ErrorBound::Rmse((hi - lo) as f64 / 10f64.powf(p / 20.0))
214        }
215        b => b,
216    };
217    let (y, nd) = decimate(x, dims, stride);
218    let mut q = codec::Config::new(b);
219    q.order = DigitOrder::RowMajor;
220    q.max_rank = cap;
221    let (s, st) = codec::encode_f32(&y, &nd, &q);
222    let r = st.ranks.iter().copied().max().unwrap_or(0);
223    (s.len(), r, t.elapsed().as_secs_f64())
224}
225
226/// QTT probe rank cap ([`qtt_probe`] with stride 2): full QTT runs only
227/// when the probe's ranks stay below it.
228pub const QTT_PROBE_CAP: usize = 64;
229
230/// Which candidates the pre-screen keeps for a bound, before any full encode.
231///
232/// - wavelet: always (coded with `compact`: the configured model, the hand
233///   model or the tiled hand model, whichever is smallest);
234/// - Tucker: PSNR/RMSE targets on ≥2D data;
235/// - interpolation: always (max-abs directly; RMSE/PSNR through its
236///   rate-controlled bound, about half a wavelet encode);
237/// - Lorenzo: always (one encode per prediction mode at max-abs, then rate
238///   control of one mode at RMSE/PSNR; cheaper than interpolation);
239/// - QTT residual-only: max-abs targets (a few % of a wavelet encode);
240/// - full QTT: only when [`qtt_probe`] (stride 2, ranks capped at
241///   [`QTT_PROBE_CAP`]) keeps every rank below the cap — the field is
242///   genuinely low-TT-rank, so QTT is both cheap and possibly smallest. At
243///   max-abs a probe rank ≤ 1 means the TT part was dropped (the residual
244///   stream alone won), which the residual-only candidate already covers.
245///
246/// Returns `(kept, skipped, probe max rank)`.
247pub fn prescreen(
248    x: &[f32],
249    dims: &[usize],
250    bound: codec::ErrorBound,
251) -> (Vec<Candidate>, Vec<Candidate>, usize) {
252    let maxabs = matches!(bound, codec::ErrorBound::MaxAbs(_));
253    let multi = dims.iter().filter(|&&d| d > 1).count() >= 2;
254    let (_, rank, _) = qtt_probe(x, dims, bound, 2, QTT_PROBE_CAP);
255    let mut keep = vec![Candidate::Wavelet];
256    let mut skip = vec![];
257    if !maxabs && multi {
258        keep.push(Candidate::Tucker);
259    }
260    keep.push(Candidate::Interp);
261    keep.push(Candidate::Lorenzo);
262    if maxabs {
263        keep.push(Candidate::QttSparse);
264    }
265    if rank < QTT_PROBE_CAP && (!maxabs || rank > 1) {
266        keep.push(Candidate::Qtt);
267    } else {
268        skip.push(Candidate::Qtt);
269    }
270    (keep, skip, rank)
271}
272
273/// Encode with one candidate (no tag byte).
274pub fn encode_candidate_f32(
275    c: Candidate,
276    x: &[f32],
277    dims: &[usize],
278    cfg: &wavcodec::WavConfig,
279) -> Vec<u8> {
280    match c {
281        Candidate::Wavelet => {
282            let cfg = wavcodec::WavConfig {
283                compact: true,
284                ..cfg.clone()
285            };
286            wavcodec::encode_f32(x, dims, &cfg).0
287        }
288        Candidate::Tucker => tucker::encode_f32(x, dims, cfg.bound).0,
289        Candidate::Qtt => {
290            let mut q = codec::Config::new(cfg.bound);
291            q.order = DigitOrder::RowMajor;
292            codec::encode_f32(x, dims, &q).0
293        }
294        Candidate::Interp => match cfg.bound {
295            codec::ErrorBound::MaxAbs(b) => interp::encode_f32(x, dims, b),
296            codec::ErrorBound::Rmse(r) => interp::encode_rmse_f32(x, dims, r),
297            codec::ErrorBound::Psnr(p) => interp::encode_psnr_f32(x, dims, p),
298        },
299        Candidate::QttSparse => {
300            let mut q = codec::Config::new(cfg.bound);
301            q.order = DigitOrder::RowMajor;
302            q.residual_only = true;
303            codec::encode_f32(x, dims, &q).0
304        }
305        Candidate::Lorenzo => match cfg.bound {
306            codec::ErrorBound::MaxAbs(b) => lorenzo::encode_f32(x, dims, b),
307            codec::ErrorBound::Rmse(r) => lorenzo::encode_rmse_f32(x, dims, r),
308            codec::ErrorBound::Psnr(p) => lorenzo::encode_psnr_f32(x, dims, p),
309        },
310    }
311}
312
313/// Portfolio encode: encode with every candidate the pre-screen keeps (all
314/// applicable ones when `screen` is false) and keep the smallest stream. The
315/// output is one tag byte (counted in the rate) followed by the stream.
316pub fn encode_portfolio_f32(
317    x: &[f32],
318    dims: &[usize],
319    cfg: &wavcodec::WavConfig,
320    screen: bool,
321) -> (Vec<u8>, AutoReport) {
322    let (mut keep, mut skip, probe_rank) = prescreen(x, dims, cfg.bound);
323    if !screen {
324        keep.append(&mut skip);
325    }
326    let mut best: Option<(Candidate, Vec<u8>)> = None;
327    let mut tried = vec![];
328    for c in keep {
329        let t = std::time::Instant::now();
330        // a candidate that fails on this input (e.g. a numerically singular
331        // Tucker factor) is skipped rather than aborting the encode; wavelet
332        // runs first and always codes
333        let Ok(s) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
334            encode_candidate_f32(c, x, dims, cfg)
335        })) else {
336            skip.push(c);
337            continue;
338        };
339        tried.push((c, s.len(), t.elapsed().as_secs_f64()));
340        if best.as_ref().is_none_or(|(_, b)| s.len() < b.len()) {
341            best = Some((c, s));
342        }
343    }
344    let (pick, body) = best.unwrap();
345    let mut out = Vec::with_capacity(body.len() + 1);
346    out.push(pick.tag());
347    out.extend(body);
348    (
349        out,
350        AutoReport {
351            pick,
352            tried,
353            skipped: skip,
354            probe_rank,
355        },
356    )
357}
358
359/// Centered crop with every axis of length ≥ 16 halved (1D: the middle
360/// eighth), and its shape.
361pub fn center_crop(x: &[f32], dims: &[usize]) -> (Vec<f32>, Vec<usize>) {
362    let f = if dims.iter().filter(|&&d| d > 1).count() == 1 {
363        8
364    } else {
365        2
366    };
367    let nd: Vec<usize> = dims
368        .iter()
369        .map(|&d| if d >= 16 { d / f } else { d })
370        .collect();
371    let lo: Vec<usize> = dims.iter().zip(&nd).map(|(&d, &n)| (d - n) / 2).collect();
372    let n: usize = nd.iter().product();
373    let mut out = Vec::with_capacity(n);
374    let mut idx = vec![0usize; dims.len()];
375    for _ in 0..n {
376        let mut off = 0;
377        for k in 0..dims.len() {
378            off = off * dims[k] + lo[k] + idx[k];
379        }
380        out.push(x[off]);
381        for k in (0..dims.len()).rev() {
382            idx[k] += 1;
383            if idx[k] < nd[k] {
384                break;
385            }
386            idx[k] = 0;
387        }
388    }
389    (out, nd)
390}
391
392/// Fast mode's single candidate: each fast variant that applies (max-abs:
393/// wavelet, interpolation, Lorenzo, QTT residual-only; PSNR/RMSE: wavelet, plus
394/// Tucker on 3D) is trial-encoded on [`center_crop`] (an eighth of the
395/// field) at the same absolute bound, and the smallest stream names the pick.
396pub fn fast_pick(x: &[f32], dims: &[usize], cfg: &wavcodec::WavConfig) -> Candidate {
397    let bound = match cfg.bound {
398        codec::ErrorBound::Psnr(p) => {
399            let (lo, hi) = x
400                .iter()
401                .fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), &v| {
402                    (a.min(v), b.max(v))
403                });
404            codec::ErrorBound::Rmse((hi - lo) as f64 / 10f64.powf(p / 20.0))
405        }
406        b => b,
407    };
408    let three = dims.iter().filter(|&&d| d > 1).count() == 3;
409    let cands: Vec<Candidate> = match bound {
410        codec::ErrorBound::MaxAbs(_) => {
411            vec![
412                Candidate::Wavelet,
413                Candidate::Interp,
414                Candidate::Lorenzo,
415                Candidate::QttSparse,
416            ]
417        }
418        _ if three => vec![Candidate::Wavelet, Candidate::Tucker],
419        _ => return Candidate::Wavelet,
420    };
421    let (y, nd) = center_crop(x, dims);
422    let c = wavcodec::WavConfig {
423        bound,
424        ..cfg.clone()
425    };
426    cands
427        .into_iter()
428        .min_by_key(|&k| try_fast(k, &y, &nd, &c).map_or(usize::MAX, |s| s.len()))
429        .unwrap()
430}
431
432/// [`encode_candidate_fast_f32`], or `None` when the candidate fails on this
433/// input (e.g. a numerically singular Tucker factor).
434fn try_fast(c: Candidate, x: &[f32], dims: &[usize], cfg: &wavcodec::WavConfig) -> Option<Vec<u8>> {
435    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
436        encode_candidate_fast_f32(c, x, dims, cfg)
437    }))
438    .ok()
439}
440
441/// Encode with one candidate's fast variant (no tag byte): step from a
442/// formula, one interpolation preset, the configured (pretrained or hand)
443/// model and never an in-stream overfit. QTT has no fast variant.
444pub fn encode_candidate_fast_f32(
445    c: Candidate,
446    x: &[f32],
447    dims: &[usize],
448    cfg: &wavcodec::WavConfig,
449) -> Vec<u8> {
450    match c {
451        Candidate::Wavelet => {
452            let cfg = wavcodec::WavConfig {
453                fast: true,
454                compact: true,
455                ..cfg.clone()
456            };
457            wavcodec::encode_f32(x, dims, &cfg).0
458        }
459        Candidate::Tucker => tucker::encode_fast_f32(x, dims, cfg.bound).0,
460        Candidate::Interp => match cfg.bound {
461            codec::ErrorBound::MaxAbs(b) => interp::encode_fast_f32(x, dims, b),
462            _ => panic!("interp codes max-abs bounds only"),
463        },
464        Candidate::Lorenzo => match cfg.bound {
465            codec::ErrorBound::MaxAbs(b) => lorenzo::encode_f32(x, dims, b),
466            _ => panic!("fast Lorenzo codes max-abs bounds only"),
467        },
468        Candidate::Qtt | Candidate::QttSparse => encode_candidate_f32(c, x, dims, cfg),
469    }
470}
471
472/// Fast encode (`tc --fast`): the single candidate [`fast_pick`] names, coded
473/// with [`encode_candidate_fast_f32`]. Same tagged stream format as
474/// [`encode_auto_f32`].
475pub fn encode_fast_f32(
476    x: &[f32],
477    dims: &[usize],
478    cfg: &wavcodec::WavConfig,
479) -> (Vec<u8>, Candidate) {
480    let pick = match std::env::var("TC_FAST_PICK").ok().as_deref() {
481        Some("wavelet") => Candidate::Wavelet,
482        Some("tucker") => Candidate::Tucker,
483        Some("interp") => Candidate::Interp,
484        Some("lorenzo") => Candidate::Lorenzo,
485        _ => fast_pick(x, dims, cfg),
486    };
487    // the wavelet always codes, so it backs up a pick that fails at full size
488    let (pick, body) = match try_fast(pick, x, dims, cfg) {
489        Some(b) => (pick, b),
490        None => (
491            Candidate::Wavelet,
492            encode_candidate_fast_f32(Candidate::Wavelet, x, dims, cfg),
493        ),
494    };
495    let mut out = Vec::with_capacity(body.len() + 1);
496    out.push(pick.tag());
497    out.extend(body);
498    (out, pick)
499}
500
501/// Automatic codec choice: [`encode_portfolio_f32`] with the pre-screen on.
502/// Returns the tagged stream and the chosen candidate.
503pub fn encode_auto_f32(
504    x: &[f32],
505    dims: &[usize],
506    cfg: &wavcodec::WavConfig,
507) -> (Vec<u8>, Candidate) {
508    let (b, r) = encode_portfolio_f32(x, dims, cfg, true);
509    (b, r.pick)
510}
511
512#[cfg(test)]
513mod auto_tests {
514    use super::*;
515
516    #[test]
517    fn auto_round_trips_and_keeps_the_smaller() {
518        let d = [16, 24, 20];
519        let x: Vec<f32> = (0..d.iter().product::<usize>())
520            .map(|i| ((i % 20) as f32 * 0.3).sin() + ((i / 480) as f32 * 0.2).cos())
521            .collect();
522        for b in [
523            codec::ErrorBound::Psnr(70.0),
524            codec::ErrorBound::MaxAbs(1e-3),
525        ] {
526            let cfg = wavcodec::WavConfig::new(b);
527            let (bytes, _) = encode_auto_f32(&x, &d, &cfg);
528            let w = wavcodec::encode_f32(&x, &d, &cfg).0.len();
529            assert!(bytes.len() <= w + 1);
530            let Decoded::F32 { data, .. } = decode_any(&bytes).unwrap() else {
531                panic!()
532            };
533            assert_eq!(data.len(), x.len());
534            if let codec::ErrorBound::MaxAbs(e) = b {
535                assert!(x
536                    .iter()
537                    .zip(&data)
538                    .all(|(a, c)| ((a - c) as f64).abs() <= e));
539            }
540        }
541    }
542}