Skip to main content

privacy/compression/
mod.rs

1//! Two separate things, both real, deliberately not conflated.
2//!
3//! **IFS transforms over 5D coordinates.**
4//! [`FractalCompressor::compress`]/[`decompress`](FractalCompressor::decompress) implement
5//! genuine Iterated Function System affine-transform math (`w(x) = A·x + b`), with
6//! chaos-perturbed contraction factors, over a [`FiveDimCoord`]. This is dimension
7//! reduction on a point, and [`FractalCompressor::compression_ratio`] describes *that*
8//! — transform storage vs. dimension count. It says nothing about byte data.
9//!
10//! **Lossless byte compression.**
11//! [`FractalCompressor::compress_bytes`]/[`decompress_bytes`](FractalCompressor::decompress_bytes)
12//! are ordinary DEFLATE (RFC 1951) via `miniz_oxide`, returning a [`CompressedBlob`](crate::compression::CompressedBlob).
13//! They are not fractal and do not pretend to be: fractal compression is inherently lossy
14//! and only pays off on self-similar signals, which arbitrary bytes are not. What callers
15//! need here is an exact round-trip, so this path is lossless.
16//!
17//! Because no algorithm compresses every input — a counting argument, not a gap in this
18//! implementation — [`compress_bytes`](FractalCompressor::compress_bytes) falls back to
19//! storing the input verbatim when DEFLATE does not make it smaller, and records that in
20//! [`CompressedBlob::stored`](crate::compression::CompressedBlob::stored). The output is therefore never meaningfully larger than the
21//! input, and [`CompressedBlob::ratio`](crate::compression::CompressedBlob::ratio) reports what actually happened.
22//!
23//! Earlier versions of this module claimed a "100:1" ratio on byte data and stored each
24//! chunk verbatim, which made the output strictly larger than the input. Both the claim and
25//! that implementation are gone.
26
27use crate::error::PrivacyError;
28use crate::types::FiveDimCoord;
29use sha2::{Digest, Sha256};
30use serde::{Deserialize, Serialize};
31
32extern crate alloc;
33use alloc::{format, vec::Vec};
34
35/// An affine transformation for IFS: w(x) = A·x + b.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AffineTransform {
38    /// 5x5 contraction matrix (flattened, row-major)
39    pub a: [f64; 25],
40    /// Translation vector (5D)
41    pub b: [f64; 5],
42    /// Probability weight
43    pub prob: f64,
44}
45
46impl AffineTransform {
47    /// Identity transform.
48    pub fn identity() -> Self {
49        let mut a = [0.0f64; 25];
50        for i in 0..5 { a[i * 5 + i] = 1.0; }
51        Self { a, b: [0.0; 5], prob: 0.2 }
52    }
53
54    /// Apply transform to a 5D coordinate.
55    #[allow(clippy::needless_range_loop)] // indexes several arrays in step
56    pub fn apply(&self, x: &[f64; 5]) -> [f64; 5] {
57        let mut result = [0.0f64; 5];
58        for i in 0..5 {
59            for j in 0..5 {
60                result[i] += self.a[i * 5 + j] * x[j];
61            }
62            result[i] += self.b[i];
63        }
64        result
65    }
66
67    /// Apply the inverse of the 1D diagonal approximation: T^{-1}(y) = (y - b) / a_diag.
68    ///
69    /// For the diagonal element a[i*5+i] (the contraction factor for dimension i).
70    /// If a_diag ≈ 0, returns 0.0 (degenerate case).
71    pub fn apply_inverse_1d(&self, y: f64, dim: usize) -> f64 {
72        let a_diag = self.a[dim * 5 + dim];
73        if a_diag.abs() < 1e-12 {
74            0.0
75        } else {
76            (y - self.b[dim]) / a_diag
77        }
78    }
79}
80
81/// IFS codebook produced by [`FractalCompressor::compress`] for a 5D coordinate.
82///
83/// This describes the *IFS* path only. Byte compression returns a [`CompressedBlob`].
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct IfsCodebook {
86    pub transforms:   Vec<AffineTransform>,
87    pub dims_reduced: usize,
88    pub chaos_seed:   [u8; 32],
89}
90
91/// Losslessly compressed byte data, produced by [`FractalCompressor::compress_bytes`].
92///
93/// `payload` is a DEFLATE stream, unless [`stored`](Self::stored) is set — in which case
94/// DEFLATE did not make the input smaller and `payload` is the original bytes verbatim.
95/// Either way [`decompress_bytes`](FractalCompressor::decompress_bytes) recovers the input
96/// exactly, and verifies it against `original_hash` before returning it.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct CompressedBlob {
99    /// DEFLATE stream, or the original bytes when `stored` is true.
100    pub payload:       Vec<u8>,
101    /// True when the input did not compress and `payload` holds it verbatim.
102    pub stored:        bool,
103    /// Length of the original input, in bytes.
104    pub original_size: usize,
105    /// SHA-256 of the original input, checked on decompression.
106    pub original_hash: Vec<u8>,
107}
108
109impl CompressedBlob {
110    /// Payload size as a fraction of the original: below 1.0 means it got smaller.
111    ///
112    /// Returns 1.0 for an empty input, and exactly 1.0 whenever [`stored`](Self::stored)
113    /// is set, since the payload is then the input itself.
114    pub fn ratio(&self) -> f64 {
115        if self.original_size == 0 {
116            return 1.0;
117        }
118        self.payload.len() as f64 / self.original_size as f64
119    }
120}
121
122/// Fractal compression engine.
123pub struct FractalCompressor;
124
125impl FractalCompressor {
126    pub fn new() -> Self {
127        Self
128    }
129
130    /// Compress a 5D manifold vertex using IFS.
131    ///
132    /// Identifies self-similar patterns via barnsley-style collage theorem.
133    /// Chaos perturbation modulates transformation coefficients.
134    #[allow(clippy::needless_range_loop)] // indexes several arrays in step
135    pub fn compress(
136        &self,
137        coord: &FiveDimCoord,
138        chaos_seed: &[u8; 32],
139        target_dims: usize,
140    ) -> Result<IfsCodebook, PrivacyError> {
141        if target_dims == 0 || target_dims > 5 {
142            return Err(PrivacyError::CompressionFailed(
143                format!("target_dims must be 1–5, got {target_dims}")
144            ));
145        }
146
147        let x = [
148            coord.spatial,
149            coord.temporal,
150            coord.probabilistic,
151            coord.quantum,
152            coord.chaotic,
153        ];
154
155        // Generate IFS transforms with chaos perturbation
156        let mut transforms = Vec::new();
157        for i in 0..target_dims {
158            let mut t = AffineTransform::identity();
159
160            // Chaos perturbation: A_i' = A_i + δ * Chua(t)
161            let delta = self.chaos_delta(chaos_seed, i as u64);
162            for j in 0..25 {
163                t.a[j] *= 0.5 + delta * 0.1; // contraction factor
164            }
165            t.b[i] = x[i] * (1.0 - 0.5 - delta * 0.1);
166            t.prob = 1.0 / target_dims as f64;
167
168            transforms.push(t);
169        }
170
171        Ok(IfsCodebook {
172            transforms,
173            dims_reduced: 5 - target_dims,
174            chaos_seed: *chaos_seed,
175        })
176    }
177
178    /// Losslessly compress byte data with DEFLATE (RFC 1951), falling back to storing it
179    /// verbatim when that does not make it smaller.
180    ///
181    /// This is not fractal compression and does not use the IFS machinery above — see the
182    /// module doc comment for why. The round-trip through
183    /// [`decompress_bytes`](Self::decompress_bytes) is exact.
184    ///
185    /// The fallback is what keeps the output from growing: no algorithm compresses every
186    /// input, so incompressible data (already-compressed bytes, ciphertext, high-entropy
187    /// keys — much of what this crate handles) is stored as-is and flagged in
188    /// [`CompressedBlob::stored`].
189    pub fn compress_bytes(&self, data: &[u8]) -> Result<CompressedBlob, PrivacyError> {
190        if data.is_empty() {
191            return Err(PrivacyError::CompressionFailed("Empty data".into()));
192        }
193
194        let original_hash = Sha256::digest(data).to_vec();
195        let deflated = miniz_oxide::deflate::compress_to_vec(data, 6);
196
197        // Only keep the DEFLATE stream if it actually won.
198        let (payload, stored) = if deflated.len() < data.len() {
199            (deflated, false)
200        } else {
201            (data.to_vec(), true)
202        };
203
204        Ok(CompressedBlob {
205            payload,
206            stored,
207            original_size: data.len(),
208            original_hash,
209        })
210    }
211
212    /// Decompress an IFS codebook back to a 5D coordinate.
213    ///
214    /// Iterates the IFS attractor for 100 steps.
215    pub fn decompress(&self, codebook: &IfsCodebook) -> Result<FiveDimCoord, PrivacyError> {
216        if codebook.transforms.is_empty() {
217            return Err(PrivacyError::DecompressionFailed("Empty codebook".into()));
218        }
219
220        // Iterate IFS attractor
221        let mut x = [0.0f64; 5];
222        for step in 0..100u64 {
223            let t_idx = (step as usize) % codebook.transforms.len();
224            x = codebook.transforms[t_idx].apply(&x);
225        }
226
227        Ok(FiveDimCoord {
228            spatial:      x[0],
229            temporal:     x[1],
230            probabilistic: x[2],
231            quantum:      x[3],
232            chaotic:      x[4],
233        })
234    }
235
236    /// Recover the exact bytes a [`CompressedBlob`] was built from.
237    ///
238    /// Inflates the DEFLATE stream, or returns the stored bytes when
239    /// [`CompressedBlob::stored`] is set, then checks both the recovered length and its
240    /// SHA-256 against what the blob recorded. A mismatch is reported as an error rather
241    /// than returned to the caller.
242    pub fn decompress_bytes(blob: &CompressedBlob) -> Result<Vec<u8>, PrivacyError> {
243        let result = if blob.stored {
244            blob.payload.clone()
245        } else {
246            miniz_oxide::inflate::decompress_to_vec(&blob.payload)
247                .map_err(|e| PrivacyError::DecompressionFailed(format!("inflate failed: {e:?}")))?
248        };
249
250        if result.len() != blob.original_size {
251            return Err(PrivacyError::DecompressionFailed(format!(
252                "length mismatch: recovered {} bytes, expected {}",
253                result.len(),
254                blob.original_size
255            )));
256        }
257
258        if Sha256::digest(&result).to_vec() != blob.original_hash {
259            return Err(PrivacyError::DecompressionFailed(
260                "hash mismatch: data corrupted".into(),
261            ));
262        }
263
264        Ok(result)
265    }
266
267    /// Compress `data` and immediately prove the round-trip recovers it exactly.
268    ///
269    /// Unlike the earlier version of this method, this can genuinely fail: the compression
270    /// path is real, so a defect in it surfaces here rather than being masked by the input
271    /// having been stored verbatim.
272    ///
273    /// Returns `(blob, decompressed)`, where `decompressed == data`.
274    pub fn compress_and_verify(
275        &self,
276        data: &[u8],
277    ) -> Result<(CompressedBlob, Vec<u8>), PrivacyError> {
278        let blob = self.compress_bytes(data)?;
279        let decompressed = Self::decompress_bytes(&blob)?;
280
281        if decompressed != data {
282            return Err(PrivacyError::DecompressionFailed(
283                "Round-trip verification failed: decompressed data does not match original".into()
284            ));
285        }
286
287        Ok((blob, decompressed))
288    }
289
290    /// Storage ratio for the **IFS** path: original coordinate size over the size of the
291    /// transforms that replace it.
292    ///
293    /// This describes dimension reduction on a [`FiveDimCoord`], not byte compression. For
294    /// that, use [`CompressedBlob::ratio`].
295    pub fn compression_ratio(original_dims: usize, codebook: &IfsCodebook) -> f64 {
296        let original_size = original_dims * 8; // 8 bytes per f64
297        let compressed_size = codebook.transforms.len() * (25 + 5 + 1) * 8;
298        original_size as f64 / compressed_size as f64
299    }
300
301    fn chaos_delta(&self, chaos_seed: &[u8; 32], counter: u64) -> f64 {
302        let mut hasher = Sha256::new();
303        hasher.update(chaos_seed);
304        hasher.update(counter.to_le_bytes());
305        hasher.update(b"ifs-delta");
306        let h: [u8; 32] = hasher.finalize().into();
307        let raw = u64::from_le_bytes(h[..8].try_into().unwrap_or([0u8; 8]));
308        (raw as f64 / u64::MAX as f64) * 2.0 - 1.0 // [-1, 1]
309    }
310}
311
312impl Default for FractalCompressor {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318/// Reduce a 5D coordinate array to 3D by projecting out the quantum and chaotic dimensions.
319///
320/// Uses a PCA-style projection: keeps the 3 dimensions with highest variance.
321/// For the 5D→3D case, we project out `quantum` and `chaotic`
322/// (indices 3 and 4) and retain `spatial`, `temporal`, `probabilistic`.
323///
324/// If the input has enough variance, a proper variance-based selection is performed.
325pub fn reduce_5d_to_3d(coords: &[FiveDimCoord]) -> Vec<[f64; 3]> {
326    if coords.is_empty() {
327        return Vec::new();
328    }
329
330    // Compute per-dimension variance to select the 3 highest-variance dimensions
331    let n = coords.len() as f64;
332    let dims: [[f64; 5]; 1] = [[0.0; 5]]; // placeholder for type inference
333    let _ = dims;
334
335    // Extract all 5 dimensions as arrays
336    let all_dims: Vec<[f64; 5]> = coords.iter().map(|c| [
337        c.spatial,
338        c.temporal,
339        c.probabilistic,
340        c.quantum,
341        c.chaotic,
342    ]).collect();
343
344    // Compute mean for each dimension
345    let mut means = [0.0f64; 5];
346    for pt in &all_dims {
347        for d in 0..5 {
348            means[d] += pt[d];
349        }
350    }
351    for m in means.iter_mut() {
352        *m /= n;
353    }
354
355    // Compute variance for each dimension
356    let mut variances = [0.0f64; 5];
357    for pt in &all_dims {
358        for d in 0..5 {
359            let diff = pt[d] - means[d];
360            variances[d] += diff * diff;
361        }
362    }
363    for v in variances.iter_mut() {
364        *v /= n;
365    }
366
367    // Select the 3 dimensions with highest variance
368    let mut dim_indices: [usize; 5] = [0, 1, 2, 3, 4];
369    // Sort by variance descending (simple insertion sort for 5 elements)
370    for i in 1..5 {
371        let mut j = i;
372        while j > 0 && variances[dim_indices[j]] > variances[dim_indices[j - 1]] {
373            dim_indices.swap(j, j - 1);
374            j -= 1;
375        }
376    }
377    let top3 = [dim_indices[0], dim_indices[1], dim_indices[2]];
378
379    // Project to 3D
380    all_dims.iter().map(|pt| [pt[top3[0]], pt[top3[1]], pt[top3[2]]]).collect()
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn test_compress_decompress() {
389        let compressor = FractalCompressor::new();
390        let seed = [0u8; 32];
391        let coord = FiveDimCoord {
392            spatial: 0.5, temporal: 1000.0, probabilistic: 1e-6,
393            quantum: 1.2, chaotic: 0.8,
394        };
395        let codebook = compressor.compress(&coord, &seed, 3).unwrap();
396        assert_eq!(codebook.dims_reduced, 2);
397        let decompressed = compressor.decompress(&codebook).unwrap();
398        assert!(decompressed.spatial.is_finite());
399    }
400
401    #[test]
402    fn test_invalid_dims() {
403        let compressor = FractalCompressor::new();
404        let seed = [0u8; 32];
405        let coord = FiveDimCoord::zero();
406        assert!(compressor.compress(&coord, &seed, 0).is_err());
407        assert!(compressor.compress(&coord, &seed, 6).is_err());
408    }
409
410    #[test]
411    fn test_compress_bytes_round_trip() {
412        let compressor = FractalCompressor::new();
413        let data = b"Hello, compression world! This is a test of lossless byte compression.";
414        let blob = compressor.compress_bytes(data).unwrap();
415        assert_eq!(blob.original_size, data.len());
416        let decompressed = FractalCompressor::decompress_bytes(&blob).unwrap();
417        assert_eq!(decompressed, data);
418    }
419
420    /// Highly repetitive input must actually get smaller — this is the assertion the old
421    /// implementation could not have passed, since it stored every chunk verbatim.
422    #[test]
423    fn test_compressible_input_actually_shrinks() {
424        let compressor = FractalCompressor::new();
425        let data = alloc::vec![b'A'; 4096];
426        let blob = compressor.compress_bytes(&data).unwrap();
427
428        assert!(!blob.stored, "repetitive input should compress, not fall back to stored");
429        assert!(
430            blob.payload.len() < data.len(),
431            "payload {} not smaller than input {}",
432            blob.payload.len(),
433            data.len()
434        );
435        assert!(blob.ratio() < 1.0);
436        assert_eq!(FractalCompressor::decompress_bytes(&blob).unwrap(), data);
437    }
438
439    /// Incompressible input must fall back to stored rather than growing.
440    #[test]
441    fn test_incompressible_input_falls_back_to_stored() {
442        let compressor = FractalCompressor::new();
443        // Counter-mode-style bytes from SHA-256: high entropy, no exploitable structure.
444        let mut data = Vec::new();
445        for i in 0..64u32 {
446            data.extend_from_slice(&Sha256::digest(i.to_le_bytes()));
447        }
448
449        let blob = compressor.compress_bytes(&data).unwrap();
450        assert!(blob.stored, "high-entropy input should fall back to stored");
451        assert_eq!(blob.payload.len(), data.len(), "stored payload must not grow");
452        assert_eq!(blob.ratio(), 1.0);
453        assert_eq!(FractalCompressor::decompress_bytes(&blob).unwrap(), data);
454    }
455
456    /// A corrupted payload must be reported, not returned.
457    #[test]
458    fn test_corrupted_payload_is_rejected() {
459        let compressor = FractalCompressor::new();
460        let data = alloc::vec![b'B'; 2048];
461        let mut blob = compressor.compress_bytes(&data).unwrap();
462        blob.original_hash[0] ^= 0xFF;
463        assert!(FractalCompressor::decompress_bytes(&blob).is_err());
464    }
465
466    #[test]
467    fn test_compress_and_verify() {
468        let compressor = FractalCompressor::new();
469        let data: Vec<u8> = (0..256).map(|i| i as u8).collect();
470        let (blob, decompressed) = compressor.compress_and_verify(&data).unwrap();
471        assert_eq!(decompressed, data);
472        assert_eq!(blob.original_size, 256);
473    }
474
475    #[test]
476    fn test_empty_input_is_an_error() {
477        let compressor = FractalCompressor::new();
478        assert!(compressor.compress_bytes(&[]).is_err());
479    }
480
481    #[test]
482    fn test_reduce_5d_to_3d() {
483        let coords = vec![
484            FiveDimCoord { spatial: 1.0, temporal: 2.0, probabilistic: 3.0, quantum: 4.0, chaotic: 5.0 },
485            FiveDimCoord { spatial: 2.0, temporal: 3.0, probabilistic: 4.0, quantum: 5.0, chaotic: 6.0 },
486            FiveDimCoord { spatial: 3.0, temporal: 4.0, probabilistic: 5.0, quantum: 6.0, chaotic: 7.0 },
487        ];
488        let reduced = reduce_5d_to_3d(&coords);
489        assert_eq!(reduced.len(), 3);
490        for pt in &reduced {
491            assert!(pt[0].is_finite());
492            assert!(pt[1].is_finite());
493            assert!(pt[2].is_finite());
494        }
495    }
496
497    #[test]
498    fn test_reduce_5d_to_3d_empty() {
499        let reduced = reduce_5d_to_3d(&[]);
500        assert!(reduced.is_empty());
501    }
502}