lzcomplexity 1.0.2__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/Cargo.lock +3 -3
  2. lzcomplexity-2.0.0/Cargo.toml +50 -0
  3. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/PKG-INFO +1 -1
  4. lzcomplexity-2.0.0/crates/lzcomplexity-core/src/shuffle.rs +358 -0
  5. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-py/src/lib.rs +28 -3
  6. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/pyproject.toml +1 -1
  7. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/python/lzcomplexity/__init__.pyi +6 -2
  8. lzcomplexity-1.0.2/Cargo.toml +0 -30
  9. lzcomplexity-1.0.2/crates/lzcomplexity-core/src/shuffle.rs +0 -216
  10. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/LICENSE +0 -0
  11. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/README.md +0 -0
  12. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-core/Cargo.toml +0 -0
  13. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-core/README.md +0 -0
  14. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-core/src/lib.rs +0 -0
  15. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-core/src/lpf.rs +0 -0
  16. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-core/src/lz76.rs +0 -0
  17. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-core/src/metrics.rs +0 -0
  18. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-core/src/sequence.rs +0 -0
  19. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-core/src/suffix_array.rs +0 -0
  20. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/crates/lzcomplexity-py/Cargo.toml +0 -0
  21. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/python/lzcomplexity/__init__.py +0 -0
  22. {lzcomplexity-1.0.2 → lzcomplexity-2.0.0}/python/lzcomplexity/py.typed +0 -0
@@ -238,7 +238,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
238
238
 
239
239
  [[package]]
240
240
  name = "lzcomplexity-cli"
241
- version = "1.0.2"
241
+ version = "2.0.0"
242
242
  dependencies = [
243
243
  "clap",
244
244
  "flate2",
@@ -250,7 +250,7 @@ dependencies = [
250
250
 
251
251
  [[package]]
252
252
  name = "lzcomplexity-core"
253
- version = "1.0.2"
253
+ version = "2.0.0"
254
254
  dependencies = [
255
255
  "cdivsufsort",
256
256
  "rand",
@@ -260,7 +260,7 @@ dependencies = [
260
260
 
261
261
  [[package]]
262
262
  name = "lzcomplexity-py"
263
- version = "1.0.2"
263
+ version = "2.0.0"
264
264
  dependencies = [
265
265
  "lzcomplexity-core",
266
266
  "pyo3",
@@ -0,0 +1,50 @@
1
+ [workspace]
2
+ resolver = "2"
3
+ members = ["crates/lzcomplexity-core", "crates/lzcomplexity-py"]
4
+
5
+ [workspace.package]
6
+ version = "2.0.0" # x-release-please-version
7
+ edition = "2021"
8
+ rust-version = "1.75"
9
+ license = "MIT"
10
+ authors = [
11
+ "Efrén Aragón Pérez <efrenaragon96@gmail.com>",
12
+ "Daniel Estévez Moya <kemossabee@gmail.com>",
13
+ "Ernesto Estévez Rams <estevez@fisica.uh.cu>",
14
+ ]
15
+ repository = "https://github.com/pleros-ai/lzcomplexity"
16
+
17
+ [workspace.dependencies]
18
+ rayon = "1.10"
19
+ # PINNED EXACTLY, AND NOT AS A SUPPLY-CHAIN MEASURE.
20
+ #
21
+ # The block shuffle behind `emc` draws from `ChaCha8Rng::seed_from_u64` and
22
+ # `Rng::gen_range`, so the exact byte stream these two crates produce is part of
23
+ # this library's observable output: it decides which surrogates are drawn, and
24
+ # therefore the value `emc` returns. Two things depend on it.
25
+ #
26
+ # 1. `emc` is documented as reproducible for a given input on any machine at any
27
+ # thread count. See docs/project/determinism.md.
28
+ # 2. The C++ backend on `main` reproduces these crates' *internal* algorithms by
29
+ # hand -- rand_core's PCG32 seed expansion, ChaCha8 with a 64-word buffer, and
30
+ # gen_range's `(range << leading_zeros) - 1` rejection zone -- so that both
31
+ # backends return identical values. See modules/core/inc/lz/rng.h there, and
32
+ # docs/project/cpp-parity.md.
33
+ #
34
+ # A minor-version bump of either crate may change that stream without any API
35
+ # change and without any warning, which would silently break both properties. Do
36
+ # not relax these to caret requirements. To upgrade deliberately: re-run the C++
37
+ # `rng_parity` test (ctest) against the new stream, regenerate its vectors if it
38
+ # fails, and treat the resulting `emc` change as a breaking change.
39
+ rand = "=0.8.6"
40
+ rand_chacha = "=0.3.1"
41
+ cdivsufsort = "2.0"
42
+ clap = { version = "4.5", features = ["derive"] }
43
+ serde = { version = "1", features = ["derive"] }
44
+ serde_json = "1"
45
+ flate2 = "1.0"
46
+
47
+ [profile.release]
48
+ lto = "thin"
49
+ codegen-units = 1
50
+ opt-level = 3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lzcomplexity
3
- Version: 1.0.2
3
+ Version: 2.0.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Programming Language :: Python :: 3
@@ -0,0 +1,358 @@
1
+ //! Shuffle-based complexity measures.
2
+
3
+ use rayon::prelude::*;
4
+
5
+ use crate::lz76::{lz76_factorization, LzArgs, LzShuffle};
6
+ use crate::sequence::{shuffle_copy_seeded, Sequence};
7
+ use crate::NO_ALPHABET;
8
+
9
+ /// Estimate the "naive M" satisfying `N = M * 2^M`, mirroring `max_block_size` in C++.
10
+ pub 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++.
31
+ pub 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.
107
+ pub 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.
170
+ fn 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.
198
+ pub 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).
204
+ pub 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.
214
+ pub 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.
224
+ pub 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).
244
+ fn 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.
254
+ fn seed_from_base(base: u64, idx: usize) -> u64 {
255
+ base ^ ((idx as u64).wrapping_mul(0x9E3779B97F4A7C15))
256
+ }
257
+
258
+ #[cfg(test)]
259
+ mod 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
+ }
@@ -177,9 +177,28 @@ fn h(
177
177
  /// content. Higher values mean more structure than random noise of the same
178
178
  /// length and alphabet.
179
179
  ///
180
+ /// For each block size ``l = 1 ... max_block_size`` the sequence is shuffled in
181
+ /// blocks of length ``l`` and re-factorized, which gives one estimate of the
182
+ /// *excess entropy accumulated up to scale l*. Excess entropy is a mutual
183
+ /// information between the past and the future, so that ladder must be
184
+ /// non-negative and non-decreasing in ``l``; the raw estimates are not, because
185
+ /// each rests on its own surrogate draw. They are projected onto that shape
186
+ /// (non-negative isotonic regression) before the terms are read off, so
187
+ /// **every returned value is non-negative** and every block size informs the
188
+ /// total.
189
+ ///
180
190
  /// The shuffle is seeded deterministically from the sequence content, so
181
191
  /// repeated calls on the same input produce identical results.
182
192
  ///
193
+ /// .. warning::
194
+ ///
195
+ /// Treat the value as an **ordinal index at fixed sequence length**, not as
196
+ /// a bit count. It is biased upward (roughly 2-3x the analytic excess
197
+ /// entropy for order-1 Markov sources) and it scales with
198
+ /// ``max_block_size``, which itself grows with length -- so never compare
199
+ /// values across sequences of different length. Report it alongside
200
+ /// :func:`h`.
201
+ ///
183
202
  /// Parameters
184
203
  /// ----------
185
204
  /// seq : str | bytes | list[int] | list[str] | Iterable[int]
@@ -198,9 +217,15 @@ fn h(
198
217
  /// Returns
199
218
  /// -------
200
219
  /// tuple[float, list[float]]
201
- /// ``(emc_value, summands)`` where ``emc_value`` is the effective measure
202
- /// complexity and ``summands`` is the list of per-block-size terms whose
203
- /// sum is ``emc_value``. ``summands[0]`` is the multi-information term.
220
+ /// ``(emc_value, summands)``. ``emc_value`` is the effective measure
221
+ /// complexity, always ``>= 0.0``. ``summands`` has one entry per block
222
+ /// size, ``summands[l-1]`` estimating the scale-``l`` conditional-entropy
223
+ /// excess ``h(l) - h``; every entry is ``>= 0.0`` and they sum to
224
+ /// ``emc_value``. Their running sum is the excess entropy captured up to
225
+ /// each scale, so a profile that keeps climbing past the first few entries
226
+ /// indicates long-range or hierarchical correlation. Zero entries mean
227
+ /// neighbouring scales were pooled by the projection, not that the scale
228
+ /// was skipped.
204
229
  ///
205
230
  /// Examples
206
231
  /// --------
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "lzcomplexity"
7
- version = "1.0.2" # x-release-please-version
7
+ version = "2.0.0" # x-release-please-version
8
8
  description = "LempelZiv-76 complexity engine. Suited for complexity analysis of time series."
9
9
  authors = [
10
10
  { name = "Efrén Aragón Pérez", email = "efrenaragon96@gmail.com" },
@@ -54,8 +54,12 @@ def emc(
54
54
  ) -> Tuple[float, List[float]]:
55
55
  """Effective measure complexity via random block shuffling.
56
56
 
57
- Returns ``(emc_value, summands)`` the EMC value and the list of
58
- per-block-size terms whose sum is the EMC value.
57
+ Returns ``(emc_value, summands)``. Both the value and every summand are
58
+ non-negative, and the summands sum to the value; ``summands[l-1]``
59
+ estimates the scale-``l`` conditional-entropy excess ``h(l) - h``.
60
+
61
+ Read the value as an ordinal index at fixed sequence length, not as a bit
62
+ count, and never compare it across lengths.
59
63
  """
60
64
  ...
61
65
 
@@ -1,30 +0,0 @@
1
- [workspace]
2
- resolver = "2"
3
- members = ["crates/lzcomplexity-core", "crates/lzcomplexity-py"]
4
-
5
- [workspace.package]
6
- version = "1.0.2" # x-release-please-version
7
- edition = "2021"
8
- rust-version = "1.75"
9
- license = "MIT"
10
- authors = [
11
- "Efrén Aragón Pérez <efrenaragon96@gmail.com>",
12
- "Daniel Estévez Moya <kemossabee@gmail.com>",
13
- "Ernesto Estévez Rams <estevez@fisica.uh.cu>",
14
- ]
15
- repository = "https://github.com/pleros-ai/lzcomplexity"
16
-
17
- [workspace.dependencies]
18
- rayon = "1.10"
19
- rand = "0.8"
20
- rand_chacha = "0.3"
21
- cdivsufsort = "2.0"
22
- clap = { version = "4.5", features = ["derive"] }
23
- serde = { version = "1", features = ["derive"] }
24
- serde_json = "1"
25
- flate2 = "1.0"
26
-
27
- [profile.release]
28
- lto = "thin"
29
- codegen-units = 1
30
- opt-level = 3
@@ -1,216 +0,0 @@
1
- //! Shuffle-based complexity measures.
2
-
3
- use rayon::prelude::*;
4
-
5
- use crate::lz76::{lz76_factorization, LzArgs, LzShuffle};
6
- use crate::sequence::{shuffle_copy_seeded, Sequence};
7
- use crate::NO_ALPHABET;
8
-
9
- /// Estimate the "naive M" satisfying `N = M * 2^M`, mirroring `max_block_size` in C++.
10
- pub 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++.
31
- pub 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`. From it we form a block-entropy estimate
66
- /// `H_l = l * C_LZ(u^{RS(l)}) * log_k(N) / N` (with `H_0 = 0`), take the entropy
67
- /// gain `ΔH_l = H_l - H_{l-1}`, and sum each gain's excess over the original
68
- /// sequence's entropy density `ĥ = C_LZ(u) * log_k(N) / N`:
69
- ///
70
- /// ```text
71
- /// Ê = Σ_{l=1}^{mm} (ΔH_l - ĥ)
72
- /// ```
73
- pub fn shuffle_entropy_calculation(
74
- seq: &Sequence,
75
- args: &LzArgs,
76
- complexity: i32,
77
- h_rand: &[i32],
78
- mm: usize,
79
- ) -> LzShuffle {
80
- let mut result = LzShuffle {
81
- max_block_size: mm as i32,
82
- ..Default::default()
83
- };
84
- if args.get_shuffle_terms {
85
- result.summands = vec![0.0; mm];
86
- }
87
- let log_base = if args.log_base == NO_ALPHABET {
88
- seq.alphabet_size().max(2)
89
- } else {
90
- args.log_base.max(2)
91
- } as f64;
92
-
93
- let n = seq.len() as f64;
94
- if n <= 1.0 || mm == 0 {
95
- return result;
96
- }
97
- // g = log_k(N) / N, so ĥ = C_LZ(u)·g and H_l = l·C_LZ(u^{RS(l)})·g.
98
- let g = (n.ln() / log_base.ln()) / n;
99
- let h_hat = complexity as f64 * g; // entropy density of the original sequence
100
-
101
- let mut emc = 0.0f64;
102
- let mut h_prev = 0.0f64; // H_0 = 0
103
- for l in 1..=mm {
104
- if l >= h_rand.len() {
105
- break;
106
- }
107
- let h_l = l as f64 * h_rand[l] as f64 * g;
108
- let term = (h_l - h_prev) - h_hat; // ΔH_l - ĥ
109
- emc += term;
110
- if args.get_shuffle_terms {
111
- result.summands[l - 1] = term;
112
- }
113
- if l == 1 {
114
- result.multi_information = term;
115
- }
116
- h_prev = h_l;
117
- }
118
- result.emc_value = emc;
119
- result
120
- }
121
-
122
- /// `lz76RandomShuffleComplexity` — full pipeline.
123
- pub fn lz76_random_shuffle_complexity(seq: &Sequence, args: &LzArgs) -> LzShuffle {
124
- lz76_random_shuffle_complexity_with(seq, args, lz76_factorization(seq, args) as i32)
125
- }
126
-
127
- /// Same, but with an already-computed original-sequence complexity (so callers
128
- /// that already have it — e.g. the `lz76` driver — skip one factorization).
129
- pub fn lz76_random_shuffle_complexity_with(
130
- seq: &Sequence,
131
- args: &LzArgs,
132
- complexity: i32,
133
- ) -> LzShuffle {
134
- let (h_rand, mm) = shuffle_factorization(seq, args);
135
- shuffle_entropy_calculation(seq, args, complexity, &h_rand, mm)
136
- }
137
-
138
- /// `lz76PairedShuffleComplexity` — shuffle on the merged (Z) sequence.
139
- pub fn lz76_paired_shuffle_complexity(seq: &Sequence, args: &LzArgs) -> LzShuffle {
140
- let mid = seq.len() / 2;
141
- let (past, future) = seq.split_at(mid);
142
- let merged = merge_sequences(&past, &future);
143
- let complexity = lz76_factorization(&merged, args) as i32;
144
- let (h_rand, mm) = shuffle_factorization(&merged, args);
145
- shuffle_entropy_calculation(&merged, args, complexity, &h_rand, mm)
146
- }
147
-
148
- /// Mirror of `internal::MergeSequences` — element-wise pairing into a new alphabet.
149
- pub fn merge_sequences(s1: &Sequence, s2: &Sequence) -> Sequence {
150
- let max_iter = s1.len().min(s2.len());
151
- let mut buf = Vec::with_capacity(max_iter);
152
- let mut lookup = vec![0u8; 65536];
153
- let mut next_symbol: u8 = b'0';
154
- let a = s1.as_bytes();
155
- let b = s2.as_bytes();
156
- for i in 0..max_iter {
157
- let key = ((a[i] as u16) << 8) | (b[i] as u16);
158
- let entry = &mut lookup[key as usize];
159
- if *entry == 0 {
160
- *entry = next_symbol;
161
- next_symbol = next_symbol.wrapping_add(1);
162
- }
163
- buf.push(*entry);
164
- }
165
- Sequence::from_bytes_with_alphabet(buf, s1.alphabet_size().saturating_mul(s2.alphabet_size()))
166
- }
167
-
168
- /// FNV-1a hash of the sequence bytes (the content-dependent part of the seed).
169
- fn fnv1a(bytes: &[u8]) -> u64 {
170
- let mut h: u64 = 14695981039346656037; // FNV offset basis (64-bit)
171
- for &c in bytes {
172
- h ^= c as u64;
173
- h = h.wrapping_mul(1099511628211);
174
- }
175
- h
176
- }
177
-
178
- /// Derive the per-block-size seed from the (once-computed) content hash.
179
- fn seed_from_base(base: u64, idx: usize) -> u64 {
180
- base ^ ((idx as u64).wrapping_mul(0x9E3779B97F4A7C15))
181
- }
182
-
183
- #[cfg(test)]
184
- mod tests {
185
- use super::*;
186
-
187
- #[test]
188
- fn emc_block_entropy_formula() {
189
- // Binary sequence (alphabet 2 -> log_base auto = 2), n = 100.
190
- let mut data = vec![b'0'; 50];
191
- data.extend(std::iter::repeat(b'1').take(50));
192
- let seq = Sequence::from_bytes(data);
193
- let mut args = LzArgs::new();
194
- args.get_shuffle_terms = true;
195
-
196
- let complexity = 10i32; // C_LZ(u)
197
- let h_rand = vec![0, 20, 15, 12]; // index 0 unused; c_1=20, c_2=15, c_3=12
198
- let mm = 3usize;
199
- let r = shuffle_entropy_calculation(&seq, &args, complexity, &h_rand, mm);
200
-
201
- // Hand-computed: g = log_2(100)/100, ĥ = 10·g, H_l = l·c_l·g (H_0 = 0).
202
- let n = 100.0f64;
203
- let g = (n.ln() / 2f64.ln()) / n;
204
- let h_hat = 10.0 * g;
205
- let (h1, h2, h3) = (1.0 * 20.0 * g, 2.0 * 15.0 * g, 3.0 * 12.0 * g);
206
- let expected = (h1 - 0.0 - h_hat) + (h2 - h1 - h_hat) + (h3 - h2 - h_hat);
207
- assert!(
208
- (r.emc_value - expected).abs() < 1e-12,
209
- "{} vs {}",
210
- r.emc_value,
211
- expected
212
- );
213
- assert!((r.summands[0] - (h1 - h_hat)).abs() < 1e-12);
214
- assert!((r.multi_information - (h1 - h_hat)).abs() < 1e-12);
215
- }
216
- }
File without changes
File without changes