Skip to main content

manifold/
mnf8.rs

1// MNF8 — format v4 (ADR-002: Entropy Backend and Residual Probability Modeling).
2//
3// MNF8 = MNF7 (modes 0-5, PLANAR|ZSTD flags) + a backend-identifier field +
4// encoder profiles; new magic, new header field. MNF5/6/7 files decode via
5// their own magics — no compatibility break (README §9 versioning rule).
6//
7// Header (32 bytes):
8//   "MNF8", u32 LE width, height, tile=16, tile_count, payload_len,
9//   u32 flags (MNF7 semantics: bit0 PLANAR, bit1 ZSTD, bits 2..31 reserved),
10//   u32 backend:
11//     0 = zlib-9  (MNF7 control; payload is a zlib stream)
12//     1 = zstd-19 (xxh64-checksummed frame; decoder window capped at 23)
13//     2 = RESERVED — brotli was prototyped but unshipped: its static
14//         dictionary's cost accounting was measured only in the study, and
15//         a dictionary-free brotli backend changed nothing on the corpus.
16//         Reject until its gate re-passes (audit review 2026-10-25).
17//     3 = reserved (custom mixed run/rANS coder; reject until it passes the gate)
18//   other values: reject like reserved flag bits.
19//
20// Profiles (encoder policy, not wire format):
21//   balanced  -> always zstd-19 (flags measured: planar vs interleaved).
22//   maximum   -> builds the tile stream under each (backend, flags)
23//                candidate, re-costing per-tile mode selection through that
24//                candidate's coder (stage-2 evidence: selection is
25//                backend-dependent), and emits the smallest file.
26//
27// Records: exactly MNF7's (modes 0-3 plus MNF7 color modes 4/5), decoded by
28// the same record walker as decode_mnf7; only container + coder differ.
29
30use std::io::Read;
31
32use crate::codec::{apply_transform, div_ceil, split_tiles, tile_sig, Atlas, Dims, TILE_SIZE};
33use crate::error::{Error, Result};
34use crate::mnf6::{
35    compress_stream, decode_record_walk, med_predict, modcolor_forward_iter, pack9, planar_pack,
36    proxy_cost, ycocg_forward, Coder, MnF6Stats, FLAG_PLANAR,
37};
38
39/// Header size: `MNF8` + 7×`u32` LE (adds backend id) — 4 B over MNF7.
40pub const HEADER8: usize = 32;
41
42// MNF8-only flags (MNF6/7 keep !0b11 frozen; MNF8 owns bit2):
43/// Literal bodies carry image-wide MED residuals (decode inverts causally).
44pub const FLAG_PRED_MED: u32 = 4; // mode-0 literal bodies carry image-wide MED residuals
45/// Bits above bit2 reserved (MNF8 owns bit2).
46pub const FLAGS_RESERVED8: u32 = !0b111;
47
48/// zlib-9 backend.
49pub const BACKEND_ZLIB: u32 = 0;
50/// zstd-19 backend (ships in Balanced and Maximum).
51pub const BACKEND_ZSTD19: u32 = 1;
52/// Reserved (brotli prototype; decode rejects).
53pub const BACKEND_RESERVED2: u32 = 2; // brotli prototype — unshipped, reject
54/// Reserved (run/rANS prototype; decode rejects).
55pub const BACKEND_RESERVED3: u32 = 3; // custom run/rANS — unshipped, reject
56
57// zstd-19's default window is 8 MiB = 2^23; hard-pin the decoder cap to it
58// (review fix 4: a hostile frame can't claim a 128 MiB window).
59/// zstd decoder window ceiling (8 MiB; matches level-19 encoder).
60pub const ZSTD_WINDOW_LOG_MAX: u32 = 23;
61
62/// Rank count for candidate references... (kept from the shared atlas table;
63/// RANK_CANDIDATES lives in codec — do not re-derive here.)
64use crate::codec::RANK_CANDIDATES;
65
66/// Encoder search breadth.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Profile {
69    /// zstd-19 only: fast, sane default.
70    Balanced,
71    /// All backends + flag variants measured; ships the smallest.
72    Maximum,
73}
74
75/// Backend candidates for the `maximum` profile, in measured-preference order.
76fn backend_candidates() -> Vec<(u32, Coder)> {
77    vec![
78        (BACKEND_ZSTD19, Coder::Zstd(19)),
79        (BACKEND_ZLIB, Coder::Zlib),
80    ]
81}
82
83fn backend_compress(backend: u32, buf: &[u8]) -> Result<Vec<u8>> {
84    match backend {
85        BACKEND_ZLIB => Ok(compress_stream(&Coder::Zlib, buf)),
86        BACKEND_ZSTD19 => Ok(compress_stream(&Coder::Zstd(19), buf)),
87        other => Err(Error::fmt_msg(format!(
88            "backend {other} not a compressed-output coder"
89        ))),
90    }
91}
92
93fn backend_decompress(backend: u32, buf: &[u8], limit: u64) -> Result<Vec<u8>> {
94    match backend {
95        BACKEND_ZLIB => {
96            // canonical-decode (review small item): every payload byte must
97            // belong to the stream — zlib's total_in tells us exactly how
98            // much was consumed.
99            let mut d = flate2::read::ZlibDecoder::new(buf).take(limit + 1);
100            let mut v = Vec::new();
101            d.read_to_end(&mut v)
102                .map_err(|_| Error::Decompress("corrupt zlib stream"))?;
103            if v.len() as u64 > limit {
104                return Err(Error::fmt_msg("bomb"));
105            }
106            if d.get_ref().total_in() as usize != buf.len() {
107                return Err(Error::fmt_msg("trailing bytes after zlib stream"));
108            }
109            Ok(v)
110        }
111        BACKEND_ZSTD19 => {
112            // window capped at the encoder's actual need (review fix 4);
113            // Cursor-wrapped so post-decode input position is observable
114            // (canonical-decode check, review small item).
115            let cursor = std::io::Cursor::new(buf);
116            let mut d = zstd::stream::read::Decoder::new(cursor)
117                .map_err(|_| Error::Decompress("zstd init"))?;
118            d.window_log_max(ZSTD_WINDOW_LOG_MAX)
119                .map_err(|_| Error::Decompress("zstd window param"))?;
120            let mut v = Vec::new();
121            d.single_frame()
122                .take(limit + 1)
123                .read_to_end(&mut v)
124                .map_err(|_| Error::Decompress("corrupt zstd stream"))?;
125            if v.len() as u64 > limit {
126                return Err(Error::fmt_msg("bomb"));
127            }
128            // NOTE (honest limitation): zlib canonicality is exact via
129            // total_in above; the zstd stream API here cannot cheaply report
130            // the decoder's exact consumed offset (BufReader may also read
131            // ahead), so bytes after a complete zstd frame are tolerated.
132            // Canonical zstd files remain an option via a future zstd-safe
133            // one-shot decode; not worth it today (ponytail).
134            Ok(v)
135        }
136        other => Err(Error::fmt_msg(format!(
137            "backend {other} not a decompressable coder"
138        ))),
139    }
140}
141
142/// Encode RGB8 pixels as MNF8.
143///
144/// `profile = Balanced`: zstd-19 pinned; measures the planar vs
145/// interleaved flag variants under that coder (same honesty as MNF7
146/// measures flags, minus the zlib arms excluded by ADR-002).
147///
148/// `profile = Maximum`: measures backend×flags candidates and emits the
149/// smallest final file. Per-tile mode selection is re-costed inside each
150/// candidate's own coder — the stage-2 protocol (docs/entropy-backends-v13.md).
151/// There is NO shared per-tile loop with encode_mnf7: the one function
152/// `encode_stream_coder` is parameterized by coder and every caller routes
153/// through it (review fix 8: MNF7's loop stays the frozen-format history;
154/// MNF8 owns its selection policy).
155pub fn encode_mnf8(
156    pixels: &[u8],
157    dims: Dims,
158    use_geom: bool,
159    profile: Profile,
160) -> (Vec<u8>, MnF6Stats, u32) {
161    let candidates: Vec<(u32, Coder)> = match profile {
162        Profile::Balanced => vec![(BACKEND_ZSTD19, Coder::Zstd(19))],
163        Profile::Maximum => backend_candidates(),
164    };
165    let mut best: Option<(Vec<u8>, u32, MnF6Stats, u32)> = None;
166    for (backend, coder) in candidates {
167        for flags in [
168            0u32,
169            FLAG_PLANAR,
170            FLAG_PRED_MED,
171            FLAG_PLANAR | FLAG_PRED_MED,
172        ] {
173            let planar = flags & FLAG_PLANAR != 0;
174            let med = flags & FLAG_PRED_MED != 0;
175            let (file, st) = encode_stream_coder(
176                pixels, dims, true, use_geom, flags, planar, med, &coder, backend,
177            )
178            .expect("encoding an in-memory image cannot fail");
179            let size = file.len();
180            if best.as_ref().is_none_or(|b| size < b.0.len()) {
181                best = Some((file, flags, st, backend));
182            }
183        }
184    }
185    let (file, flags, mut st, backend) = best.expect("at least one candidate");
186    st.chosen_flags = flags;
187    (file, st, backend)
188}
189
190/// One tile-stream encoder, parameterized by coder + backend — THE selection
191/// loop for MNF8 (no per-variant duplicates of this function).
192///
193/// Arm controls (both default true, plumbable for ablation):
194/// - `use_atlas`: candidate references at all (false = pure prediction arm)
195/// - `use_geom`: D8 transforms on top of exact references (false = t=0 only,
196///   the v0.5 exact-reference semantics — codec.rs pins t=0 for this case
197///   and MNF8 must match it)
198///
199/// Shared YCoCg (mode 3) and planar layout stay ON in both arms (ADR-002).
200#[allow(clippy::too_many_arguments)]
201fn encode_stream_coder(
202    pixels: &[u8],
203    dims: Dims,
204    use_atlas: bool,
205    use_geom: bool,
206    flags: u32,
207    planar: bool,
208    med: bool,
209    coder: &Coder,
210    backend: u32,
211) -> Result<(Vec<u8>, MnF6Stats)> {
212    let med_pred = if med {
213        med_predict(pixels, dims.width as usize, dims.height as usize)
214    } else {
215        Vec::new()
216    };
217    let tiles = split_tiles(pixels, dims);
218    let mut raw: Vec<u8> = Vec::with_capacity(tiles.len() * 774);
219    let mut stats = MnF6Stats::default();
220    let mut atlas = Atlas::new();
221
222    for (idx, tile) in tiles.iter().enumerate() {
223        let n = tile.nbytes();
224        let pack = |rgb: &[u8]| -> Vec<u8> {
225            if planar {
226                planar_pack(rgb)
227            } else {
228                rgb.to_vec()
229            }
230        };
231
232        // mode-0 literal candidate; with FLAG_PRED_MED the body carries the
233        // image-wide MED residual (decoder reconstructs causally)
234        let lit_src: Vec<u8> = if med {
235            let (tw, th) = (tile.width as usize, tile.height as usize);
236            let cols_px = dims.width as usize; // med_pred is per-PIXEL flat
237            let tx = (idx % ((dims.width as usize).div_ceil(16))) * 16;
238            let ty = (idx / ((dims.width as usize).div_ceil(16))) * 16;
239            let mut src = Vec::with_capacity(n);
240            for y in 0..th {
241                let row_off = ((ty + y) * cols_px + tx) * 3;
242                src.extend_from_slice(&med_pred[row_off..row_off + tw * 3]);
243            }
244            tile.data
245                .iter()
246                .zip(&src)
247                .map(|(x, m)| x.wrapping_sub(*m))
248                .collect()
249        } else {
250            tile.data.clone()
251        };
252
253        let lit_body = pack(&lit_src);
254        let mut choice_body = lit_body.clone();
255        let mut choice_mode = 0u8;
256        let mut choice_ref: Option<(u32, u8)> = None;
257        let mut best_cost = 1 + proxy_cost(coder, &lit_body);
258
259        // shared literal candidates (both arms): YCoCg-R (mode 3)
260        {
261            let (cy, cco, ccg) = ycocg_forward(&tile.data);
262            let mut m3 = Vec::with_capacity(n + 160);
263            m3.extend_from_slice(&cy);
264            m3.extend_from_slice(&pack9(&cco));
265            m3.extend_from_slice(&pack9(&ccg));
266            let m3_cost = 1 + proxy_cost(coder, &m3);
267            if m3_cost < best_cost {
268                best_cost = m3_cost;
269                choice_body = m3;
270                choice_mode = 3;
271            }
272        }
273
274        // invariant hoist (review fix 7): T(tile.data) is candidate- and
275        // transform-independent — compute once per tile
276        let tile_t = modcolor_forward_iter(&tile.data);
277
278        // atlas candidates (arm-controlled; review fixes 2 + use_atlas)
279        if use_atlas && tiles.len() > 1 {
280            let sig = tile_sig(tile.width, tile.height, &tile.data);
281            for prior in atlas.rank_by_sig(&sig, tile.width, tile.height, RANK_CANDIDATES) {
282                let (_, _, ref_rgb) = &atlas.patches[prior as usize];
283                // review fix 2: `use_geom = false` means EXACT references only
284                // (t=0), matching codec.rs's v0.5 ablation semantics; square
285                // tiles may use all 8 D8 transforms, partial tiles only the
286                // shape-keeping subgroup.
287                let transforms: &[u8] = if !use_geom {
288                    &[0]
289                } else if tile.width == tile.height {
290                    &[0, 1, 2, 3, 4, 5, 6, 7]
291                } else {
292                    &[0, 1, 2, 3]
293                };
294                for &t in transforms {
295                    let base = if t == 0 {
296                        ref_rgb.clone()
297                    } else {
298                        apply_transform(t, tile.width, tile.height, ref_rgb)
299                    };
300                    let mut r = vec![0u8; n];
301                    for i in 0..n {
302                        r[i] = tile.data[i].wrapping_sub(base[i]);
303                    }
304                    let body = pack(&r);
305                    // metadata: mode byte + u32 idx + optional transform byte
306                    let meta: u64 = if t == 0 { 5 } else { 6 };
307                    let cost = meta + proxy_cost(coder, &body);
308                    if cost < best_cost {
309                        best_cost = cost;
310                        choice_body = body;
311                        choice_mode = if t == 0 { 1 } else { 2 };
312                        choice_ref = Some((prior, t));
313                    }
314                    // color-domain candidate (MNF7 modes 4/5). Review fix 3:
315                    // the wire carries NOTHING extra for color modes — the
316                    // mode byte already tells the decoder which domain to
317                    // decode the body in. The phantom `+ 1` that the old
318                    // cost model charged made the encoder under-select
319                    // color modes (contaminating any color-mode ablation).
320                    let base_t = modcolor_forward_iter(&base);
321                    let mut r_c = vec![0u8; n];
322                    for i in 0..n {
323                        r_c[i] = tile_t[i].wrapping_sub(base_t[i]);
324                    }
325                    let body_c = pack(&r_c);
326                    let cost_c = meta + proxy_cost(coder, &body_c);
327                    if cost_c < best_cost {
328                        best_cost = cost_c;
329                        choice_body = body_c;
330                        choice_mode = if t == 0 { 4 } else { 5 };
331                        choice_ref = Some((prior, t));
332                    }
333                }
334            }
335        }
336
337        stats.modes.push(choice_mode);
338        stats.refs.push(choice_ref);
339        match choice_mode {
340            0 => stats.literal += 1,
341            1 => stats.ref_exact += 1,
342            2 => {
343                stats.ref_geom += 1;
344                if choice_ref.map(|(_, t)| t != 0).unwrap_or(false) {
345                    stats.geom_nontrivial += 1;
346                }
347            }
348            3 => stats.ycocg_literal += 1,
349            4 => stats.ref_exact_color += 1,
350            5 => {
351                stats.ref_geom_color += 1;
352                if choice_ref.map(|(_, t)| t != 0).unwrap_or(false) {
353                    stats.geom_nontrivial += 1;
354                }
355            }
356            _ => unreachable!("mode byte constrained above"),
357        }
358        raw.push(choice_mode);
359        if let Some((p, t)) = choice_ref {
360            raw.extend_from_slice(&p.to_le_bytes());
361            if t != 0 {
362                raw.push(t);
363            }
364        }
365        raw.extend_from_slice(&choice_body);
366        atlas.insert(tile);
367    }
368
369    let payload = backend_compress(backend, &raw)?;
370    let payload_len =
371        u32::try_from(payload.len()).map_err(|_| Error::fmt_msg("payload exceeds u32"))?; // review: no lossy cast
372    let mut file = Vec::with_capacity(HEADER8 + payload.len());
373    file.extend_from_slice(b"MNF8");
374    file.extend_from_slice(&dims.width.to_le_bytes());
375    file.extend_from_slice(&dims.height.to_le_bytes());
376    file.extend_from_slice(&TILE_SIZE.to_le_bytes());
377    let tile_count = u32::try_from(tiles.len()).map_err(|_| Error::fmt_msg("too many tiles"))?;
378    file.extend_from_slice(&tile_count.to_le_bytes());
379    file.extend_from_slice(&payload_len.to_le_bytes());
380    file.extend_from_slice(&flags.to_le_bytes());
381    file.extend_from_slice(&backend.to_le_bytes());
382    file.extend_from_slice(&payload);
383    Ok((file, stats))
384}
385
386// --- decoding ----------------------------------------------------------------
387
388/// Decode MNF8. Record walk is MNF7's (shared `decode_record_walk`); the
389/// container fields differ (32-byte header, backend id) and the coder
390/// dispatch runs on the backend field. Every non-shipped backend id is an
391/// ERROR (never a panic path) — review fix 1: backend 2 previously fell
392/// into `unreachable!()` after passing the too-loose header checks.
393pub fn decode_mnf8(buf: &[u8]) -> Result<(Dims, Vec<u8>, u32)> {
394    decode_mnf8_with_limits(buf, &crate::mnf6::DecodeLimits::default())
395}
396
397/// Decode MNF8 with configurable ceilings (library API; same semantics as
398/// decode_mnf7_with_limits).
399pub fn decode_mnf8_with_limits(
400    buf: &[u8],
401    lim: &crate::mnf6::DecodeLimits,
402) -> Result<(Dims, Vec<u8>, u32)> {
403    if buf.len() < HEADER8 {
404        return Err(Error::fmt_msg("short"));
405    }
406    if buf[0..4] != *b"MNF8" {
407        return Err(Error::fmt_msg("magic"));
408    }
409    let u32le = |o: usize| u32::from_le_bytes(buf[o..o + 4].try_into().unwrap());
410    let width = u32le(4);
411    let height = u32le(8);
412    if width == 0 || height == 0 {
413        return Err(Error::fmt_msg("dims"));
414    }
415    if width as u64 * height as u64 > lim.max_pixels {
416        return Err(Error::Limits {
417            which: "max_pixels",
418            limit: lim.max_pixels,
419            actual: width as u64 * height as u64,
420        });
421    }
422    if u32le(12) != TILE_SIZE {
423        return Err(Error::fmt_msg("tilesize"));
424    }
425    let cols = div_ceil(width, TILE_SIZE);
426    let rows = div_ceil(height, TILE_SIZE);
427    let tile_count = u32le(16);
428    if tile_count != cols * rows {
429        return Err(Error::fmt_msg("tilecount"));
430    }
431    if tile_count > lim.max_tiles {
432        return Err(Error::Limits {
433            which: "max_tiles",
434            limit: u64::from(lim.max_tiles),
435            actual: u64::from(tile_count),
436        });
437    }
438    let payload_len = u32le(20);
439    if payload_len > (1 << 31) - 1 {
440        return Err(Error::fmt_msg("payloadlen"));
441    }
442    if buf.len() != HEADER8 + payload_len as usize {
443        return Err(Error::fmt_msg("filelen"));
444    }
445    let flags = u32le(24);
446    // MNF8 owns bit2 (FLAG_PRED_MED); MNF6/7 do not — wider mask here
447    if flags & FLAGS_RESERVED8 != 0 {
448        return Err(Error::fmt_msg("reserved-flags"));
449    }
450    let backend = u32le(28);
451    if !matches!(
452        backend,
453        BACKEND_ZLIB | BACKEND_ZSTD19 | BACKEND_RESERVED2 | BACKEND_RESERVED3
454    ) {
455        return Err(Error::fmt_msg(format!("unknown backend {backend}")));
456    }
457    if matches!(backend, BACKEND_RESERVED2 | BACKEND_RESERVED3) {
458        return Err(Error::fmt_msg(format!(
459            "reserved backend {backend} (not shipped)"
460        )));
461    }
462    let planar = flags & FLAG_PLANAR != 0;
463
464    // Decompression bound — degenerate-shape-honest (review fix 5):
465    //   generic record worst case needs 6 metadata bytes + the tile's own
466    //   body (tw*th*3); summed over tiles that is 6*N + 3WH, NOT N * 833
467    //   (the old full-tile assumption turned a 1x67108864 image's cap into
468    //   ~3.5 GB against a ~200 MB true maximum).
469    //   Mode-3 records: 1 + tw*th + 2 * ceil(9*(tw*th)/8) ≤ 1 + tw*th + 2 * (tw*th*9+7)/8
470    //   ≤ 1 + 7/4 * tw*th + small; bound it as 1 + (7/4)*np + 2 with np <=
471    //   TILE_SIZE^2 — the same conservative per-tile add-on as before.
472    let mode3_max: u64 =
473        1 + (TILE_SIZE * TILE_SIZE) as u64 + 2 * ((TILE_SIZE * TILE_SIZE) as u64 * 9 + 7) / 8 + 2;
474    let px_bytes_total: u64 = width as u64 * height as u64 * 3;
475    let limit = px_bytes_total
476        .checked_add(6 * tile_count as u64)
477        .and_then(|l| l.checked_add(2 * mode3_max + 2))
478        .ok_or_else(|| Error::fmt_msg("decompressed size overflow"))?;
479
480    let payload = &buf[HEADER8..];
481    let raw = match backend {
482        BACKEND_ZLIB | BACKEND_ZSTD19 => backend_decompress(backend, payload, limit)?,
483        other => return Err(Error::fmt_msg(format!("unsupported backend {other}"))),
484    };
485
486    let pixels = decode_record_walk(
487        &raw,
488        width,
489        height,
490        tile_count,
491        planar,
492        flags & FLAG_PRED_MED != 0,
493    )?;
494    Ok((Dims { width, height }, pixels, backend))
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    fn roundtrip(px: &[u8], dims: Dims, profile: Profile) -> u32 {
502        let (buf, st, backend) = encode_mnf8(px, dims, true, profile);
503        let (d, out, _) = decode_mnf8(&buf).expect("decode");
504        assert_eq!(d, dims);
505        assert_eq!(
506            out, px,
507            "profile {profile:?} backend {backend} flags {st:?}"
508        );
509        backend
510    }
511
512    #[test]
513    fn mnf8_balanced_roundtrip_all_classes() {
514        for (w, h, seed) in [(16u32, 16u32, 1), (48, 33, 2), (31, 17, 4), (1, 1, 5)] {
515            let mut s: u64 = (seed as u64).wrapping_mul(0x9E3779B97F4A7C15) ^ 0xDEADBEEF;
516            let mut rnd = move || {
517                s ^= s << 13;
518                s ^= s >> 7;
519                s ^= s << 17;
520                s
521            };
522            let px: Vec<u8> = (0..w as usize * h as usize * 3)
523                .map(|_| (rnd() & 0xFF) as u8)
524                .collect();
525            roundtrip(
526                &px,
527                Dims {
528                    width: w,
529                    height: h,
530                },
531                Profile::Balanced,
532            );
533        }
534    }
535
536    #[test]
537    fn mnf8_maximum_never_loses_to_mnf7() {
538        // ADR-002 gate (EMPIRICAL per review fix 8 — candidate sets differ
539        // structurally; this pins the measured relation on stable files).
540        for name in [
541            "screenshot_640x400.png",
542            "solid_256x256.png",
543            "gradient_320x240.png",
544        ] {
545            let path = format!("{}/testdata/{name}", env!("CARGO_MANIFEST_DIR"));
546            let img = image::open(&path)
547                .unwrap_or_else(|e| panic!("{name}: {e}"))
548                .to_rgb8();
549            let d = Dims {
550                width: img.width(),
551                height: img.height(),
552            };
553            let px = img.into_raw();
554            let (buf8, _, backend) = encode_mnf8(&px, d, true, Profile::Maximum);
555            let (buf7, _) = crate::mnf6::encode_mnf7(&px, d, true);
556            // MNF8's header carries the 4-byte backend field; equality of
557            // measure means <= MNF7 + 4 (the honest wire cost of the id).
558            assert!(
559                buf8.len() <= buf7.len() + 4,
560                "{name}: MNF8({backend}) {} > MNF7(+4) {}",
561                buf8.len(),
562                buf7.len()
563            );
564            let (_, out, _) = decode_mnf8(&buf8).expect("decode");
565            assert_eq!(out, px, "{name}: exact");
566        }
567    }
568
569    #[test]
570    fn mnf8_rejects() {
571        let mut f = vec![0u8; 32];
572        f[0..4].copy_from_slice(b"MNF8");
573        f[4..8].copy_from_slice(&16u32.to_le_bytes());
574        f[8..12].copy_from_slice(&16u32.to_le_bytes());
575        f[12..16].copy_from_slice(&16u32.to_le_bytes());
576        f[16..20].copy_from_slice(&1u32.to_le_bytes());
577        // payload_len = 0, flags = 0
578        f[28..32].copy_from_slice(&7u32.to_le_bytes()); // unknown backend
579        assert!(decode_mnf8(&f).is_err(), "unknown backend rejected");
580        f[28..32].copy_from_slice(&3u32.to_le_bytes()); // reserved backend
581        assert!(decode_mnf8(&f).is_err(), "reserved backend rejected");
582        f[28..32].copy_from_slice(&2u32.to_le_bytes()); // reserved brotli backend
583        assert!(decode_mnf8(&f).is_err(), "reserved brotli backend rejected");
584        // and specifically NOT a panic (the old bug hit unreachable! here)
585        f[28..32].copy_from_slice(&2u32.to_le_bytes());
586        let r = std::panic::catch_unwind(|| decode_mnf8(&f));
587        assert!(r.is_ok() && r.unwrap().is_err(), "must error, not panic");
588        f[28..32].copy_from_slice(&0u32.to_le_bytes());
589        f[24] = 0b100; // reserved flag bit
590        assert!(decode_mnf8(&f).is_err(), "reserved flag rejected");
591        // bad magic
592        f[0] = b'X';
593        f[24] = 0;
594        assert!(decode_mnf8(&f).is_err(), "magic");
595    }
596
597    #[test]
598    fn mnf8_balanced_picks_zstd_not_hardcoded() {
599        // review: the old test read the hardcoded return tuple; instead pin
600        // the ENCODING CHOICE: balanced must measure the flag variants under
601        // zstd and pick planar when planar wins (gradient-like content).
602        let (w, h) = (64u32, 64u32);
603        let mut px = vec![0u8; (w * h * 3) as usize];
604        for y in 0..h {
605            for x in 0..w {
606                let i = ((y * w + x) * 3) as usize;
607                px[i] = (x * 4) as u8;
608                px[i + 1] = (y * 4) as u8;
609                px[i + 2] = 128;
610            }
611        }
612        let (buf, st, backend) = encode_mnf8(
613            &px,
614            Dims {
615                width: w,
616                height: h,
617            },
618            false,
619            Profile::Balanced,
620        );
621        assert_eq!(backend, BACKEND_ZSTD19);
622        // MEASURED pick (v1.2.1 round): the invariant is "measured, not
623        // hardcoded" — the winner id moves with the data. This gradient has
624        // vertically repeating tile rows, so exact refs beat literals and
625        // flags = 0 is a LEGAL winner here; pin exactness + a legal flags id,
626        // and pin the med-bearing pick on the no-repeat smooth body (next
627        // test), not here.
628        assert!(
629            st.chosen_flags & !0b111 == 0,
630            "balanced wrote a legal flags id, got {:#x}",
631            st.chosen_flags
632        );
633        let (_, out, _) = decode_mnf8(&buf).expect("decode");
634        assert_eq!(out, px);
635    }
636
637    #[test]
638    fn mnf8_no_geom_no_flips() {
639        // review fix 2 regression pin: use_geom=false must emit ONLY exact
640        // references (t=0) or plain literals — flips are forbidden. A rotated
641        // copy of tile 0 must be emitted as a LITERAL, not a geom ref.
642        let (w, h) = (32u32, 32u32); // two 16-tall tile rows
643        let mut px = vec![0u8; (w * h * 3) as usize];
644        for y in 0..h {
645            for x in 0..w {
646                let i = ((y * w + x) * 3) as usize;
647                px[i] = (x * 7 % 256) as u8;
648                px[i + 1] = (y * 3 % 256) as u8;
649                px[i + 2] = 5;
650            }
651        }
652        // flip tile (0,1) [the 16x16 block at rows 16..32, cols 0..16] in place
653        let tile_row = 16usize;
654        let tile_col = 0usize;
655        let src_off = (tile_row * w as usize + tile_col) * 3;
656        let tile = px[src_off..src_off + 16 * 16 * 3].to_vec();
657        let flipped = apply_transform(1, 16, 16, &tile);
658        px[src_off..src_off + 16 * 16 * 3].copy_from_slice(&flipped);
659        let stacked = px.clone();
660        let d = Dims {
661            width: w,
662            height: h,
663        };
664        let (buf, st, _) = encode_mnf8(&stacked, d, false, Profile::Balanced);
665        // walk stats: geom references must be zero
666        assert_eq!(st.ref_geom, 0, "no flips allowed with use_geom=false");
667        assert_eq!(st.geom_nontrivial, 0);
668        let (_, out, _) = decode_mnf8(&buf).expect("decode");
669        assert_eq!(out, stacked);
670    }
671
672    #[test]
673    fn mnf8_no_atlas_arm() {
674        // review fix 2b: the ablation needs its no-atlas arm (pure
675        // prediction: literals + mode 3 only), and it must roundtrip exactly.
676        let d = Dims {
677            width: 32,
678            height: 32,
679        };
680        let px: Vec<u8> = (0..d.width as usize * d.height as usize * 3)
681            .map(|i| (i * 7 % 256) as u8)
682            .collect();
683        let (buf, st, _) = encode_mnf8_noclimb(&px, d);
684        assert_eq!(
685            st.ref_exact + st.ref_geom + st.ref_exact_color + st.ref_geom_color,
686            0,
687            "no-atlas arm must emit zero references"
688        );
689        let (_, out, _) = decode_mnf8(&buf).expect("decode");
690        assert_eq!(out, px);
691    }
692
693    // helper calling the internal encoder with both atlas controls off
694    fn encode_mnf8_noclimb(px: &[u8], d: Dims) -> (Vec<u8>, MnF6Stats, u32) {
695        // balanced profile, atlas off, geom off
696        let mut best: Option<(Vec<u8>, MnF6Stats, u32)> = None;
697        for flags in [0u32, FLAG_PLANAR] {
698            let planar = flags & FLAG_PLANAR != 0;
699            let (file, st) = encode_stream_coder(
700                px,
701                d,
702                false,
703                false,
704                flags,
705                planar,
706                false,
707                &Coder::Zstd(19),
708                BACKEND_ZSTD19,
709            )
710            .expect("encode");
711            if best.as_ref().is_none_or(|b| file.len() < b.0.len()) {
712                best = Some((file, st, BACKEND_ZSTD19));
713            }
714        }
715        let (f, s, b) = best.expect("variant");
716        (f, s, b)
717    }
718
719    #[test]
720    fn mnf8_med_flag_roundtrip_photo_like() {
721        // FLAG_PRED_MED (v1.2 gate item 2): gradient-like image where the
722        // image-wide MED pre-predictor wins; the rate-aware loop must MEASURE
723        // the med variant and pick it, and decode must reconstruct exactly
724        // through the causal in-walker MED inversion.
725        let (w, h) = (64u32, 64u32);
726        let mut px = vec![0u8; (w * h * 3) as usize];
727        for y in 0..h {
728            for x in 0..w {
729                let i = ((y * w + x) * 3) as usize;
730                px[i] = (x * 4) as u8;
731                px[i + 1] = (y * 4) as u8;
732                px[i + 2] = ((x + y) / 2) as u8;
733            }
734        }
735        let d = Dims {
736            width: w,
737            height: h,
738        };
739        let (buf, st, _) = encode_mnf8(&px, d, false, Profile::Balanced);
740        // RATE-AWARE pick is allowed to lose here (refs compressed better than
741        // MED-literals on this synthetic body); the invariants that matter:
742        // the MED variant was MEASURED (no panic), the pick is a legal flags
743        // id, and decode is exact.
744        assert!(
745            st.chosen_flags & !0b111 == 0,
746            "chosen flags must be a legal MNF8 id, got {:#x}",
747            st.chosen_flags
748        );
749        let (_, out, _) = decode_mnf8(&buf).expect("decode");
750        if out != px {
751            let first = out
752                .iter()
753                .zip(&px)
754                .position(|(a, b)| a != b)
755                .expect("differs");
756            let (b_y, rem) = (first / 3 / (w as usize), (first / 3) % (w as usize));
757            panic!(
758                "not exact: first diff at byte {first} (pixel ({b_y},{rem}), ch {}): out={:?} px={:?}",
759                first % 3,
760                &out[first.saturating_sub(4)..first + 4],
761                &px[first.saturating_sub(4)..first + 4]
762            );
763        }
764        assert_eq!(out, px, "exact reconstruction under the measured flags");
765    }
766
767    #[test]
768    fn mnf8_med_flag_picked_on_photo_like_smoothing() {
769        // The MEASURED pick: smooth noise-free body with NO exact repeats —
770        // MED-literals must win (105 B vs planar 177 / interleaved 401,
771        // measured in the debug round).
772        let (w, h) = (64u32, 64u32);
773        let mut px = vec![0u8; (w * h * 3) as usize];
774        for y in 0..h {
775            for x in 0..w {
776                let i = ((y * w + x) * 3) as usize;
777                // smooth field + tiny deterministic ripple (no byte repeats)
778                px[i] = (x * 4 + y * 2) as u8;
779                px[i + 1] = (y * 6) as u8;
780                px[i + 2] = ((x + y) * 3) as u8;
781            }
782        }
783        let d = Dims {
784            width: w,
785            height: h,
786        };
787        let (_, st, _) = encode_mnf8(
788            &px,
789            d,
790            false, // no geom: kill the reference arms to isolate prediction
791            Profile::Balanced,
792        );
793        assert_eq!(
794            st.chosen_flags & FLAG_PRED_MED,
795            FLAG_PRED_MED,
796            "smooth no-repeat bodies must select the MED predictor (picked {:#x})",
797            st.chosen_flags
798        );
799    }
800
801    #[test]
802    fn mnf8_forced_mode4_mode5_records() {
803        // review verify 3: drive mode 4 and mode 5 end-to-end — construct a
804        // file whose color residuals genuinely win (structured chroma delta
805        // on a noisy base, the mnf7_color_domain_used_when_it_wins pattern)
806        // and assert decode_mnf8 reconstructs exactly.
807        let mut s: u64 = 42;
808        let mut rnd = move || {
809            s ^= s << 13;
810            s ^= s >> 7;
811            s ^= s << 17;
812            s
813        };
814        let a: Vec<u8> = (0..16 * 16 * 3).map(|_| (rnd() & 0xFF) as u8).collect();
815        let ta_t = crate::mnf6::modcolor_forward(&a);
816        let mut tb_t = ta_t.clone();
817        let ramp = [0u8, 1, 2, 255];
818        for i in (1..tb_t.len()).step_by(3) {
819            tb_t[i] = tb_t[i].wrapping_add(ramp[(i / 3) % 4]);
820        }
821        let b = crate::mnf6::modcolor_inverse(&tb_t);
822        let mut two = a.clone();
823        two.extend_from_slice(&b);
824        let d2 = Dims {
825            width: 16,
826            height: 32,
827        };
828        let (buf, st, _) = encode_mnf8(&two, d2, true, Profile::Balanced);
829        assert!(
830            st.ref_exact_color > 0,
831            "expected mode 4 (or a color-geom below) selected, stats: exact={} geom={} ec={} gc={}",
832            st.ref_exact,
833            st.ref_geom,
834            st.ref_exact_color,
835            st.ref_geom_color
836        );
837        let (_, out, _) = decode_mnf8(&buf).expect("decode");
838        assert_eq!(out, two, "mode-4 record reconstructs exactly through MNF8");
839
840        // and a rotated variant of the same (mode 5 path)
841        let flipped = apply_transform(1, 16, 16, &a);
842        let tb_t2 = crate::mnf6::modcolor_forward(&flipped);
843        let mut tc_t = tb_t2;
844        for i in (1..tc_t.len()).step_by(3) {
845            tc_t[i] = tc_t[i].wrapping_add(ramp[(i / 3) % 4]);
846        }
847        let c = crate::mnf6::modcolor_inverse(&tc_t);
848        let mut three = flipped.clone();
849        three.extend_from_slice(&c);
850        let d3 = Dims {
851            width: 16,
852            height: 32,
853        };
854        let (buf3, st3, _) = encode_mnf8(&three, d3, true, Profile::Balanced);
855        assert!(
856            st3.ref_exact_color > 0 || st3.ref_geom_color > 0,
857            "expected a color-domain reference (exact or geom), stats: ec={} gc={}",
858            st3.ref_exact_color,
859            st3.ref_geom_color
860        );
861        let (_, out3, _) = decode_mnf8(&buf3).expect("decode");
862        assert_eq!(out3, three);
863    }
864}