Skip to main content

manifold/
mix_coder.rs

1//! MNF13-R5 — the dual-timescale adaptive mix coder (backend id 4),
2//! MNF13-R6 — the R6-tuned variant (backend id 5).
3//!
4//! Per-context FAST and SLOW adaptive fixed-point (2^24-scale) estimators
5//! over the full 256-symbol alphabet, mixed by a log-space EWA weight
6//! updated per symbol from the DECODED symbol — identical model evolution
7//! on both sides (zero model transport; the wire = [u32 stream_len] + LE
8//! u32 words). ConstricTON 0.5's stream range coder carries the
9//! fixed-point probabilities (2^24 scale, no fp in the wire data path).
10//! The exact-inverse contract is asserted by the conformance suite
11//! (`tests/mnf8_conformance.rs`) and the corpus gate.
12//!
13//! Wire (v1.14.0): MNF8 backend ids 4 (v1.13.0, frozen) and 5 (MNF13-R6;
14//! the measured grid winner, docs/mnf13-r6.md). The payload the backend
15//! field addresses = the mix-coded residual byte stream in the file's
16//! own coded order; every context key recomputes from already-coded
17//! bytes, so the decoder needs no side tables and rejects malformed
18//! inputs with errors (never panics).
19//!
20//! Backend 4: context = the previous coded byte bucketed to 8 keys;
21//! FAST cap 4,096, SLOW cap 65,536. Measured docs/mnf13-r5.md: −1.80%
22//! complete corpus at the v1.12 pick level, wins 19/19 photographic
23//! record files, zero regressions elsewhere.
24//!
25//! Backend 5 (MNF13-R6): context = the previous byte's bucket + a
26//! 2-band coarse pre-prev key (16 total); FAST cap 32,768, SLOW cap
27//! 1,048,576 (the long horizon). Same algorithmic family; the DECODE
28//! evolution differs by the constants, which is why the backend id is
29//! distinct. Measured docs/mnf13-r6.md: −3,376 B on the 20 backend-4
30//! corpus files (−0.62%), 20/20 fires, zero regressions; corpus
31//! projection 816,341 → 812,965 (−0.41%).
32
33use constriction::stream::{
34    model::ContiguousCategoricalEntropyModel, queue::DefaultRangeDecoder,
35    queue::DefaultRangeEncoder, Decode, Encode,
36};
37
38/// Fixed-point probability scale (2^24).
39const SCALE: u64 = 1 << 24;
40/// Backend 4's FAST estimator halving cap (frozen with v1.13.0).
41pub const MIX_FAST_CAP: u64 = 4096;
42/// Backend 4's SLOW estimator halving cap (frozen with v1.13.0).
43pub const MIX_SLOW_CAP: u64 = 65536;
44/// Log-space EWA learning rate (both backends).
45const EWA_LR: f64 = 0.15;
46/// Per-symbol count increment (both backends).
47const COUNT_INC: u64 = 32;
48/// Backend 5's FAST estimator halving cap (MNF13-R6).
49pub const MIX5_FAST_CAP: u64 = 32768;
50/// Backend 5's SLOW estimator halving cap — the long horizon (MNF13-R6).
51pub const MIX5_SLOW_CAP: u64 = 1048576;
52/// Backend 5's context count (prev bucket + 2-band pre-prev key).
53pub const MIX5_NCTX: usize = 16;
54
55/// One coder configuration; backend 4 and 5 differ ONLY here.
56#[derive(Clone, Copy)]
57pub struct MixParams {
58    /// Context count (backend 4: 8; backend 5: 16).
59    pub nctx: usize,
60    /// FAST estimator halving cap.
61    pub cap_fast: u64,
62    /// SLOW estimator halving cap.
63    pub cap_slow: u64,
64}
65
66impl MixParams {
67    /// Backend 4 constants (v1.13.0, frozen file-format contract).
68    pub const BACKEND4: Self = Self {
69        nctx: 8,
70        cap_fast: MIX_FAST_CAP,
71        cap_slow: MIX_SLOW_CAP,
72    };
73    /// Backend 5 constants (v1.14.0, MNF13-R6).
74    pub const BACKEND5: Self = Self {
75        nctx: MIX5_NCTX,
76        cap_fast: MIX5_FAST_CAP,
77        cap_slow: MIX5_SLOW_CAP,
78    };
79}
80
81/// The previous-coded-byte magnitude context (0; 1-2; 3-5; 6-11;
82/// 12-24; 25-48; 49-96; 97+) — shared by both backends' key rules.
83#[inline]
84pub fn mix_ctx_bucket(prev: u8) -> u8 {
85    match prev {
86        0 => 0,
87        1..=2 => 1,
88        3..=5 => 2,
89        6..=11 => 3,
90        12..=24 => 4,
91        25..=48 => 5,
92        49..=96 => 6,
93        _ => 7,
94    }
95}
96
97/// One context's dual estimator.
98#[derive(Clone)]
99struct MixCtx {
100    fast: Vec<u64>,
101    slow: Vec<u64>,
102    log_w: f64,
103    cap_fast: u64,
104    cap_slow: u64,
105}
106
107impl MixCtx {
108    fn new(cap_fast: u64, cap_slow: u64) -> Self {
109        Self {
110            fast: vec![1; 256],
111            slow: vec![1; 256],
112            log_w: 0.0,
113            cap_fast,
114            cap_slow,
115        }
116    }
117
118    /// Fixed-point probability vector over the FULL alphabet; every
119    /// symbol >= 1 (all must stay decodable); total exactly SCALE.
120    fn probs(&self) -> Vec<u32> {
121        let tf = self.fast.iter().sum::<u64>() as f64;
122        let ts = self.slow.iter().sum::<u64>() as f64;
123        let wf = 1.0 / (1.0 + (-self.log_w).exp());
124        let mut p: Vec<u64> = self
125            .fast
126            .iter()
127            .zip(&self.slow)
128            .map(|(&cf, &cs)| {
129                ((wf * (cf as f64 / tf) + (1.0 - wf) * (cs as f64 / ts)) * SCALE as f64) as u64
130            })
131            .collect();
132        // rebalance: remainder to slot 0; every zero slot <= 1 stolen
133        // from the densest entry (two passes, no double borrow)
134        let sum: u64 = p.iter().sum();
135        p[0] += SCALE - sum;
136        let mut dense = 0usize;
137        for (i, &v) in p.iter().enumerate() {
138            if v > p[dense] {
139                dense = i;
140            }
141        }
142        let zeros = p.iter().filter(|&&v| v == 0).count();
143        p[dense] = p[dense].max(1 + zeros as u64);
144        for v in p.iter_mut() {
145            if *v == 0 {
146                *v = 1;
147            }
148        }
149        p.into_iter().map(|v| v as u32).collect()
150    }
151
152    fn model(&self) -> Option<ContiguousCategoricalEntropyModel<u32, Vec<u32>, 24>> {
153        ContiguousCategoricalEntropyModel::<u32, Vec<u32>, 24>::from_nonzero_fixed_point_probabilities(
154            self.probs(),
155            false,
156        )
157        .ok()
158    }
159
160    fn step(&mut self, s: u8) {
161        let si = usize::from(s);
162        let tf = self.fast.iter().sum::<u64>() as f64;
163        let ts = self.slow.iter().sum::<u64>() as f64;
164        let pf = (self.fast[si] as f64 / tf).max(1e-9);
165        let ps = (self.slow[si] as f64 / ts).max(1e-9);
166        self.log_w = (self.log_w + EWA_LR * (pf.ln() - ps.ln())).clamp(-20.0, 20.0);
167        self.fast[si] += COUNT_INC;
168        self.slow[si] += COUNT_INC;
169        if self.fast.iter().sum::<u64>() > self.cap_fast {
170            for c in &mut self.fast {
171                *c = (*c + 1) >> 1;
172            }
173        }
174        if self.slow.iter().sum::<u64>() > self.cap_slow {
175            for c in &mut self.slow {
176                *c = (*c + 1) >> 1;
177            }
178        }
179    }
180}
181
182/// The context key for the byte at coded position `i`, given the two
183/// previously coded bytes. Backend 4 = 8 buckets of `prev` (the v1.13
184/// rule, byte-for-byte); backend 5 = the prev bucket plus a 2-band
185/// coarse pre-prev bucket (the R6 rule; deterministic, causal).
186#[inline]
187fn mix_ctx_key(params: &MixParams, prev: u8, prev2: u8) -> usize {
188    let pb = usize::from(mix_ctx_bucket(prev));
189    if params.nctx == 8 {
190        pb
191    } else {
192        let band = usize::from(mix_ctx_bucket(prev2) >= 3);
193        pb | (band << 3)
194    }
195}
196
197/// The mix coder: encode `data` (the residual bytes in coded order)
198/// under `params`. Zero transport; the model evolves from the bytes
199/// themselves.
200pub fn mix_encode_with(params: &MixParams, data: &[u8]) -> Vec<u8> {
201    let mut ctxs: Vec<MixCtx> = (0..params.nctx)
202        .map(|_| MixCtx::new(params.cap_fast, params.cap_slow))
203        .collect();
204    let mut enc = DefaultRangeEncoder::new();
205    let mut prev = 0u8;
206    let mut prev2 = 0u8;
207    for &b in data {
208        let c = mix_ctx_key(params, prev, prev2);
209        let m = ctxs[c]
210            .model()
211            .expect("mix model with all-nonzero probabilities is always buildable");
212        enc.encode_symbols(std::iter::once((usize::from(b), &m)))
213            .expect("mix encode symbol");
214        ctxs[c].step(b);
215        prev2 = prev;
216        prev = b;
217    }
218    let compressed = enc.into_compressed().expect("mix seal");
219    // The wire = [u32 stream_len][LE words]; the stream length is
220    // charged transport (honest) — the range coder cannot recover it
221    // from the frame alone.
222    let mut out = Vec::with_capacity(compressed.len() * 4 + 4);
223    out.extend_from_slice(
224        &u32::try_from(data.len())
225            .expect("stream len fits u32")
226            .to_le_bytes(),
227    );
228    for w in compressed {
229        out.extend_from_slice(&w.to_le_bytes());
230    }
231    out
232}
233
234/// The backend-4 form (v1.13.0 constants; byte-identical to the 1.13.0
235/// release's behavior).
236pub fn mix_encode(data: &[u8]) -> Vec<u8> {
237    mix_encode_with(&MixParams::BACKEND4, data)
238}
239
240/// The inverse under `params`: decode the wire back to its stream bytes.
241/// Errors, never panics, on malformed input.
242pub fn mix_decode_with(
243    params: &MixParams,
244    data: &[u8],
245    limit: usize,
246) -> Result<Vec<u8>, crate::error::Error> {
247    // payload = [u32 stream_len][LE words...]
248    if data.len() < 4 || !(data.len() - 4).is_multiple_of(4) {
249        return Err(crate::error::Error::fmt_msg("mix payload not word-aligned"));
250    }
251    let stream_len = usize::try_from(u32::from_le_bytes(data[..4].try_into().expect("4 bytes")))
252        .map_err(|_| crate::error::Error::fmt_msg("mix stream length"))?;
253    if stream_len > limit {
254        return Err(crate::error::Error::fmt_msg("bomb"));
255    }
256    let mut words = Vec::with_capacity((data.len() - 4) / 4);
257    for chunk in data[4..].chunks_exact(4) {
258        words.push(u32::from_le_bytes(chunk.try_into().expect("4 bytes")));
259    }
260    let mut dec = DefaultRangeDecoder::from_compressed(words)
261        .map_err(|e| crate::error::Error::fmt_msg(format!("mix decoder open: {e:?}")))?;
262    let mut ctxs: Vec<MixCtx> = (0..params.nctx)
263        .map(|_| MixCtx::new(params.cap_fast, params.cap_slow))
264        .collect();
265    let mut out = Vec::with_capacity(stream_len);
266    let mut prev = 0u8;
267    let mut prev2 = 0u8;
268    for _i in 0..stream_len {
269        let c = mix_ctx_key(params, prev, prev2);
270        let m = ctxs[c]
271            .model()
272            .ok_or_else(|| crate::error::Error::fmt_msg("mix model build failed"))?;
273        let s = dec
274            .decode_symbols(std::iter::once(&m))
275            .next()
276            .ok_or_else(|| crate::error::Error::fmt_msg("mix stream exhausted"))?
277            .map_err(|e| crate::error::Error::fmt_msg(format!("mix decode symbol: {e:?}")))?;
278        let s = u8::try_from(s)
279            .map_err(|_| crate::error::Error::fmt_msg("mix symbol out of byte range"))?;
280        ctxs[c].step(s);
281        out.push(s);
282        prev2 = prev;
283        prev = s;
284    }
285    Ok(out)
286}
287
288/// The backend-4 form (v1.13.0 constants).
289pub fn mix_decode(data: &[u8], limit: usize) -> Result<Vec<u8>, crate::error::Error> {
290    mix_decode_with(&MixParams::BACKEND4, data, limit)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    /// R5 legacy: the 8-ctx backend-4 form is byte-exact on run classes.
298    #[test]
299    fn mix_backend4_roundtrip_runs() {
300        let data: Vec<u8> = std::iter::repeat_n(0u8, 4096)
301            .chain(std::iter::repeat_n(7u8, 512))
302            .collect();
303        let wire = mix_encode(&data);
304        assert_eq!(mix_decode(&wire, data.len()).unwrap(), data);
305    }
306
307    /// R6: the 16-key backend-5 form roundtrips and differs from
308    /// backend 4 on non-trivial data (distinct decode evolution).
309    #[test]
310    fn mix_backend5_roundtrip_and_distinct() {
311        let data: Vec<u8> = (0..8192u32).map(|i| (i * 7 + (i >> 5)) as u8).collect();
312        let w4 = mix_encode(&data);
313        let w5 = mix_encode_with(&MixParams::BACKEND5, &data);
314        assert_eq!(mix_decode(&w4, data.len()).unwrap(), data);
315        assert_eq!(
316            mix_decode_with(&MixParams::BACKEND5, &w5, data.len()).unwrap(),
317            data
318        );
319        // different model constants = different wire (usually smaller)
320        assert_ne!(w4, w5, "backend 5's evolution must differ from backend 4's");
321    }
322
323    /// The R6 research projection's shape: on the REAL residual wires the
324    /// 16-key/long-cap form never loses (pinned in the corpus gate;
325    /// this unit pins the wire-level contract on one synthetic stream).
326    #[test]
327    fn mix_backend5_rejects_malformed() {
328        let data: Vec<u8> = (0..4096u32).map(|i| ((i * 31) >> 3) as u8).collect();
329        let w5 = mix_encode_with(&MixParams::BACKEND5, &data);
330        // truncated (17 B is not word-aligned) / bomb stream_len
331        assert!(mix_decode_with(&MixParams::BACKEND5, &w5[..17], 4096).is_err());
332        let mut bomb = w5.clone();
333        bomb[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
334        assert!(mix_decode_with(&MixParams::BACKEND5, &bomb, 4096).is_err());
335        // truncation to a word-aligned prefix (12 B = 2 words past the
336        // length word): constriction's queue decoder MAY legitimately
337        // decode a prefix stream — exhaustion surfaces as Ok-with-short
338        // OR Err depending on the coder state; the CONTRACT is never-a-
339        // panic, so assert exactly that:
340        let r = mix_decode_with(&MixParams::BACKEND5, &w5[..12], 4096);
341        assert!(r.is_ok() || r.is_err(), "no panic reachable"); // panics propagate
342        let _ = r.map(|v| assert!(v.len() <= 4096));
343        // well-formed wire of a RUN stream decodes exact
344        assert_eq!(
345            mix_decode_with(&MixParams::BACKEND5, &w5, data.len()).unwrap(),
346            data
347        );
348    }
349}