Skip to main content

cch/
writer.rs

1//! Bundle WRITERS — produce `.cch-struct` / `.cch-metric` files that are
2//! **byte-identical** to `RoutingKit`'s `cch_save_struct` / `cch_save_metric`
3//! (`oracle/routingkit-cch/src/cch_bundle.cc`).
4//!
5//! [`Cch::save_struct`] writes the `CCH_STRC` v1 layout; [`Metric::save`] writes
6//! the `CCH_METR` v1 layout. Together with the readers in [`crate::bundle`] this
7//! makes the pipeline fully pure-Rust round-trippable while staying compatible
8//! with the existing on-disk corpus and the C++ loader.
9//!
10//! ## The on-disk mapping layout (the crux)
11//!
12//! [`Cch::build`] stores the input-arc → CCH-arc mapping as FULL-SIZE
13//! (`cch_arc_count`) arrays — convenient for [`Cch::customize`]. The on-disk
14//! format instead uses the C++ LOCAL-id-compressed representation: three
15//! [`BitVector`](crate::internal::BitVector)s plus arrays compressed via a
16//! [`LocalIDMapper`](crate::internal::LocalIDMapper). [`reconstruct_on_disk_mapping`]
17//! rebuilds that exact representation from the full-size arrays so the bytes
18//! match the C++ constructor (`customizable_contraction_hierarchy.cpp` ~555-640)
19//! and serializer (`cch_bundle.cc` ~130-160).
20
21use std::io::{self, Write};
22
23use crate::bundle::INVALID_ID;
24use crate::customize::Metric;
25use crate::internal::bitvec::BitVector;
26use crate::internal::id_map::LocalIDMapper;
27use crate::structure::Cch;
28
29const STRUCT_MAGIC: u64 = 0x4343_485F_5354_5243; // "CCH_STRC"
30const METRIC_MAGIC: u64 = 0x4343_485F_4D45_5452; // "CCH_METR"
31const FORMAT_VERSION: u32 = 1;
32
33/// Write the raw little-endian bytes of a `u32`.
34fn write_u32<W: Write>(out: &mut W, value: u32) -> io::Result<()> {
35    out.write_all(&value.to_le_bytes())
36}
37
38/// Write the raw little-endian bytes of a `u64`.
39fn write_u64<W: Write>(out: &mut W, value: u64) -> io::Result<()> {
40    out.write_all(&value.to_le_bytes())
41}
42
43/// `write_sized_vector`: a `u64` byte length (`= v.len() * 4`) followed by the
44/// raw little-endian `u32` bytes. Mirrors `cch_bundle.cc::write_sized_vector`.
45fn write_sized_vector<W: Write>(out: &mut W, v: &[u32]) -> io::Result<()> {
46    let byte_length = (v.len() as u64) * 4;
47    write_u64(out, byte_length)?;
48    // On a little-endian host the in-memory `u32` layout already matches the
49    // wire format; build the byte buffer explicitly so the writer is
50    // endianness-independent and matches the C++ raw `out.write` exactly.
51    let mut bytes = Vec::with_capacity(v.len() * 4);
52    for &x in v {
53        bytes.extend_from_slice(&x.to_le_bytes());
54    }
55    out.write_all(&bytes)
56}
57
58/// `write_sized_bit_vector`: a `u64` bit count, then a `u64` byte length
59/// (`= ((bit_count + 511) / 512) * 64`), then the 512-bit-block-padded
60/// `u64`-word bytes. Mirrors `cch_bundle.cc::write_sized_bit_vector`.
61fn write_sized_bit_vector<W: Write>(out: &mut W, bv: &BitVector) -> io::Result<()> {
62    let bit_count = bv.len();
63    let byte_length = bit_count.div_ceil(512) * 64;
64    write_u64(out, bit_count)?;
65    write_u64(out, byte_length)?;
66    // The C++ dumps the BitVector's 64-bit-aligned storage, which is padded to
67    // a multiple of 512 bits. `BitVector::words()` is only ceil(bit/64) words,
68    // so pad with zero words up to `byte_length / 8` to match exactly.
69    let word_count = (byte_length / 8) as usize;
70    let mut bytes = Vec::with_capacity(word_count * 8);
71    let words = bv.words();
72    for &w in words {
73        bytes.extend_from_slice(&w.to_le_bytes());
74    }
75    for _ in words.len()..word_count {
76        bytes.extend_from_slice(&0u64.to_le_bytes());
77    }
78    out.write_all(&bytes)
79}
80
81/// The C++ LOCAL-id-compressed on-disk representation of the input-arc mapping,
82/// reconstructed from the full-size arrays held by [`Cch`].
83struct OnDiskMapping {
84    is_input_arc_upward: BitVector,
85    does_cch_arc_have_input_arc: BitVector,
86    does_cch_arc_have_extra_input_arc: BitVector,
87    /// Length = `does_cch_arc_have_input_arc.local_id_count()`.
88    forward_input_arc_of_cch: Vec<u32>,
89    /// Length = `does_cch_arc_have_input_arc.local_id_count()`.
90    backward_input_arc_of_cch: Vec<u32>,
91    /// CSR offsets, length = `does_cch_arc_have_extra_input_arc.local_id_count() + 1`.
92    first_extra_forward_input_arc_of_cch: Vec<u32>,
93    first_extra_backward_input_arc_of_cch: Vec<u32>,
94    /// Flat extra lists.
95    extra_forward_input_arc_of_cch: Vec<u32>,
96    extra_backward_input_arc_of_cch: Vec<u32>,
97}
98
99/// Rebuilds the C++ LOCAL-id-compressed mapping (3 bitvectors + local-compressed
100/// vectors + extra CSR) from the full-size arrays in `cch`, byte-identically to
101/// `customizable_contraction_hierarchy.cpp` ~555-640.
102#[allow(clippy::cast_possible_truncation)] // ids/counts fit u32 (CCH limit)
103fn reconstruct_on_disk_mapping(cch: &Cch) -> OnDiskMapping {
104    let input_arc_count = cch.input_arc_to_cch_arc.len();
105    let cch_arc_count = cch.cch_arc_count();
106
107    // is_input_arc_upward[input_arc]: set iff the input arc runs up-rank. An
108    // input arc is "upward" exactly when it backs a FORWARD weight, i.e. it
109    // appears as a (first or extra) forward input arc. C++ sets this bit (line
110    // 350) for every input arc whose relabeled tail < head.
111    let mut is_input_arc_upward = BitVector::new(input_arc_count as u64);
112    for &ia in &cch.forward_input_arc_of_cch {
113        if ia != INVALID_ID {
114            is_input_arc_upward.set(u64::from(ia));
115        }
116    }
117    for &ia in &cch.extra_forward_input_arc_of_cch {
118        is_input_arc_upward.set(u64::from(ia));
119    }
120
121    // does_cch_arc_have_input_arc[cch_arc]: set iff the CCH arc has any input
122    // arc (forward or backward). C++ lines 560-562.
123    let mut does_cch_arc_have_input_arc = BitVector::new(cch_arc_count as u64);
124    for cch_arc in 0..cch_arc_count {
125        if cch.forward_input_arc_of_cch[cch_arc] != INVALID_ID
126            || cch.backward_input_arc_of_cch[cch_arc] != INVALID_ID
127        {
128            does_cch_arc_have_input_arc.set(cch_arc as u64);
129        }
130    }
131
132    // does_cch_arc_have_extra_input_arc[cch_arc]: set iff the CCH arc has 2+
133    // input arcs in either direction. C++ lines 583 / 592.
134    let mut does_cch_arc_have_extra_input_arc = BitVector::new(cch_arc_count as u64);
135    let ff = &cch.first_extra_forward_input_arc_of_cch;
136    let fb = &cch.first_extra_backward_input_arc_of_cch;
137    for cch_arc in 0..cch_arc_count {
138        if ff[cch_arc + 1] > ff[cch_arc] || fb[cch_arc + 1] > fb[cch_arc] {
139            does_cch_arc_have_extra_input_arc.set(cch_arc as u64);
140        }
141    }
142
143    // LOCAL-compressed first/forward arrays, indexed by to_local(cch_arc) over
144    // does_cch_arc_have_input_arc (C++ lines 564-581).
145    let in_mapper = LocalIDMapper::new(
146        does_cch_arc_have_input_arc.words(),
147        does_cch_arc_have_input_arc.len(),
148    );
149    let local_in = in_mapper.local_id_count() as usize;
150    let mut forward_local = vec![INVALID_ID; local_in];
151    let mut backward_local = vec![INVALID_ID; local_in];
152    for cch_arc in 0..cch_arc_count {
153        if does_cch_arc_have_input_arc.is_set(cch_arc as u64) {
154            let li = in_mapper.to_local(cch_arc as u64) as usize;
155            forward_local[li] = cch.forward_input_arc_of_cch[cch_arc];
156            backward_local[li] = cch.backward_input_arc_of_cch[cch_arc];
157        }
158    }
159
160    // Extra CSR, indexed by to_local(cch_arc) over does_cch_arc_have_extra_input_arc
161    // (C++ lines 601-637). The flat extra lists are already grouped by ascending
162    // cch arc in `cch.extra_*`; only the CSR offsets are re-indexed to extra-local
163    // ids. Build the per-extra-local count, then prefix-sum to CSR offsets.
164    let extra_mapper = LocalIDMapper::new(
165        does_cch_arc_have_extra_input_arc.words(),
166        does_cch_arc_have_extra_input_arc.len(),
167    );
168    let local_extra = extra_mapper.local_id_count() as usize;
169
170    let mut first_extra_forward = vec![0u32; local_extra + 1];
171    let mut first_extra_backward = vec![0u32; local_extra + 1];
172    for cch_arc in 0..cch_arc_count {
173        if does_cch_arc_have_extra_input_arc.is_set(cch_arc as u64) {
174            let li = extra_mapper.to_local(cch_arc as u64) as usize;
175            first_extra_forward[li + 1] = ff[cch_arc + 1] - ff[cch_arc];
176            first_extra_backward[li + 1] = fb[cch_arc + 1] - fb[cch_arc];
177        }
178    }
179    for i in 0..local_extra {
180        first_extra_forward[i + 1] += first_extra_forward[i];
181        first_extra_backward[i + 1] += first_extra_backward[i];
182    }
183
184    OnDiskMapping {
185        is_input_arc_upward,
186        does_cch_arc_have_input_arc,
187        does_cch_arc_have_extra_input_arc,
188        forward_input_arc_of_cch: forward_local,
189        backward_input_arc_of_cch: backward_local,
190        first_extra_forward_input_arc_of_cch: first_extra_forward,
191        first_extra_backward_input_arc_of_cch: first_extra_backward,
192        extra_forward_input_arc_of_cch: cch.extra_forward_input_arc_of_cch.clone(),
193        extra_backward_input_arc_of_cch: cch.extra_backward_input_arc_of_cch.clone(),
194    }
195}
196
197/// A cursor over an in-memory `.cch-struct` byte buffer, with bounds-checked
198/// little-endian reads. Every read advances `pos` and returns `InvalidData`
199/// (never panics / reads out of bounds) when the buffer is too short — matching
200/// the defensive posture of [`crate::bundle::CchBundle::open`].
201struct StructReader<'a> {
202    bytes: &'a [u8],
203    pos: usize,
204}
205
206impl<'a> StructReader<'a> {
207    fn new(bytes: &'a [u8]) -> Self {
208        Self { bytes, pos: 0 }
209    }
210
211    /// Reads a little-endian `u64`.
212    fn read_u64(&mut self) -> io::Result<u64> {
213        // `self.pos <= self.bytes.len()` always (every advance is bounds-checked
214        // before it happens), so `pos + 8` is at most `len + 8` and cannot
215        // overflow usize on any supported platform; a saturating add keeps the
216        // bounds check sound without an unreachable overflow branch.
217        let end = self.pos.saturating_add(8);
218        if end > self.bytes.len() {
219            return Err(truncated_err("u64 field"));
220        }
221        let mut buf = [0u8; 8];
222        buf.copy_from_slice(&self.bytes[self.pos..end]);
223        self.pos = end;
224        Ok(u64::from_le_bytes(buf))
225    }
226
227    /// Reads a little-endian `u64` and converts it to `usize`.
228    ///
229    /// The crate targets 64-bit platforms (`usize == u64`), so the conversion is
230    /// total. A subsequent length/section check rejects any absurd value as
231    /// corrupt before it is used to index or allocate.
232    #[allow(clippy::cast_possible_truncation)] // 64-bit target: usize == u64
233    fn read_u64_usize(&mut self) -> io::Result<usize> {
234        Ok(self.read_u64()? as usize)
235    }
236
237    /// Reads a little-endian `u32`.
238    fn read_u32(&mut self) -> io::Result<u32> {
239        let end = self.pos.saturating_add(4);
240        if end > self.bytes.len() {
241            return Err(truncated_err("u32 field"));
242        }
243        let mut buf = [0u8; 4];
244        buf.copy_from_slice(&self.bytes[self.pos..end]);
245        self.pos = end;
246        Ok(u32::from_le_bytes(buf))
247    }
248
249    /// Reads a `write_sized_vector`-framed `Vec<u32>`: a `u64` byte length
250    /// followed by that many bytes (must be a multiple of 4), validating the
251    /// element count against the header-derived `expected_count` (mirrors the
252    /// C++ `read_sized_vector_exact`).
253    fn read_sized_vector(&mut self, label: &str, expected_count: usize) -> io::Result<Vec<u32>> {
254        self.read_sized_vector_inner(label, Some(expected_count))
255    }
256
257    /// Like [`Self::read_sized_vector`] but trusts the wire byte length without
258    /// an expected-count check (used for the data-dependent flat extra lists,
259    /// matching the C++ `read_sized_vector`).
260    fn read_sized_vector_any(&mut self, label: &str) -> io::Result<Vec<u32>> {
261        self.read_sized_vector_inner(label, None)
262    }
263
264    fn read_sized_vector_inner(
265        &mut self,
266        label: &str,
267        expected_count: Option<usize>,
268    ) -> io::Result<Vec<u32>> {
269        let byte_length = self.read_u64_usize()?;
270        if byte_length % 4 != 0 {
271            return Err(io::Error::new(
272                io::ErrorKind::InvalidData,
273                format!("section '{label}' byte_length {byte_length} is not a multiple of 4"),
274            ));
275        }
276        let count = byte_length / 4;
277        if let Some(expected) = expected_count {
278            // `expected` is a header-derived count that already fits usize; on a
279            // 32-bit target a 4x multiply could overflow, which we treat as a
280            // (corrupt) length disagreement via the != comparison below.
281            if expected.checked_mul(4) != Some(byte_length) {
282                return Err(io::Error::new(
283                    io::ErrorKind::InvalidData,
284                    format!(
285                        "section '{label}' byte_length {byte_length} does not match expected \
286                         4 * {expected}; header count and section length disagree"
287                    ),
288                ));
289            }
290        }
291        let end = self.pos.saturating_add(byte_length);
292        if end > self.bytes.len() {
293            return Err(truncated_err(label));
294        }
295        let mut v = Vec::with_capacity(count);
296        let mut off = self.pos;
297        for _ in 0..count {
298            let mut buf = [0u8; 4];
299            buf.copy_from_slice(&self.bytes[off..off + 4]);
300            v.push(u32::from_le_bytes(buf));
301            off += 4;
302        }
303        self.pos = end;
304        Ok(v)
305    }
306
307    /// Reads a `write_sized_bit_vector`-framed [`BitVector`]: a `u64` bit count,
308    /// a `u64` byte length (must equal the 512-block padding of the bit count),
309    /// then the word bytes. Validates the bit count against the header-derived
310    /// `expected_bits`.
311    #[allow(clippy::cast_possible_truncation)] // bit_count == expected_bits fits usize
312    fn read_sized_bit_vector(&mut self, label: &str, expected_bits: u64) -> io::Result<BitVector> {
313        let bit_count = self.read_u64()?;
314        if bit_count != expected_bits {
315            return Err(io::Error::new(
316                io::ErrorKind::InvalidData,
317                format!(
318                    "section '{label}' bit_count {bit_count} does not match expected \
319                     {expected_bits}"
320                ),
321            ));
322        }
323        let byte_length = self.read_u64_usize()?;
324        // 512-bit-block padding of `bit_count`. `bit_count == expected_bits`,
325        // a header count that fits usize, so this conversion cannot fail here.
326        let expected_byte_length = (byte_length_for_bits(bit_count)).unwrap_or(usize::MAX);
327        if byte_length != expected_byte_length {
328            return Err(io::Error::new(
329                io::ErrorKind::InvalidData,
330                format!(
331                    "section '{label}' bitvector byte_length {byte_length} does not match \
332                     expected {expected_byte_length} for bit_count {bit_count}"
333                ),
334            ));
335        }
336        let end = self.pos.saturating_add(byte_length);
337        if end > self.bytes.len() {
338            return Err(truncated_err(label));
339        }
340        // The on-disk blob is 512-bit-block padded; `BitVector` keeps only
341        // ceil(bit_count / 64) words. Read exactly that many words from the
342        // front of the blob (the writer wrote the real words first, then zero
343        // padding) and reconstruct the `BitVector` from them.
344        // `bit_count` fits usize (validated == expected_bits), so ceil/64 does too.
345        let word_count = (bit_count.div_ceil(64)) as usize;
346        let mut bv = BitVector::new(bit_count);
347        let mut off = self.pos;
348        for word_idx in 0..word_count {
349            let mut buf = [0u8; 8];
350            buf.copy_from_slice(&self.bytes[off..off + 8]);
351            let word = u64::from_le_bytes(buf);
352            // Set each set bit that is in range; padding bits in the final word
353            // are ignored (BitVector::new zeroed them and keeps them zero).
354            let base = (word_idx as u64) * 64;
355            let mut w = word;
356            while w != 0 {
357                let b = u64::from(w.trailing_zeros());
358                let global = base + b;
359                if global < bit_count {
360                    bv.set(global);
361                }
362                w &= w - 1;
363            }
364            off += 8;
365        }
366        self.pos = end;
367        Ok(bv)
368    }
369}
370
371/// `InvalidData` error for a truncated section.
372fn truncated_err(label: &str) -> io::Error {
373    io::Error::new(
374        io::ErrorKind::InvalidData,
375        format!("truncated .cch-struct at section '{label}'"),
376    )
377}
378
379/// 512-bit-block-padded byte length of a `bit_count`-bit `BitVector`
380/// (`= ceil(bit_count / 512) * 64`), or `None` if it does not fit `usize`.
381fn byte_length_for_bits(bit_count: u64) -> Option<usize> {
382    usize::try_from(bit_count.div_ceil(512))
383        .ok()
384        .and_then(|blocks| blocks.checked_mul(64))
385}
386
387/// Rebuilds a FULL-SIZE per-CCH-arc array (length `cch_arc_count`) from a
388/// LOCAL-id-compressed array indexed by `to_local` over `presence`. Entries for
389/// CCH arcs whose presence bit is clear are filled with [`INVALID_ID`].
390///
391/// This inverts the LOCAL compression `reconstruct_on_disk_mapping` performs
392/// for `forward_input_arc_of_cch` / `backward_input_arc_of_cch`.
393#[allow(clippy::cast_possible_truncation)] // cch_arc / local id < usize::MAX (CCH limit)
394fn expand_local_to_full(
395    presence: &BitVector,
396    mapper: &LocalIDMapper,
397    local: &[u32],
398    cch_arc_count: usize,
399) -> Vec<u32> {
400    let mut full = vec![INVALID_ID; cch_arc_count];
401    for (cch_arc, slot) in full.iter_mut().enumerate() {
402        if presence.is_set(cch_arc as u64) {
403            // `to_local(cch_arc) < local.len()` is guaranteed: `local` was read
404            // with an exact-count check against `mapper.local_id_count()`, and
405            // `mapper` is built from `presence`, so the rank is in range.
406            let li = mapper.to_local(cch_arc as u64) as usize;
407            *slot = local[li];
408        }
409    }
410    full
411}
412
413/// Rebuilds the FULL-SIZE extra-arc CSR offsets (length `cch_arc_count + 1`)
414/// from the LOCAL-id-compressed CSR (indexed by `to_local` over `presence`).
415///
416/// Inverts the extra-CSR re-indexing in `reconstruct_on_disk_mapping`: the flat
417/// extra lists are unchanged, only the per-arc offsets are expanded from the
418/// extra-local id space back to the full per-CCH-arc id space.
419#[allow(clippy::cast_possible_truncation)] // cch_arc / local id < usize::MAX (CCH limit)
420fn expand_extra_csr(
421    presence: &BitVector,
422    mapper: &LocalIDMapper,
423    first_local: &[u32],
424    cch_arc_count: usize,
425) -> io::Result<Vec<u32>> {
426    let mut full = vec![0u32; cch_arc_count + 1];
427    for cch_arc in 0..cch_arc_count {
428        let count = if presence.is_set(cch_arc as u64) {
429            // `le < local_extra` and `first_local.len() == local_extra + 1`, so
430            // both indices are in bounds (validated against the mapper's
431            // local_id_count when `first_local` was read).
432            let le = mapper.to_local(cch_arc as u64) as usize;
433            let (lo, hi) = (first_local[le], first_local[le + 1]);
434            // The on-disk CSR offsets are not otherwise validated to be
435            // monotonic; a corrupt file could make `hi < lo`.
436            hi.checked_sub(lo).ok_or_else(|| {
437                io::Error::new(
438                    io::ErrorKind::InvalidData,
439                    "extra CSR offsets are not monotonic",
440                )
441            })?
442        } else {
443            0
444        };
445        // The per-arc counts are disjoint adjacent deltas of the single
446        // monotonic `first_local` array, so the running sum telescopes to a
447        // prefix of `first_local` and is bounded by its (u32) maximum — it
448        // cannot overflow u32. A plain add is therefore total.
449        full[cch_arc + 1] = full[cch_arc] + count;
450    }
451    Ok(full)
452}
453
454impl Cch {
455    /// Writes this CCH structure to `path` in the `CCH_STRC` v1 format,
456    /// byte-identical to `RoutingKit`'s `cch_save_struct`.
457    ///
458    /// # Errors
459    /// Returns [`io::Error`] on any I/O failure (cannot create the file, write
460    /// error, or flush error).
461    pub fn save_struct(&self, path: &std::path::Path) -> io::Result<()> {
462        let file = std::fs::File::create(path)?;
463        let mut out = std::io::BufWriter::new(file);
464        self.write_struct(&mut out)?;
465        out.flush()
466    }
467
468    /// Serializes the struct to an arbitrary writer (used by [`Self::save_struct`]
469    /// and by in-memory round-trip tests).
470    fn write_struct<W: Write>(&self, out: &mut W) -> io::Result<()> {
471        let node_count = self.node_count() as u64;
472        let cch_arc_count = self.cch_arc_count() as u64;
473        let input_arc_count = self.input_arc_to_cch_arc.len() as u64;
474
475        write_u64(out, STRUCT_MAGIC)?;
476        write_u32(out, FORMAT_VERSION)?;
477        write_u32(out, 0)?; // reserved
478        write_u64(out, node_count)?;
479        write_u64(out, cch_arc_count)?;
480        write_u64(out, input_arc_count)?;
481
482        // Fixed-size sections (C++ cch_bundle.cc lines 129-138).
483        write_sized_vector(out, &self.order)?;
484        write_sized_vector(out, &self.rank)?;
485        write_sized_vector(out, &self.elimination_tree_parent)?;
486        write_sized_vector(out, &self.up_first_out)?;
487        write_sized_vector(out, &self.up_head)?;
488        write_sized_vector(out, &self.up_tail)?;
489        write_sized_vector(out, &self.down_first_out)?;
490        write_sized_vector(out, &self.down_head)?;
491        write_sized_vector(out, &self.down_to_up)?;
492        write_sized_vector(out, &self.input_arc_to_cch_arc)?;
493
494        // Reconstruct the C++ LOCAL-compressed mapping layout.
495        let m = reconstruct_on_disk_mapping(self);
496
497        write_sized_bit_vector(out, &m.is_input_arc_upward)?;
498        write_sized_bit_vector(out, &m.does_cch_arc_have_input_arc)?;
499        write_sized_bit_vector(out, &m.does_cch_arc_have_extra_input_arc)?;
500
501        write_sized_vector(out, &m.forward_input_arc_of_cch)?;
502        write_sized_vector(out, &m.backward_input_arc_of_cch)?;
503        write_sized_vector(out, &m.first_extra_forward_input_arc_of_cch)?;
504        write_sized_vector(out, &m.first_extra_backward_input_arc_of_cch)?;
505        write_sized_vector(out, &m.extra_forward_input_arc_of_cch)?;
506        write_sized_vector(out, &m.extra_backward_input_arc_of_cch)?;
507
508        Ok(())
509    }
510
511    /// Loads a CCH structure from a `.cch-struct` file at `path`, reconstructing
512    /// a fully RE-CUSTOMIZABLE [`Cch`] (symmetric to [`Self::save_struct`]).
513    ///
514    /// Unlike [`crate::bundle::CchBundle::open`] (which mmaps only the query
515    /// prefix), this reads ALL sections — including the input-arc → CCH-arc
516    /// mapping — and inverts the LOCAL-id-compressed on-disk representation back
517    /// into the FULL-SIZE arrays that [`Self::customize`] consumes. The returned
518    /// `Cch`'s internal mapping representation may differ from a freshly-built
519    /// one, but `customize` produces bit-identical output.
520    ///
521    /// Reads the C++ (`RoutingKit` `cch_save_struct`) format and the pure-Rust
522    /// writer's output interchangeably.
523    ///
524    /// # Errors
525    /// Returns [`io::Error`] on I/O failure, and [`io::ErrorKind::InvalidData`]
526    /// on a corrupt/truncated file (bad magic, unsupported version, mismatched
527    /// section lengths, …). Never panics or reads out of bounds on bad input.
528    pub fn load_struct(path: &std::path::Path) -> io::Result<Cch> {
529        let bytes = std::fs::read(path)?;
530        Self::read_struct(&bytes)
531    }
532
533    /// Parses an in-memory `.cch-struct` byte buffer into a re-customizable
534    /// [`Cch`]. Factored out of [`Self::load_struct`] so corrupt-input tests can
535    /// craft buffers directly without touching the filesystem.
536    #[allow(clippy::cast_possible_truncation)] // counts fit u32/usize (CCH limit), validated above
537    #[allow(clippy::too_many_lines)] // linear header + per-section read clearest inline
538    fn read_struct(bytes: &[u8]) -> io::Result<Cch> {
539        let mut r = StructReader::new(bytes);
540
541        let magic = r.read_u64()?;
542        if magic != STRUCT_MAGIC {
543            return Err(io::Error::new(
544                io::ErrorKind::InvalidData,
545                format!("bad magic in .cch-struct: {magic:#x}, expected {STRUCT_MAGIC:#x}"),
546            ));
547        }
548        let version = r.read_u32()?;
549        if version != FORMAT_VERSION {
550            return Err(io::Error::new(
551                io::ErrorKind::InvalidData,
552                format!("unsupported .cch-struct version {version}"),
553            ));
554        }
555        let _reserved = r.read_u32()?;
556        let node_count = r.read_u64_usize()?;
557        let cch_arc_count = r.read_u64_usize()?;
558        let input_arc_count = r.read_u64_usize()?;
559
560        // `node_count + 1` cannot overflow: `node_count` came from a u64 that
561        // fits usize, and a valid struct's node_count is far below usize::MAX.
562        let node_count_plus_1 = node_count.saturating_add(1);
563
564        // Fixed-size sections (header-derived counts, exact-checked).
565        let order = r.read_sized_vector("order", node_count)?;
566        let rank = r.read_sized_vector("rank", node_count)?;
567        let elimination_tree_parent = r.read_sized_vector("elimination_tree_parent", node_count)?;
568        let up_first_out = r.read_sized_vector("up_first_out", node_count_plus_1)?;
569        let up_head = r.read_sized_vector("up_head", cch_arc_count)?;
570        let up_tail = r.read_sized_vector("up_tail", cch_arc_count)?;
571        let down_first_out = r.read_sized_vector("down_first_out", node_count_plus_1)?;
572        let down_head = r.read_sized_vector("down_head", cch_arc_count)?;
573        let down_to_up = r.read_sized_vector("down_to_up", cch_arc_count)?;
574        let input_arc_to_cch_arc = r.read_sized_vector("input_arc_to_cch_arc", input_arc_count)?;
575
576        // BitVectors — they drive the LOCAL-id mapper sizes below.
577        let _is_input_arc_upward =
578            r.read_sized_bit_vector("is_input_arc_upward", input_arc_count as u64)?;
579        let does_cch_arc_have_input_arc =
580            r.read_sized_bit_vector("does_cch_arc_have_input_arc", cch_arc_count as u64)?;
581        let does_cch_arc_have_extra_input_arc =
582            r.read_sized_bit_vector("does_cch_arc_have_extra_input_arc", cch_arc_count as u64)?;
583
584        let in_mapper = LocalIDMapper::new(
585            does_cch_arc_have_input_arc.words(),
586            does_cch_arc_have_input_arc.len(),
587        );
588        let extra_mapper = LocalIDMapper::new(
589            does_cch_arc_have_extra_input_arc.words(),
590            does_cch_arc_have_extra_input_arc.len(),
591        );
592
593        // LOCAL-id-compressed mapping arrays (sized by mapper local_id_count).
594        // The local counts are popcounts of cch_arc_count-bit vectors, so they
595        // are <= cch_arc_count and fit usize; `+ 1` cannot overflow.
596        let local_in = in_mapper.local_id_count() as usize;
597        let local_extra = extra_mapper.local_id_count() as usize;
598        let local_extra_plus_1 = local_extra.saturating_add(1);
599
600        let forward_local = r.read_sized_vector("forward_input_arc_of_cch", local_in)?;
601        let backward_local = r.read_sized_vector("backward_input_arc_of_cch", local_in)?;
602        let first_extra_forward_local =
603            r.read_sized_vector("first_extra_forward_input_arc_of_cch", local_extra_plus_1)?;
604        let first_extra_backward_local =
605            r.read_sized_vector("first_extra_backward_input_arc_of_cch", local_extra_plus_1)?;
606        // The flat extra lists have data-dependent lengths; trust the wire
607        // byte_length (matching the C++ `read_sized_vector`).
608        let extra_forward_input_arc_of_cch =
609            r.read_sized_vector_any("extra_forward_input_arc_of_cch")?;
610        let extra_backward_input_arc_of_cch =
611            r.read_sized_vector_any("extra_backward_input_arc_of_cch")?;
612
613        // Invert the LOCAL compression back into the FULL-SIZE arrays.
614        let forward_input_arc_of_cch = expand_local_to_full(
615            &does_cch_arc_have_input_arc,
616            &in_mapper,
617            &forward_local,
618            cch_arc_count,
619        );
620        let backward_input_arc_of_cch = expand_local_to_full(
621            &does_cch_arc_have_input_arc,
622            &in_mapper,
623            &backward_local,
624            cch_arc_count,
625        );
626        let first_extra_forward_input_arc_of_cch = expand_extra_csr(
627            &does_cch_arc_have_extra_input_arc,
628            &extra_mapper,
629            &first_extra_forward_local,
630            cch_arc_count,
631        )?;
632        let first_extra_backward_input_arc_of_cch = expand_extra_csr(
633            &does_cch_arc_have_extra_input_arc,
634            &extra_mapper,
635            &first_extra_backward_local,
636            cch_arc_count,
637        )?;
638
639        // Final cross-check: the expanded CSR totals must match the flat extra
640        // list lengths (guards a corrupt CSR/extra-list pair that the per-arc
641        // checks alone would miss).
642        if first_extra_forward_input_arc_of_cch[cch_arc_count] as usize
643            != extra_forward_input_arc_of_cch.len()
644        {
645            return Err(io::Error::new(
646                io::ErrorKind::InvalidData,
647                "forward extra CSR total disagrees with extra list length",
648            ));
649        }
650        if first_extra_backward_input_arc_of_cch[cch_arc_count] as usize
651            != extra_backward_input_arc_of_cch.len()
652        {
653            return Err(io::Error::new(
654                io::ErrorKind::InvalidData,
655                "backward extra CSR total disagrees with extra list length",
656            ));
657        }
658
659        Ok(Cch {
660            rank,
661            order,
662            elimination_tree_parent,
663            up_first_out,
664            up_head,
665            up_tail,
666            down_first_out,
667            down_head,
668            down_to_up,
669            input_arc_to_cch_arc,
670            forward_input_arc_of_cch,
671            backward_input_arc_of_cch,
672            first_extra_forward_input_arc_of_cch,
673            extra_forward_input_arc_of_cch,
674            first_extra_backward_input_arc_of_cch,
675            extra_backward_input_arc_of_cch,
676        })
677    }
678}
679
680impl Metric {
681    /// Writes this customized metric to `path` in the `CCH_METR` v1 format,
682    /// byte-identical to `RoutingKit`'s `cch_save_metric`.
683    ///
684    /// # Errors
685    /// Returns [`io::Error`] on any I/O failure.
686    pub fn save(&self, path: &std::path::Path) -> io::Result<()> {
687        let file = std::fs::File::create(path)?;
688        let mut out = std::io::BufWriter::new(file);
689        self.write_metric(&mut out)?;
690        out.flush()
691    }
692
693    /// Serializes the metric to an arbitrary writer.
694    fn write_metric<W: Write>(&self, out: &mut W) -> io::Result<()> {
695        let cch_arc_count = self.forward.len() as u64;
696        write_u64(out, METRIC_MAGIC)?;
697        write_u32(out, FORMAT_VERSION)?;
698        write_u32(out, 0)?; // reserved
699        write_u64(out, cch_arc_count)?;
700        write_sized_vector(out, &self.forward)?;
701        write_sized_vector(out, &self.backward)?;
702        Ok(())
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use crate::INF_WEIGHT;
710    use crate::graph::Graph;
711
712    /// Build a CSR `Graph` from a directed arc multiset grouped by tail.
713    fn csr(node_count: u32, tail: &[u32], head: &[u32]) -> Graph {
714        let n = node_count as usize;
715        let mut degree = vec![0u32; n];
716        for &t in tail {
717            degree[t as usize] += 1;
718        }
719        let mut first_out = vec![0u32; n + 1];
720        for v in 0..n {
721            first_out[v + 1] = first_out[v] + degree[v];
722        }
723        let mut next: Vec<u32> = first_out[..n].to_vec();
724        let mut g_head = vec![0u32; head.len()];
725        for (&t, &h) in tail.iter().zip(head.iter()) {
726            let slot = next[t as usize] as usize;
727            g_head[slot] = h;
728            next[t as usize] += 1;
729        }
730        Graph {
731            first_out,
732            head: g_head,
733            weight: vec![1u32; head.len()],
734        }
735    }
736
737    /// Serialize a `Cch` to a `.cch-struct` byte buffer via the production
738    /// [`Cch::save_struct`] path (a tempfile), so the corrupt-input tests share
739    /// the exact bytes the writer emits without introducing a second generic
740    /// instantiation of the writer over an in-memory buffer.
741    fn to_bytes(c: &Cch) -> Vec<u8> {
742        let dir = tempfile::tempdir().expect("tempdir");
743        let path = dir.path().join("tmp.cch-struct");
744        c.save_struct(&path).expect("save_struct");
745        std::fs::read(&path).expect("read back struct bytes")
746    }
747
748    /// Assert that a round-trip through bytes preserves every structural array
749    /// and that `customize` is bit-identical for several weight vectors.
750    fn assert_round_trip(name: &str, c: &Cch, weight_sets: &[Vec<u32>]) {
751        let bytes = to_bytes(c);
752        let loaded = Cch::read_struct(&bytes).expect("read_struct");
753
754        // GATE 1: structural arrays + input_arc_to_cch_arc equal.
755        assert_eq!(loaded.rank, c.rank, "[{name}] rank");
756        assert_eq!(loaded.order, c.order, "[{name}] order");
757        assert_eq!(
758            loaded.elimination_tree_parent, c.elimination_tree_parent,
759            "[{name}] elim"
760        );
761        assert_eq!(loaded.up_first_out, c.up_first_out, "[{name}] up_first_out");
762        assert_eq!(loaded.up_head, c.up_head, "[{name}] up_head");
763        assert_eq!(loaded.up_tail, c.up_tail, "[{name}] up_tail");
764        assert_eq!(
765            loaded.down_first_out, c.down_first_out,
766            "[{name}] down_first_out"
767        );
768        assert_eq!(loaded.down_head, c.down_head, "[{name}] down_head");
769        assert_eq!(loaded.down_to_up, c.down_to_up, "[{name}] down_to_up");
770        assert_eq!(
771            loaded.input_arc_to_cch_arc, c.input_arc_to_cch_arc,
772            "[{name}] input_arc_to_cch_arc"
773        );
774
775        // GATE 2: re-customize bit-identical for every weight vector.
776        for (i, w) in weight_sets.iter().enumerate() {
777            let want = c.customize(w);
778            let got = loaded.customize(w);
779            assert_eq!(want.forward, got.forward, "[{name}] forward weights #{i}");
780            assert_eq!(
781                want.backward, got.backward,
782                "[{name}] backward weights #{i}"
783            );
784        }
785    }
786
787    #[test]
788    fn round_trip_path_identity() {
789        let n = 5u32;
790        let tail = vec![0u32, 1, 1, 2, 2, 3, 3, 4];
791        let head = vec![1u32, 0, 2, 1, 3, 2, 4, 3];
792        let c = Cch::build(&csr(n, &tail, &head), &(0..n).collect::<Vec<_>>());
793        let ws = vec![
794            vec![10u32, 11, 20, 21, 30, 31, 40, 41],
795            vec![1u32, 1, 1, 1, 1, 1, 1, 1],
796            vec![INF_WEIGHT, 5, INF_WEIGHT, 7, 9, INF_WEIGHT, 2, 3],
797        ];
798        assert_round_trip("path_identity", &c, &ws);
799    }
800
801    #[test]
802    fn round_trip_fillin_nonidentity_order() {
803        let n = 4u32;
804        let tail = vec![0u32, 0, 0, 1, 2, 3];
805        let head = vec![1u32, 2, 3, 0, 0, 0];
806        let order = vec![0u32, 1, 2, 3];
807        let c = Cch::build(&csr(n, &tail, &head), &order);
808        let ws = vec![vec![5u32, 7, 9, 6, 8, 10], vec![1u32, 2, 3, 4, 5, 6]];
809        assert_round_trip("fillin", &c, &ws);
810    }
811
812    #[test]
813    fn round_trip_parallel_arcs() {
814        // Non-empty extra lists (parallel arcs) + fill-in + non-identity order.
815        let n = 4u32;
816        let tail = vec![0u32, 0, 1, 1, 1, 2, 2, 3];
817        let head = vec![1u32, 1, 0, 0, 2, 1, 3, 2];
818        let order = vec![2u32, 0, 3, 1];
819        let c = Cch::build(&csr(n, &tail, &head), &order);
820        // Sanity: this fixture really exercises the extra (parallel) lists.
821        let extra_total =
822            c.extra_forward_input_arc_of_cch.len() + c.extra_backward_input_arc_of_cch.len();
823        assert!(
824            extra_total > 0,
825            "fixture must have parallel arcs in the extra lists"
826        );
827        let ws = vec![
828            vec![50u32, 9, 40, 8, 17, 18, 19, 20],
829            vec![9u32, 50, 8, 40, 1, 2, 3, 4],
830        ];
831        assert_round_trip("parallel_arcs", &c, &ws);
832    }
833
834    #[test]
835    fn round_trip_grid_block_boundary() {
836        // 24x24 grid → cch_arc_count > 512, exercising the bitvector 512-bit
837        // block padding in the round-trip.
838        let cols = 24u32;
839        let rows = 24u32;
840        let n = cols * rows;
841        let mut tail = Vec::new();
842        let mut head = Vec::new();
843        for r in 0..rows {
844            for c in 0..cols {
845                let v = r * cols + c;
846                if c + 1 < cols {
847                    tail.push(v);
848                    head.push(v + 1);
849                }
850                if c > 0 {
851                    tail.push(v);
852                    head.push(v - 1);
853                }
854                if r + 1 < rows {
855                    tail.push(v);
856                    head.push(v + cols);
857                }
858                if r > 0 {
859                    tail.push(v);
860                    head.push(v - cols);
861                }
862            }
863        }
864        let graph = csr(n, &tail, &head);
865        let order = crate::degree_order(&graph);
866        let c = Cch::build(&graph, &order);
867        assert!(c.cch_arc_count() > 512, "grid must exceed 512 CCH arcs");
868        #[allow(clippy::cast_possible_truncation)]
869        let w: Vec<u32> = (0..tail.len() as u32)
870            .map(|i| (i * 7 + 1) % 9973 + 1)
871            .collect();
872        assert_round_trip("grid_24x24", &c, &[w]);
873    }
874
875    #[test]
876    fn round_trip_empty_and_single_node() {
877        // Empty graph (no arcs), several isolated nodes.
878        let n = 4u32;
879        let c = Cch::build(&csr(n, &[], &[]), &(0..n).collect::<Vec<_>>());
880        assert_round_trip("empty_arcs", &c, &[vec![]]);
881
882        // Single isolated node.
883        let c = Cch::build(&csr(1, &[], &[]), &[0]);
884        assert_round_trip("single_node", &c, &[vec![]]);
885    }
886
887    // ---- Corrupt-input robustness: every error path returns InvalidData,
888    //      never panics or reads out of bounds. ----
889
890    /// A valid baseline `.cch-struct` byte buffer built from a fill-in graph
891    /// (so all sections — bitvectors, local arrays, extra CSR — are non-empty).
892    fn valid_struct_bytes() -> Vec<u8> {
893        let n = 4u32;
894        let tail = vec![0u32, 0, 1, 1, 1, 2, 2, 3];
895        let head = vec![1u32, 1, 0, 0, 2, 1, 3, 2];
896        let c = Cch::build(&csr(n, &tail, &head), &[0, 1, 2, 3]);
897        to_bytes(&c)
898    }
899
900    fn assert_invalid(bytes: &[u8], must_contain: &str) {
901        let err = Cch::read_struct(bytes).map(|_| ()).unwrap_err();
902        assert_eq!(
903            err.kind(),
904            io::ErrorKind::InvalidData,
905            "expected InvalidData, got: {err}"
906        );
907        assert!(
908            err.to_string().contains(must_contain),
909            "error '{err}' must contain '{must_contain}'"
910        );
911    }
912
913    #[test]
914    fn corrupt_baseline_is_valid() {
915        // The baseline used by the corrupt tests must itself load cleanly.
916        Cch::read_struct(&valid_struct_bytes()).expect("baseline must be valid");
917    }
918
919    #[test]
920    fn corrupt_empty_buffer_truncated() {
921        assert_invalid(&[], "truncated");
922    }
923
924    #[test]
925    fn corrupt_bad_magic() {
926        let mut b = valid_struct_bytes();
927        b[0..8].copy_from_slice(&0xDEAD_BEEF_DEAD_BEEFu64.to_le_bytes());
928        assert_invalid(&b, "bad magic");
929    }
930
931    #[test]
932    fn corrupt_bad_version() {
933        let mut b = valid_struct_bytes();
934        b[8..12].copy_from_slice(&2u32.to_le_bytes());
935        assert_invalid(&b, "unsupported .cch-struct version");
936    }
937
938    #[test]
939    fn corrupt_truncated_after_header() {
940        // Truncate to exactly the 40-byte header: the first section (order)
941        // length prefix cannot be read.
942        let b = valid_struct_bytes();
943        assert_invalid(&b[..40], "truncated");
944    }
945
946    #[test]
947    fn corrupt_truncated_mid_section() {
948        // Truncate so the 'order' section's declared bytes run past the buffer.
949        let b = valid_struct_bytes();
950        // header (40) + order length prefix (8) + 2 bytes of order data.
951        assert_invalid(&b[..50], "order");
952    }
953
954    #[test]
955    fn corrupt_inflated_node_count() {
956        // Inflate node_count so 'order' byte_length disagrees with 4 * count.
957        let mut b = valid_struct_bytes();
958        let nc = {
959            let mut buf = [0u8; 8];
960            buf.copy_from_slice(&b[16..24]);
961            u64::from_le_bytes(buf)
962        };
963        b[16..24].copy_from_slice(&(nc + 1_000_000).to_le_bytes());
964        assert_invalid(&b, "does not match expected");
965    }
966
967    #[test]
968    fn corrupt_node_count_absurd() {
969        // node_count = u64::MAX: the order section's real (small) byte_length
970        // cannot equal 4 * usize::MAX (the multiply saturates to None), so the
971        // section-length reconciliation rejects it without any allocation/OOB.
972        let mut b = valid_struct_bytes();
973        b[16..24].copy_from_slice(&u64::MAX.to_le_bytes());
974        assert_invalid(&b, "does not match expected");
975    }
976
977    #[test]
978    fn corrupt_order_byte_length_not_multiple_of_4() {
979        // Set the 'order' section byte_length to 1 (not a multiple of 4) and
980        // truncate the header counts to keep the prefix readable.
981        let mut b = valid_struct_bytes();
982        // node_count = 0 so the exact-count check would expect 0 bytes, but we
983        // want the "not a multiple of 4" branch: feed a non-multiple length and
984        // a matching expectation by inflating order alone is awkward; instead
985        // craft a minimal buffer with node_count=0 and an order len of 3.
986        b[16..24].copy_from_slice(&0u64.to_le_bytes()); // node_count = 0
987        b[24..32].copy_from_slice(&0u64.to_le_bytes()); // cch_arc_count = 0
988        b[32..40].copy_from_slice(&0u64.to_le_bytes()); // input_arc_count = 0
989        // Rebuild a fresh buffer: header (40) + order length=3 + 3 bytes.
990        let mut crafted = b[..40].to_vec();
991        crafted.extend_from_slice(&3u64.to_le_bytes());
992        crafted.extend_from_slice(&[0u8, 0, 0]);
993        assert_invalid(&crafted, "not a multiple of 4");
994    }
995
996    #[test]
997    fn corrupt_bitvector_byte_length_mismatch() {
998        // Corrupt the byte_length of the first bitvector (is_input_arc_upward)
999        // so it disagrees with the 512-block padding of its bit_count.
1000        //
1001        // Walk the sections to find the first bitvector's byte_length offset.
1002        let mut b = valid_struct_bytes();
1003        let off = first_bitvector_byte_length_offset(&b);
1004        // The bitvector framing is [u64 bit_count][u64 byte_length]; corrupt the
1005        // byte_length (second u64).
1006        b[off + 8..off + 16].copy_from_slice(&999u64.to_le_bytes());
1007        assert_invalid(&b, "bitvector byte_length");
1008    }
1009
1010    /// Reads a little-endian `u64` at `o` from `b` as a `usize` (test helper).
1011    fn read_u64_usize(b: &[u8], o: usize) -> usize {
1012        let mut buf = [0u8; 8];
1013        buf.copy_from_slice(&b[o..o + 8]);
1014        usize::try_from(u64::from_le_bytes(buf)).expect("offset fits usize in test")
1015    }
1016
1017    /// Walk the 10 fixed-size sized vectors after the 40-byte header and return
1018    /// the byte offset of the first bitvector's `bit_count` u64.
1019    fn first_bitvector_byte_length_offset(b: &[u8]) -> usize {
1020        let mut pos = 40usize;
1021        for _ in 0..10 {
1022            pos += 8 + read_u64_usize(b, pos);
1023        }
1024        pos
1025    }
1026
1027    #[test]
1028    fn corrupt_extra_csr_total_mismatch() {
1029        // Corrupt the LAST entry of first_extra_forward so the expanded CSR
1030        // total disagrees with the flat extra-forward list length. We truncate
1031        // the extra-forward list to trigger the cross-check.
1032        //
1033        // Simplest reliable trigger: append a fabricated buffer where the flat
1034        // extra-forward list is shortened. We rebuild from a parallel-arc
1035        // fixture and chop bytes off the extra_forward section.
1036        let n = 4u32;
1037        let tail = vec![0u32, 0, 1, 1, 1, 2, 2, 3];
1038        let head = vec![1u32, 1, 0, 0, 2, 1, 3, 2];
1039        let c = Cch::build(&csr(n, &tail, &head), &[0, 1, 2, 3]);
1040        assert!(
1041            !c.extra_forward_input_arc_of_cch.is_empty(),
1042            "need a non-empty forward extra list"
1043        );
1044        let mut b = to_bytes(&c);
1045        // Find the extra_forward section: it is the 5th sized vector after the
1046        // 3 bitvectors. Walk past header + 10 vectors + 3 bitvectors + 4 vectors.
1047        let off = extra_forward_byte_length_offset(&b);
1048        // Shrink its byte_length by 4 (drop one element) and drop the trailing
1049        // 4 data bytes so the buffer stays self-consistent up to that point.
1050        let cur = read_u64_usize(&b, off);
1051        assert!(cur >= 4);
1052        let shrunk = u64::try_from(cur - 4).expect("fits");
1053        b[off..off + 8].copy_from_slice(&shrunk.to_le_bytes());
1054        // Remove 4 bytes of data so subsequent parsing succeeds but the CSR
1055        // total no longer matches.
1056        b.drain(off + 8 + (cur - 4)..off + 8 + cur);
1057        assert_invalid(&b, "extra CSR total disagrees");
1058    }
1059
1060    /// Offset of the `extra_forward_input_arc_of_cch` section's `byte_length` u64.
1061    fn extra_forward_byte_length_offset(b: &[u8]) -> usize {
1062        let mut pos = 40usize;
1063        // 10 fixed-size sized vectors.
1064        for _ in 0..10 {
1065            pos += 8 + read_u64_usize(b, pos);
1066        }
1067        // 3 bitvectors: framing is [u64 bit_count][u64 byte_length][bytes].
1068        for _ in 0..3 {
1069            pos += 16 + read_u64_usize(b, pos + 8);
1070        }
1071        // 4 sized vectors (forward, backward, first_extra_forward, first_extra_backward).
1072        for _ in 0..4 {
1073            pos += 8 + read_u64_usize(b, pos);
1074        }
1075        // Now at extra_forward_input_arc_of_cch.
1076        pos
1077    }
1078
1079    /// Returns the start byte offset (the length-prefix position) of each of the
1080    /// 19 sections in a valid `.cch-struct`, in on-disk order.
1081    fn section_start_offsets(b: &[u8]) -> Vec<usize> {
1082        let mut starts = Vec::with_capacity(19);
1083        let mut pos = 40usize;
1084        // 10 fixed-size sized vectors.
1085        for _ in 0..10 {
1086            starts.push(pos);
1087            pos += 8 + read_u64_usize(b, pos);
1088        }
1089        // 3 bitvectors: [u64 bit_count][u64 byte_length][bytes].
1090        for _ in 0..3 {
1091            starts.push(pos);
1092            pos += 16 + read_u64_usize(b, pos + 8);
1093        }
1094        // 6 trailing sized vectors.
1095        for _ in 0..6 {
1096            starts.push(pos);
1097            pos += 8 + read_u64_usize(b, pos);
1098        }
1099        starts
1100    }
1101
1102    #[test]
1103    fn corrupt_truncate_at_each_section_start() {
1104        // Truncating the buffer to the start of each section makes that
1105        // section's length-prefix read fail with a truncation error, exercising
1106        // the `?` error path of every `read_sized_*` call in `read_struct`.
1107        let b = valid_struct_bytes();
1108        for (i, &start) in section_start_offsets(&b).iter().enumerate() {
1109            let truncated = &b[..start];
1110            let err = Cch::read_struct(truncated).map(|_| ()).unwrap_err();
1111            assert_eq!(
1112                err.kind(),
1113                io::ErrorKind::InvalidData,
1114                "section #{i} truncation must be InvalidData (got {err})"
1115            );
1116        }
1117    }
1118
1119    #[test]
1120    fn corrupt_truncated_u32_field() {
1121        // Truncate inside the version u32 (header bytes 8..12): `read_u32` must
1122        // return a truncation error rather than panic / read OOB.
1123        let b = valid_struct_bytes();
1124        assert_invalid(&b[..9], "truncated");
1125    }
1126
1127    #[test]
1128    fn corrupt_bitvector_bit_count_mismatch() {
1129        // Set the bit_count of the second bitvector (does_cch_arc_have_input_arc,
1130        // expected = cch_arc_count) to a value that keeps the byte_length valid
1131        // but disagrees with the header — exercising the expected-bits check.
1132        let mut b = valid_struct_bytes();
1133        let starts = section_start_offsets(&b);
1134        // starts[11] is does_cch_arc_have_input_arc (index 11 of 0-based).
1135        let off = starts[11];
1136        let real_bits = read_u64_usize(&b, off) as u64;
1137        assert!(real_bits > 0, "fixture must have CCH arcs");
1138        // A bit_count in the same 512-block keeps byte_length unchanged but is
1139        // != cch_arc_count. real_bits is small (< 512 here), so real_bits + 1
1140        // stays in the first block.
1141        b[off..off + 8].copy_from_slice(&(real_bits + 1).to_le_bytes());
1142        assert_invalid(&b, "does not match expected");
1143    }
1144
1145    #[test]
1146    fn corrupt_bitvector_data_truncated() {
1147        // Truncate the buffer partway into the first bitvector's DATA region:
1148        // the bit_count and byte_length framing are valid (they match the
1149        // header), but the declared data runs past the (now-short) buffer end,
1150        // exercising the data-truncation branch of `read_sized_bit_vector`.
1151        let b = valid_struct_bytes();
1152        let starts = section_start_offsets(&b);
1153        let off = starts[10]; // is_input_arc_upward (first bitvector)
1154        let byte_length = read_u64_usize(&b, off + 8);
1155        assert!(byte_length > 0, "bitvector must have data to truncate");
1156        // Keep the 16-byte framing + 8 bytes of data, drop the rest.
1157        let cut = off + 16 + 8;
1158        assert!(cut < off + 16 + byte_length, "must truncate mid-data");
1159        assert_invalid(&b[..cut], "is_input_arc_upward");
1160    }
1161
1162    #[test]
1163    fn corrupt_extra_csr_non_monotonic() {
1164        // Make a first_extra_forward CSR offset decrease (hi < lo) so
1165        // `expand_extra_csr` reports a non-monotonic CSR.
1166        let n = 4u32;
1167        let tail = vec![0u32, 0, 1, 1, 1, 2, 2, 3];
1168        let head = vec![1u32, 1, 0, 0, 2, 1, 3, 2];
1169        let c = Cch::build(&csr(n, &tail, &head), &[0, 1, 2, 3]);
1170        assert!(
1171            !c.extra_forward_input_arc_of_cch.is_empty(),
1172            "need a non-empty forward extra list"
1173        );
1174        let mut b = to_bytes(&c);
1175        let starts = section_start_offsets(&b);
1176        // starts[15] = first_extra_forward_input_arc_of_cch. Its data starts at
1177        // starts[15] + 8; set its FIRST element to a large value so the first
1178        // present extra arc computes hi < lo.
1179        let data = starts[15] + 8;
1180        b[data..data + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
1181        assert_invalid(&b, "not monotonic");
1182    }
1183
1184    #[test]
1185    fn corrupt_backward_extra_csr_non_monotonic() {
1186        // Symmetric to corrupt_extra_csr_non_monotonic but for the BACKWARD
1187        // first-extra CSR, covering the backward `expand_extra_csr` call.
1188        let n = 4u32;
1189        let tail = vec![0u32, 0, 1, 1, 1, 2, 2, 3];
1190        let head = vec![1u32, 1, 0, 0, 2, 1, 3, 2];
1191        let c = Cch::build(&csr(n, &tail, &head), &[0, 1, 2, 3]);
1192        assert!(
1193            !c.extra_backward_input_arc_of_cch.is_empty(),
1194            "need a non-empty backward extra list"
1195        );
1196        let mut b = to_bytes(&c);
1197        let starts = section_start_offsets(&b);
1198        // starts[16] = first_extra_backward_input_arc_of_cch.
1199        let data = starts[16] + 8;
1200        b[data..data + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
1201        assert_invalid(&b, "not monotonic");
1202    }
1203
1204    #[test]
1205    fn corrupt_backward_extra_csr_total_mismatch() {
1206        // Symmetric to corrupt_extra_csr_total_mismatch but for the BACKWARD
1207        // extra list, covering the backward cross-check branch.
1208        let n = 4u32;
1209        let tail = vec![0u32, 0, 1, 1, 1, 2, 2, 3];
1210        let head = vec![1u32, 1, 0, 0, 2, 1, 3, 2];
1211        let c = Cch::build(&csr(n, &tail, &head), &[0, 1, 2, 3]);
1212        assert!(
1213            !c.extra_backward_input_arc_of_cch.is_empty(),
1214            "need a non-empty backward extra list"
1215        );
1216        let mut b = to_bytes(&c);
1217        let starts = section_start_offsets(&b);
1218        // starts[18] = extra_backward_input_arc_of_cch (the last section).
1219        let off = starts[18];
1220        let cur = read_u64_usize(&b, off);
1221        assert!(cur >= 4);
1222        let shrunk = u64::try_from(cur - 4).expect("fits");
1223        b[off..off + 8].copy_from_slice(&shrunk.to_le_bytes());
1224        b.drain(off + 8 + (cur - 4)..off + 8 + cur);
1225        assert_invalid(&b, "backward extra CSR total disagrees");
1226    }
1227}