Skip to main content

lzcomplexity_core/
shuffle.rs

1//! Shuffle-based complexity measures.
2
3use rayon::prelude::*;
4
5use crate::lz76::{lz76_factorization, LzArgs, LzShuffle};
6use crate::sequence::{shuffle_copy_seeded, Sequence};
7use crate::NO_ALPHABET;
8
9/// Estimate the "naive M" satisfying `N = M * 2^M`, mirroring `max_block_size` in C++.
10pub fn max_block_size(size: usize) -> usize {
11    if size < 10 {
12        return 1;
13    }
14    let mut m: usize = 10;
15    let mut m_old: usize = 1;
16    let mut count: u32 = 0;
17    while count < 100 {
18        let est = ((size as f64) / (m_old as f64)).ln() / 2f64.ln();
19        m = est.round() as usize;
20        count += 1;
21        if m == m_old {
22            break;
23        }
24        m_old = m;
25    }
26    m
27}
28
29/// Compute the random-shuffle factorization vector and `mm`. Mirrors
30/// `ShuffleFactorization` from the C++.
31pub fn shuffle_factorization(seq: &Sequence, args: &LzArgs) -> (Vec<i32>, usize) {
32    let mut mm: i64 = args.block_size as i64;
33    if mm <= 0 {
34        mm = max_block_size(seq.len()) as i64;
35        if seq.len() > 50 {
36            mm += 10;
37        }
38    }
39    let mm = mm as usize;
40
41    // Index 0 unused, indices 1..=mm hold the per-block-size complexity.
42    let mut res: Vec<i32> = vec![0; mm + 3];
43
44    // Hash the sequence content ONCE; each per-block seed only differs by the
45    // `idx` term (bit-identical seeds → identical shuffles → identical EMC).
46    let base = fnv1a(seq.as_bytes());
47    let computed: Vec<(usize, i32)> = (1..=mm)
48        .into_par_iter()
49        .map(|idx| {
50            let seed = seed_from_base(base, idx);
51            let rand_seq = shuffle_copy_seeded(seq, idx as u32, (seq.len() / 2) as u32, seed);
52            let c = lz76_factorization(&rand_seq, args);
53            (idx, c as i32)
54        })
55        .collect();
56    for (idx, c) in computed {
57        res[idx] = c;
58    }
59    (res, mm)
60}
61
62/// Effective measure complexity via the block-entropy estimator.
63///
64/// For each block size `l`, `h_rand[l]` is `C_LZ(u^{RS(l)})`, the LZ complexity
65/// of `u` block-shuffled at scale `l`. The surrogate is (idealised) an i.i.d.
66/// stream of `l`-blocks whose entropy rate is `H(l)/l`, so multiplying back
67/// recovers a block-entropy estimate `H_l = l · C_LZ(u^{RS(l)}) · g`, with
68/// `g = log_k(N)/N` and `ĥ = C_LZ(u) · g` the entropy density of `u`. That
69/// gives one *finite-scale excess entropy* per block size — the subextensive
70/// part of the block entropy at scale `l`:
71///
72/// ```text
73/// Ê(l) = H_l − l·ĥ = l · g · ( C_LZ(u^{RS(l)}) − C_LZ(u) )
74/// ```
75///
76/// Theory pins down the shape of that ladder: `E(l) = Σ_{j≤l} [h(j) − h]` with
77/// every increment `h(j) − h ≥ 0`, so `E(l)` is non-negative and non-decreasing
78/// in `l` and rises to `E` (Crutchfield & Feldman, *Chaos* 13:25 (2003),
79/// Lemma 1 and Eq. 52). The raw ladder violates both properties, because each
80/// `Ê(l)` is an *independent* surrogate draw rather than a slice of one
81/// consistent block-entropy curve. We therefore project it onto that cone —
82/// non-negative isotonic regression, see [`non_negative_isotonic`] — and read
83/// the answer off the projection:
84///
85/// ```text
86/// summands[l−1] = Ê_fit(l) − Ê_fit(l−1)   ≥ 0     (the estimate of h(l) − h)
87/// Ê             = Ê_fit(mm) = Σ summands  ≥ 0
88/// ```
89///
90/// Every block size informs the fit, and the returned scalar can no longer go
91/// negative — a mutual information never does.
92///
93/// # Why not sum the raw differences
94///
95/// Versions ≤ 1.0.1 summed `(H_l − H_{l−1}) − ĥ` directly. That is the same
96/// textbook identity written the other way round, `Σ_l [h(l) − h] = H(L) − L·h`,
97/// so the `H_l` telescoped and the total collapsed *exactly* to
98/// `mm · g · (C_LZ(u^{RS(mm)}) − C_LZ(u))`: only the largest block size reached
99/// the scalar, the other `mm − 1` factorizations cancelled algebraically, and a
100/// single unlucky surrogate draw could push the result below zero. Projecting
101/// first is what couples the scales together.
102///
103/// `multi_information` stays the *raw* `l = 1` contrast `H_1 − ĥ`, unchanged
104/// from earlier versions: it is a separate quantity (the multi-information rate
105/// of the source), not a term of the sum, and it is the best-calibrated number
106/// this estimator produces.
107pub fn shuffle_entropy_calculation(
108    seq: &Sequence,
109    args: &LzArgs,
110    complexity: i32,
111    h_rand: &[i32],
112    mm: usize,
113) -> LzShuffle {
114    let mut result = LzShuffle {
115        max_block_size: mm as i32,
116        ..Default::default()
117    };
118    if args.get_shuffle_terms {
119        result.summands = vec![0.0; mm];
120    }
121    let log_base = if args.log_base == NO_ALPHABET {
122        seq.alphabet_size().max(2)
123    } else {
124        args.log_base.max(2)
125    } as f64;
126
127    let n = seq.len() as f64;
128    if n <= 1.0 || mm == 0 {
129        return result;
130    }
131    // g = log_k(N) / N, so ĥ = C_LZ(u)·g and H_l = l·C_LZ(u^{RS(l)})·g.
132    let g = (n.ln() / log_base.ln()) / n;
133    let h_hat = complexity as f64 * g; // entropy density of the original sequence
134
135    // The raw ladder Ê(l) = H_l − l·ĥ, one finite-scale excess entropy per
136    // block size. `h_rand` is 1-indexed, so `len` is how many scales it holds.
137    let len = mm.min(h_rand.len().saturating_sub(1));
138    if len == 0 {
139        return result;
140    }
141    let raw: Vec<f64> = (1..=len)
142        .map(|l| l as f64 * (h_rand[l] as f64 * g - h_hat))
143        .collect();
144
145    // The l = 1 contrast, before any projection.
146    result.multi_information = raw[0];
147
148    // Project onto {non-negative, non-decreasing} and read off the increments.
149    let fitted = non_negative_isotonic(&raw);
150    let mut prev = 0.0f64; // Ê_fit(0) = 0
151    for l in 1..=len {
152        let term = fitted[l - 1] - prev;
153        if args.get_shuffle_terms {
154            result.summands[l - 1] = term;
155        }
156        prev = fitted[l - 1];
157    }
158    result.emc_value = prev; // = Ê_fit(len)
159    result
160}
161
162/// L2 projection of `y` onto the cone of non-negative, non-decreasing sequences.
163///
164/// The isotonic part is the pool-adjacent-violators algorithm (Ayer et al.,
165/// *Ann. Math. Statist.* 26:641 (1955)): walk left to right, and whenever the
166/// newest block averages below its predecessor, merge the two and re-check.
167/// Blocks are stored as `(sum, count)` pairs, so this is O(n) overall. The
168/// non-negative projection is then the pointwise positive part, which preserves
169/// monotonicity.
170fn non_negative_isotonic(y: &[f64]) -> Vec<f64> {
171    let mut sums: Vec<f64> = Vec::with_capacity(y.len());
172    let mut counts: Vec<usize> = Vec::with_capacity(y.len());
173    for &v in y {
174        sums.push(v);
175        counts.push(1);
176        while sums.len() > 1 {
177            let k = sums.len();
178            let last = sums[k - 1] / counts[k - 1] as f64;
179            let prev = sums[k - 2] / counts[k - 2] as f64;
180            if prev <= last {
181                break;
182            }
183            sums[k - 2] += sums[k - 1];
184            counts[k - 2] += counts[k - 1];
185            sums.pop();
186            counts.pop();
187        }
188    }
189    let mut out = Vec::with_capacity(y.len());
190    for (s, c) in sums.iter().zip(counts.iter()) {
191        let level = (s / *c as f64).max(0.0);
192        out.extend(std::iter::repeat(level).take(*c));
193    }
194    out
195}
196
197/// `lz76RandomShuffleComplexity` — full pipeline.
198pub fn lz76_random_shuffle_complexity(seq: &Sequence, args: &LzArgs) -> LzShuffle {
199    lz76_random_shuffle_complexity_with(seq, args, lz76_factorization(seq, args) as i32)
200}
201
202/// Same, but with an already-computed original-sequence complexity (so callers
203/// that already have it — e.g. the `lz76` driver — skip one factorization).
204pub fn lz76_random_shuffle_complexity_with(
205    seq: &Sequence,
206    args: &LzArgs,
207    complexity: i32,
208) -> LzShuffle {
209    let (h_rand, mm) = shuffle_factorization(seq, args);
210    shuffle_entropy_calculation(seq, args, complexity, &h_rand, mm)
211}
212
213/// `lz76PairedShuffleComplexity` — shuffle on the merged (Z) sequence.
214pub fn lz76_paired_shuffle_complexity(seq: &Sequence, args: &LzArgs) -> LzShuffle {
215    let mid = seq.len() / 2;
216    let (past, future) = seq.split_at(mid);
217    let merged = merge_sequences(&past, &future);
218    let complexity = lz76_factorization(&merged, args) as i32;
219    let (h_rand, mm) = shuffle_factorization(&merged, args);
220    shuffle_entropy_calculation(&merged, args, complexity, &h_rand, mm)
221}
222
223/// Mirror of `internal::MergeSequences` — element-wise pairing into a new alphabet.
224pub fn merge_sequences(s1: &Sequence, s2: &Sequence) -> Sequence {
225    let max_iter = s1.len().min(s2.len());
226    let mut buf = Vec::with_capacity(max_iter);
227    let mut lookup = vec![0u8; 65536];
228    let mut next_symbol: u8 = b'0';
229    let a = s1.as_bytes();
230    let b = s2.as_bytes();
231    for i in 0..max_iter {
232        let key = ((a[i] as u16) << 8) | (b[i] as u16);
233        let entry = &mut lookup[key as usize];
234        if *entry == 0 {
235            *entry = next_symbol;
236            next_symbol = next_symbol.wrapping_add(1);
237        }
238        buf.push(*entry);
239    }
240    Sequence::from_bytes_with_alphabet(buf, s1.alphabet_size().saturating_mul(s2.alphabet_size()))
241}
242
243/// FNV-1a hash of the sequence bytes (the content-dependent part of the seed).
244fn fnv1a(bytes: &[u8]) -> u64 {
245    let mut h: u64 = 14695981039346656037; // FNV offset basis (64-bit)
246    for &c in bytes {
247        h ^= c as u64;
248        h = h.wrapping_mul(1099511628211);
249    }
250    h
251}
252
253/// Derive the per-block-size seed from the (once-computed) content hash.
254fn seed_from_base(base: u64, idx: usize) -> u64 {
255    base ^ ((idx as u64).wrapping_mul(0x9E3779B97F4A7C15))
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    /// A binary sequence (alphabet 2 -> log_base auto = 2) of length 100, plus
263    /// `g = log_2(100)/100`. The block-entropy arithmetic only reads the length
264    /// and the alphabet, so the content is irrelevant to these tests.
265    fn fixture() -> (Sequence, LzArgs, f64) {
266        let mut data = vec![b'0'; 50];
267        data.extend(std::iter::repeat(b'1').take(50));
268        let seq = Sequence::from_bytes(data);
269        let mut args = LzArgs::new();
270        args.get_shuffle_terms = true;
271        let n = 100.0f64;
272        (seq, args, (n.ln() / 2f64.ln()) / n)
273    }
274
275    #[test]
276    fn emc_projects_the_excess_ladder() {
277        let (seq, args, g) = fixture();
278        // c_1 = 20, c_2 = 15, c_3 = 12 against C_LZ(u) = 10, so the raw ladder
279        // Ê(l) = l·g·(c_l − 10) is [10g, 10g, 6g] — non-monotone at l = 3.
280        let r = shuffle_entropy_calculation(&seq, &args, 10, &[0, 20, 15, 12], 3);
281
282        // Pooling all three violators gives one block at (10 + 10 + 6)/3 g.
283        let level = (26.0 / 3.0) * g;
284        assert!((r.emc_value - level).abs() < 1e-12, "{}", r.emc_value);
285        assert!((r.summands[0] - level).abs() < 1e-12);
286        assert_eq!(r.summands[1], 0.0);
287        assert_eq!(r.summands[2], 0.0);
288        // multi_information is the *raw* l = 1 contrast, not the fitted one.
289        assert!((r.multi_information - 10.0 * g).abs() < 1e-12);
290    }
291
292    #[test]
293    fn emc_keeps_the_closed_form_when_the_ladder_is_monotone() {
294        let (seq, args, g) = fixture();
295        // c_l = 11 for every l gives Ê(l) = l·g, already strictly increasing,
296        // so the projection is the identity and the total is the old
297        // closed form mm·g·(c_mm − C_LZ(u)).
298        let r = shuffle_entropy_calculation(&seq, &args, 10, &[0, 11, 11, 11], 3);
299
300        assert!((r.emc_value - 3.0 * g).abs() < 1e-12, "{}", r.emc_value);
301        for s in &r.summands {
302            assert!((s - g).abs() < 1e-12, "{s}");
303        }
304    }
305
306    #[test]
307    fn emc_is_never_negative() {
308        let (seq, args, g) = fixture();
309        // Every surrogate less complex than the original: the raw ladder is
310        // [−10g, −20g, −30g], entirely below zero.
311        let r = shuffle_entropy_calculation(&seq, &args, 20, &[0, 10, 10, 10], 3);
312
313        assert_eq!(r.emc_value, 0.0);
314        assert!(r.summands.iter().all(|&s| s == 0.0), "{:?}", r.summands);
315        // The diagnostic still shows the negative contrast.
316        assert!((r.multi_information + 10.0 * g).abs() < 1e-12);
317    }
318
319    #[test]
320    fn isotonic_pools_adjacent_violators() {
321        // 3.0 then 2.0 violates monotonicity; they pool to 2.5.
322        assert_eq!(
323            non_negative_isotonic(&[1.0, 3.0, 2.0, 4.0]),
324            vec![1.0, 2.5, 2.5, 4.0]
325        );
326        // Already isotonic: unchanged apart from the non-negativity clamp.
327        assert_eq!(non_negative_isotonic(&[-1.0, 0.5]), vec![0.0, 0.5]);
328        // A strictly decreasing input pools into one block.
329        assert_eq!(non_negative_isotonic(&[6.0, 3.0, 0.0]), vec![3.0, 3.0, 3.0]);
330        assert_eq!(non_negative_isotonic(&[]), Vec::<f64>::new());
331    }
332
333    #[test]
334    fn summands_are_non_negative_and_sum_to_the_value() {
335        // A real end-to-end run: period-8 motif, n = 2000.
336        let data: Vec<u8> = b"00010111".iter().copied().cycle().take(2000).collect();
337        let seq = Sequence::from_bytes(data);
338        let mut args = LzArgs::new();
339        args.get_shuffle_terms = true;
340        let r = lz76_random_shuffle_complexity(&seq, &args);
341
342        assert_eq!(r.summands.len(), r.max_block_size as usize);
343        assert!(r.emc_value > 0.0, "expected structure, got {}", r.emc_value);
344        assert!(r.summands.iter().all(|&s| s >= 0.0), "{:?}", r.summands);
345        let total: f64 = r.summands.iter().sum();
346        assert!(
347            (total - r.emc_value).abs() < 1e-12,
348            "{total} vs {}",
349            r.emc_value
350        );
351        // The ladder is non-decreasing, so partial sums climb to the total.
352        let mut acc = 0.0;
353        for &s in &r.summands {
354            acc += s;
355            assert!(acc <= r.emc_value + 1e-12);
356        }
357    }
358}