Skip to main content

hurray_inspect/
main.rs

1//! `hurray-inspect` — CLI tool for inspecting Hurray files.
2//!
3//! # Usage
4//!
5//! ```text
6//! hurray-inspect [--data[=hex|numpy]] <file>
7//! hurray-inspect -                           # read from stdin
8//! ```
9//!
10//! Auto-detects the file type:
11//!
12//! - **HRRYFILE container** (magic `HRRYFILE`): displays the file header,
13//!   trailer, index, KV metadata section, and each tensor's descriptor.
14//! - **Raw tensor descriptor** (magic `HRRY`): displays the descriptor fields.
15//!
16//! With `--data` (or `--data=numpy`), tensor buffer values are printed after each
17//! descriptor in NumPy-style nested bracket notation.  With `--data=hex`, the raw
18//! buffer bytes are printed as a hex table.  For raw descriptors the buffer bytes
19//! must immediately follow the descriptor in the same file.
20
21use std::{
22    env, fs,
23    io::{self, Read},
24    process,
25};
26
27use half::{bf16, f16};
28
29use hurray_core::{
30    descriptor::{MemberRole, TensorDescriptor},
31    layout::{
32        BlockTableIndexType, CombineOp, CompositionRule, KvRole, LayoutDescriptor, TiledLayout,
33    },
34    QuantizationDescriptor, DYNAMIC,
35};
36
37// ── Error type ─────────────────────────────────────────────────────────────────
38
39#[derive(Debug, thiserror::Error)]
40enum Error {
41    #[error("I/O error: {0}")]
42    Io(#[from] io::Error),
43    #[error("{0}")]
44    Parse(#[from] hurray_core::Error),
45    #[error("malformed HRRYFILE: {0}")]
46    Hrryfile(String),
47    #[error("usage: hurray-inspect [--data[=hex|numpy]] <file>  (use '-' for stdin)")]
48    Usage,
49}
50
51type Result<T> = std::result::Result<T, Error>;
52
53// ── Table row ──────────────────────────────────────────────────────────────────
54
55/// A single row in the output hex table.
56struct Row {
57    /// Byte offset of the first byte of this field within the file.
58    offset: usize,
59    /// Raw bytes of this field (empty for annotation-only rows).
60    bytes: Vec<u8>,
61    /// Human-readable field description.
62    field: String,
63}
64
65// ── HRRYFILE format constants ──────────────────────────────────────────────────
66
67const FILE_MAGIC: &[u8; 8] = b"HRRYFILE";
68const FILE_HEADER_SIZE: usize = 64;
69const TRAILER_SIZE: usize = 40;
70
71const FLAG_HAS_KV_METADATA: u32 = 1 << 0;
72const FLAG_SORTED_INDEX: u32 = 1 << 1;
73const FLAG_HAS_INDEX_CRC32C: u32 = 1 << 2;
74
75fn file_flags_display(flags: u32) -> String {
76    let mut parts = Vec::new();
77    if flags & FLAG_HAS_KV_METADATA != 0 {
78        parts.push("HAS_KV_METADATA");
79    }
80    if flags & FLAG_SORTED_INDEX != 0 {
81        parts.push("SORTED_INDEX");
82    }
83    if flags & FLAG_HAS_INDEX_CRC32C != 0 {
84        parts.push("HAS_INDEX_CRC32C");
85    }
86    let reserved = flags & !0x07u32;
87    let base = format!("0x{flags:08X}");
88    let mut suffix = parts.join(" | ");
89    if reserved != 0 {
90        if !suffix.is_empty() {
91            suffix.push_str(&format!(" | reserved=0x{reserved:08X}"));
92        } else {
93            suffix = format!("reserved=0x{reserved:08X}");
94        }
95    }
96    if suffix.is_empty() {
97        base
98    } else {
99        format!("{base} ({suffix})")
100    }
101}
102
103fn kv_tag_name(tag: u8) -> &'static str {
104    match tag {
105        0x01 => "string",
106        0x02 => "int64",
107        0x03 => "uint64",
108        0x04 => "float64",
109        0x05 => "bool",
110        0x06 => "bytes",
111        0x07 => "array",
112        _ => "unknown",
113    }
114}
115
116// ── LE decode helpers ──────────────────────────────────────────────────────────
117
118fn le_u8(b: &[u8]) -> u8 {
119    b[0]
120}
121fn le_u16(b: &[u8]) -> u16 {
122    // SAFETY: take() guarantees exactly 2 bytes
123    u16::from_le_bytes(b[..2].try_into().unwrap())
124}
125fn le_u32(b: &[u8]) -> u32 {
126    // SAFETY: take() guarantees exactly 4 bytes
127    u32::from_le_bytes(b[..4].try_into().unwrap())
128}
129fn le_u64(b: &[u8]) -> u64 {
130    // SAFETY: take() guarantees exactly 8 bytes
131    u64::from_le_bytes(b[..8].try_into().unwrap())
132}
133fn le_i64(b: &[u8]) -> i64 {
134    // SAFETY: take() guarantees exactly 8 bytes
135    i64::from_le_bytes(b[..8].try_into().unwrap())
136}
137fn le_f64(b: &[u8]) -> f64 {
138    // SAFETY: take() guarantees exactly 8 bytes
139    f64::from_le_bytes(b[..8].try_into().unwrap())
140}
141
142// ── Data display mode ──────────────────────────────────────────────────────────
143
144#[derive(Clone, Copy, Debug, PartialEq)]
145enum DataMode {
146    None,
147    Hex,
148    Numpy,
149}
150
151// ── Value formatting ───────────────────────────────────────────────────────────
152
153/// Format a single float as a string, adding a trailing `.` when the value
154/// has no fractional part (matching NumPy's display convention).
155fn format_float(v: f64) -> String {
156    if v.is_nan() {
157        return "nan".into();
158    }
159    if v.is_infinite() {
160        return if v > 0.0 { "inf".into() } else { "-inf".into() };
161    }
162    let s = format!("{v}");
163    if s.contains('.') || s.contains('e') || s.contains('E') {
164        s
165    } else {
166        format!("{s}.")
167    }
168}
169
170/// Decode a fixed-width little-endian integer at element index `idx`.
171macro_rules! read_le {
172    ($data:expr, $idx:expr, $n:expr, $ty:ty) => {{
173        let i = ($idx * $n) as usize;
174        if i + $n <= $data.len() {
175            <$ty>::from_le_bytes($data[i..i + $n].try_into().unwrap()).to_string()
176        } else {
177            "?".into()
178        }
179    }};
180}
181
182/// Extracts a power-of-two sub-byte code (`bits` ∈ {1, 2, 4}) at element `idx`, LSB-first.
183fn read_pow2_code(data: &[u8], idx: u64, bits: u8) -> Option<u32> {
184    let per_byte = 8 / bits as u64;
185    let byte_i = (idx / per_byte) as usize;
186    let shift = ((idx % per_byte) * bits as u64) as u32;
187    let mask = (1u32 << bits) - 1;
188    data.get(byte_i).map(|&b| (b as u32 >> shift) & mask)
189}
190
191/// Extracts a 6-bit code at element `idx` from the 4-elements-per-3-bytes LSB-first packing
192/// (`element-types.md` § 6-bit packing).
193fn read_f6_code(data: &[u8], idx: u64) -> Option<u32> {
194    let g = (idx / 4) as usize * 3;
195    let (b0, b1, b2) = (
196        *data.get(g)? as u32,
197        *data.get(g + 1).unwrap_or(&0) as u32,
198        *data.get(g + 2).unwrap_or(&0) as u32,
199    );
200    Some(match idx % 4 {
201        0 => b0 & 0x3F,
202        1 => (b0 >> 6) | ((b1 & 0x0F) << 2),
203        2 => (b1 >> 4) | ((b2 & 0x03) << 4),
204        _ => b2 >> 2,
205    })
206}
207
208/// Sign-extends a `bits`-wide two's-complement `code` to `i64`.
209fn sign_extend(code: u32, bits: u32) -> i64 {
210    let shift = 64 - bits;
211    ((code as i64) << shift) >> shift
212}
213
214/// Decodes an OCP micro-float `code` (sign|exponent|mantissa, LSB→MSB) to `f64`.
215///
216/// `has_inf`: all-ones exponent is infinity (mantissa 0) or NaN (e5m2). `nan_at_max`:
217/// all-ones exponent AND mantissa is NaN but other max-exponent patterns are normal (e4m3).
218/// When neither is set (e2m1, float6) the max exponent is an ordinary normal value.
219fn micro_float(
220    code: u32,
221    exp_bits: u32,
222    man_bits: u32,
223    bias: i32,
224    has_inf: bool,
225    nan_at_max: bool,
226) -> f64 {
227    let sign = if (code >> (exp_bits + man_bits)) & 1 == 1 {
228        -1.0
229    } else {
230        1.0
231    };
232    let exp = (code >> man_bits) & ((1 << exp_bits) - 1);
233    let man_mask = (1u32 << man_bits) - 1;
234    let man = code & man_mask;
235    let max_exp = (1u32 << exp_bits) - 1;
236    let man_div = (1u64 << man_bits) as f64;
237    if exp == max_exp && has_inf {
238        return if man == 0 {
239            sign * f64::INFINITY
240        } else {
241            f64::NAN
242        };
243    }
244    if exp == max_exp && nan_at_max && man == man_mask {
245        return f64::NAN;
246    }
247    if exp == 0 {
248        sign * (man as f64 / man_div) * 2f64.powi(1 - bias) // subnormal / zero
249    } else {
250        sign * (1.0 + man as f64 / man_div) * 2f64.powi(exp as i32 - bias) // normal
251    }
252}
253
254/// Decodes a `float8_e8m0` byte (power-of-two scale; `0x00`/`0xFF` reserved → NaN).
255fn e8m0(byte: u8) -> f64 {
256    if byte == 0x00 || byte == 0xFF {
257        f64::NAN
258    } else {
259        2f64.powi(byte as i32 - 127)
260    }
261}
262
263/// Decode and format the element at logical index `idx` from `data`.
264///
265/// Complex types are formatted as `(real+imagj)`; `float128` (no stable Rust type) and any
266/// unknown/private type are shown as raw hex.
267fn format_scalar(et: hurray_core::ElementType, data: &[u8], idx: u64) -> String {
268    use hurray_core::ElementType::*;
269
270    match et {
271        Bool => {
272            let byte = (idx / 8) as usize;
273            let bit = (idx % 8) as u8;
274            if byte < data.len() {
275                if (data[byte] >> bit) & 1 == 1 {
276                    "True".into()
277                } else {
278                    "False".into()
279                }
280            } else {
281                "?".into()
282            }
283        }
284        Int8 => {
285            let i = idx as usize;
286            if i < data.len() {
287                (data[i] as i8).to_string()
288            } else {
289                "?".into()
290            }
291        }
292        Uint8 => {
293            let i = idx as usize;
294            if i < data.len() {
295                data[i].to_string()
296            } else {
297                "?".into()
298            }
299        }
300        Int16 => read_le!(data, idx, 2, i16),
301        Uint16 => read_le!(data, idx, 2, u16),
302        Int32 => read_le!(data, idx, 4, i32),
303        Uint32 => read_le!(data, idx, 4, u32),
304        Int64 => read_le!(data, idx, 8, i64),
305        Uint64 => read_le!(data, idx, 8, u64),
306        Float16 => {
307            let i = (idx * 2) as usize;
308            if i + 2 <= data.len() {
309                let bits = u16::from_le_bytes(data[i..i + 2].try_into().unwrap());
310                format_float(f16::from_bits(bits).to_f64())
311            } else {
312                "?".into()
313            }
314        }
315        BFloat16 => {
316            let i = (idx * 2) as usize;
317            if i + 2 <= data.len() {
318                let bits = u16::from_le_bytes(data[i..i + 2].try_into().unwrap());
319                format_float(bf16::from_bits(bits).to_f64())
320            } else {
321                "?".into()
322            }
323        }
324        Float32 => {
325            let i = (idx * 4) as usize;
326            if i + 4 <= data.len() {
327                format_float(f32::from_le_bytes(data[i..i + 4].try_into().unwrap()) as f64)
328            } else {
329                "?".into()
330            }
331        }
332        Float64 => {
333            let i = (idx * 8) as usize;
334            if i + 8 <= data.len() {
335                format_float(f64::from_le_bytes(data[i..i + 8].try_into().unwrap()))
336            } else {
337                "?".into()
338            }
339        }
340        Complex64 => {
341            let i = (idx * 8) as usize;
342            if i + 8 <= data.len() {
343                let re = f32::from_le_bytes(data[i..i + 4].try_into().unwrap());
344                let im = f32::from_le_bytes(data[i + 4..i + 8].try_into().unwrap());
345                format!("({}+{}j)", format_float(re as f64), format_float(im as f64))
346            } else {
347                "?".into()
348            }
349        }
350        Complex128 => {
351            let i = (idx * 16) as usize;
352            if i + 16 <= data.len() {
353                let re = f64::from_le_bytes(data[i..i + 8].try_into().unwrap());
354                let im = f64::from_le_bytes(data[i + 8..i + 16].try_into().unwrap());
355                format!("({}+{}j)", format_float(re), format_float(im))
356            } else {
357                "?".into()
358            }
359        }
360        // Sub-byte integers (LSB-first packing).
361        Int4 => read_pow2_code(data, idx, 4).map_or("?".into(), |c| sign_extend(c, 4).to_string()),
362        Uint4 => read_pow2_code(data, idx, 4).map_or("?".into(), |c| c.to_string()),
363        Int2 => read_pow2_code(data, idx, 2).map_or("?".into(), |c| sign_extend(c, 2).to_string()),
364        Uint2 => read_pow2_code(data, idx, 2).map_or("?".into(), |c| c.to_string()),
365
366        // Tier 2 micro-floats (OCP OFP8 / MX).
367        Float8E4M3 => data.get(idx as usize).map_or("?".into(), |&b| {
368            format_float(micro_float(b as u32, 4, 3, 7, false, true))
369        }),
370        Float8E5M2 => data.get(idx as usize).map_or("?".into(), |&b| {
371            format_float(micro_float(b as u32, 5, 2, 15, true, false))
372        }),
373        Float8E8M0 => data
374            .get(idx as usize)
375            .map_or("?".into(), |&b| format_float(e8m0(b))),
376        Float4E2M1 => read_pow2_code(data, idx, 4).map_or("?".into(), |c| {
377            format_float(micro_float(c, 2, 1, 1, false, false))
378        }),
379        Float6E2M3 => read_f6_code(data, idx).map_or("?".into(), |c| {
380            format_float(micro_float(c, 2, 3, 1, false, false))
381        }),
382        Float6E3M2 => read_f6_code(data, idx).map_or("?".into(), |c| {
383            format_float(micro_float(c, 3, 2, 3, false, false))
384        }),
385
386        // float128 (no stable Rust type) and any unknown/private type: raw hex bytes.
387        _ => {
388            let bw = et.bit_width();
389            if bw == 0 || bw >= 8 {
390                let n = bw.div_ceil(8).max(1) as usize;
391                let i = (idx * n as u64) as usize;
392                if i + n <= data.len() {
393                    hex_str(&data[i..i + n])
394                } else {
395                    "?".into()
396                }
397            } else {
398                // Sub-byte: decode the packed nibble/bits for this element.
399                let elements_per_byte = 8 / bw as u64;
400                let byte_i = (idx / elements_per_byte) as usize;
401                let bit_off = ((idx % elements_per_byte) * bw as u64) as u8;
402                let mask = (1u8 << bw) - 1;
403                if byte_i < data.len() {
404                    let raw = (data[byte_i] >> bit_off) & mask;
405                    format!("0x{raw:01X}")
406                } else {
407                    "?".into()
408                }
409            }
410        }
411    }
412}
413
414/// Maximum number of elements shown before truncation (matches NumPy default).
415const NUMPY_THRESHOLD: u64 = 1000;
416
417/// Format tensor data as a NumPy-style nested-bracket string.
418///
419/// Elements beyond `NUMPY_THRESHOLD` are elided with `...`.
420fn format_numpy(et: hurray_core::ElementType, shape: &[u64], data: &[u8]) -> String {
421    let total: u64 = if shape.is_empty() {
422        1
423    } else {
424        shape.iter().product()
425    };
426
427    if total == 0 {
428        return "[]".to_string();
429    }
430
431    if shape.is_empty() {
432        return format_scalar(et, data, 0);
433    }
434
435    if total > NUMPY_THRESHOLD {
436        let head = 3u64.min(total);
437        let tail = 3u64.min(total - head);
438        let mut parts: Vec<String> = (0..head).map(|i| format_scalar(et, data, i)).collect();
439        parts.push("...".into());
440        for i in (total - tail)..total {
441            parts.push(format_scalar(et, data, i));
442        }
443        return format!("[{}]  # shape={shape:?}, {total} elements", parts.join(" "));
444    }
445
446    format_numpy_dim(et, shape, data, 0, 0)
447}
448
449/// Recursively build nested-bracket notation for dimension `depth`.
450fn format_numpy_dim(
451    et: hurray_core::ElementType,
452    shape: &[u64],
453    data: &[u8],
454    depth: usize,
455    offset: u64,
456) -> String {
457    let ndim = shape.len();
458
459    if depth == ndim - 1 {
460        // Innermost dimension: single row of space-separated values.
461        let parts: Vec<String> = (0..shape[depth])
462            .map(|i| format_scalar(et, data, offset + i))
463            .collect();
464        return format!("[{}]", parts.join(" "));
465    }
466
467    let stride: u64 = shape[depth + 1..].iter().product();
468    let parts: Vec<String> = (0..shape[depth])
469        .map(|i| format_numpy_dim(et, shape, data, depth + 1, offset + i * stride))
470        .collect();
471
472    // Between groups: blank line for all but the two innermost dims (numpy style).
473    let indent = " ".repeat(depth + 1);
474    let sep = if ndim - depth > 2 {
475        format!("\n\n{indent}")
476    } else {
477        format!("\n{indent}")
478    };
479    format!("[{}]", parts.join(&sep))
480}
481
482// ── FReader — sequential cursor over a file byte slice ────────────────────────
483
484/// Sequential byte reader that tracks absolute file offsets.
485struct FReader<'a> {
486    data: &'a [u8],
487    pos: usize,
488}
489
490impl<'a> FReader<'a> {
491    fn new(data: &'a [u8]) -> Self {
492        Self { data, pos: 0 }
493    }
494
495    fn seek_to(&mut self, pos: usize) {
496        self.pos = pos;
497    }
498
499    /// Consume `n` bytes and return `(absolute_offset, owned_bytes)`.
500    fn take(&mut self, n: usize) -> Result<(usize, Vec<u8>)> {
501        let offset = self.pos;
502        let end = offset + n;
503        if end > self.data.len() {
504            return Err(Error::Hrryfile(format!(
505                "unexpected end of file at offset {offset} \
506                 (need {n} bytes, have {})",
507                self.data.len().saturating_sub(offset)
508            )));
509        }
510        let bytes = self.data[offset..end].to_vec();
511        self.pos = end;
512        Ok((offset, bytes))
513    }
514
515    fn row(&mut self, n: usize, field: String) -> Result<Row> {
516        let (offset, bytes) = self.take(n)?;
517        Ok(Row {
518            offset,
519            bytes,
520            field,
521        })
522    }
523}
524
525// ── Reader — cursor for raw TensorDescriptor slices ───────────────────────────
526
527/// Cursor over a byte slice bounded to `limit` bytes.
528///
529/// `base_offset` shifts all reported row offsets so they reflect the field's
530/// absolute position within the containing file (0 for standalone descriptors).
531struct Reader<'a> {
532    data: &'a [u8],
533    pos: usize,
534    limit: usize,
535    base_offset: usize,
536}
537
538impl<'a> Reader<'a> {
539    fn with_base(data: &'a [u8], limit: usize, base_offset: usize) -> Self {
540        Self {
541            data,
542            pos: 0,
543            limit,
544            base_offset,
545        }
546    }
547
548    /// Consume `n` bytes and return a display [`Row`] labelled with `field`.
549    fn take_row(&mut self, n: usize, field: String) -> Row {
550        let offset = self.pos;
551        let end = (self.pos + n).min(self.limit).min(self.data.len());
552        let bytes = self.data[offset..end].to_vec();
553        self.pos = end;
554        Row {
555            offset: self.base_offset + offset,
556            bytes,
557            field,
558        }
559    }
560
561    /// Advance to `target`, emitting a row for any skipped bytes.
562    fn skip_to(&mut self, target: usize) -> Option<Row> {
563        if target <= self.pos || target > self.limit || target > self.data.len() {
564            return None;
565        }
566        let offset = self.pos;
567        let bytes = self.data[self.pos..target].to_vec();
568        self.pos = target;
569        Some(Row {
570            offset: self.base_offset + offset,
571            bytes,
572            field: "(unknown / padding bytes)".to_string(),
573        })
574    }
575}
576
577// ── Layout tag name ─────────────────────────────────────────────────────────────
578
579fn layout_tag_name(tag: u8) -> &'static str {
580    match tag {
581        0x01 => "row-major",
582        0x02 => "column-major",
583        0x03 => "strided",
584        0x04 => "tiled",
585        0x05 => "morton",
586        0x06 => "COO",
587        0x07 => "CSR",
588        0x08 => "CSC",
589        0x09 => "CSF",
590        0x0A => "block-paged",
591        0x0B => "composite",
592        0x40 => "hilbert",
593        0xF0..=0xFE => "private-extension",
594        _ => "unknown",
595    }
596}
597
598// ── Quantization descriptor rows ─────────────────────────────────────────────────
599
600fn quant_scheme_name(tag: u8) -> &'static str {
601    match tag {
602        0x01 => "per-tensor-affine",
603        0x02 => "per-channel-affine",
604        0x03 => "per-block-affine",
605        0x04 => "NF4",
606        0x05 => "MXFP",
607        _ => "unknown",
608    }
609}
610
611/// Renders the decoded quantization descriptor: the 4-byte header plus the per-scheme fields
612/// (byte-accurate to `quantization/*.md`).
613fn quant_rows(reader: &mut Reader<'_>, qd: &QuantizationDescriptor) -> Vec<Row> {
614    let tag = qd.scheme_tag().tag();
615    let mut rows = vec![
616        reader.take_row(
617            1,
618            format!("scheme_tag = 0x{tag:02X} ({})", quant_scheme_name(tag)),
619        ),
620        reader.take_row(1, "scheme_version".to_string()),
621        reader.take_row(2, "quant.flags".to_string()),
622    ];
623    let zp = |z: Option<u32>| match z {
624        Some(x) => x.to_string(),
625        None => "none (symmetric)".to_string(),
626    };
627    match qd {
628        QuantizationDescriptor::PerTensorAffine(x) => {
629            rows.push(reader.take_row(4, format!("scale = {}", x.scale())));
630            rows.push(reader.take_row(4, format!("zero_point = {}", x.zero_point())));
631            rows.push(reader.take_row(4, "quant._reserved".to_string()));
632        }
633        QuantizationDescriptor::PerChannelAffine(x) => {
634            rows.push(reader.take_row(4, format!("axis = {}", x.axis())));
635            rows.push(reader.take_row(
636                4,
637                format!("scale_buffer_index = {}", x.scale_buffer_index()),
638            ));
639            rows.push(reader.take_row(
640                4,
641                format!(
642                    "zero_point_buffer_index = {}",
643                    zp(x.zero_point_buffer_index())
644                ),
645            ));
646            rows.push(reader.take_row(1, format!("scale_type = {}", x.scale_type())));
647            rows.push(reader.take_row(3, "quant._reserved".to_string()));
648        }
649        QuantizationDescriptor::PerBlockAffine(x) => {
650            rows.push(reader.take_row(4, format!("axis = {}", x.axis())));
651            rows.push(reader.take_row(4, format!("block_size = {}", x.block_size())));
652            rows.push(reader.take_row(
653                4,
654                format!("scale_buffer_index = {}", x.scale_buffer_index()),
655            ));
656            rows.push(reader.take_row(
657                4,
658                format!(
659                    "zero_point_buffer_index = {}",
660                    zp(x.zero_point_buffer_index())
661                ),
662            ));
663            rows.push(reader.take_row(1, "scale_type_tag".to_string()));
664            rows.push(reader.take_row(3, "quant._reserved".to_string()));
665        }
666        QuantizationDescriptor::Nf4(x) => {
667            rows.push(reader.take_row(4, format!("axis = {}", x.axis())));
668            rows.push(reader.take_row(4, format!("block_size = {}", x.block_size())));
669            rows.push(reader.take_row(
670                4,
671                format!("scale_buffer_index = {}", x.scale_buffer_index()),
672            ));
673        }
674        QuantizationDescriptor::Mxfp(x) => {
675            rows.push(reader.take_row(4, format!("axis = {}", x.axis())));
676            rows.push(reader.take_row(4, format!("block_size = {}", x.block_size())));
677            rows.push(reader.take_row(
678                4,
679                format!("scale_buffer_index = {}", x.scale_buffer_index()),
680            ));
681        }
682    }
683    rows
684}
685
686// ── Layout-specific rows ───────────────────────────────────────────────────────
687
688fn layout_rows(reader: &mut Reader<'_>, layout: &LayoutDescriptor) -> Vec<Row> {
689    match layout {
690        LayoutDescriptor::RowMajor | LayoutDescriptor::ColMajor => vec![],
691
692        LayoutDescriptor::Strided(s) => s
693            .strides
694            .iter()
695            .enumerate()
696            .map(|(i, &v)| reader.take_row(8, format!("strides[{i}] = {v}")))
697            .collect(),
698
699        LayoutDescriptor::Tiled(t) => tiled_rows(reader, t),
700
701        LayoutDescriptor::Morton(m) => m
702            .morton_bits
703            .iter()
704            .enumerate()
705            .map(|(i, &v)| reader.take_row(4, format!("morton_bits[{i}] = {v}")))
706            .collect(),
707
708        LayoutDescriptor::Coo(c) => vec![
709            reader.take_row(8, format!("nnz = {}", c.nnz)),
710            reader.take_row(1, format!("is_sorted = 0x{:02X}", u8::from(c.is_sorted))),
711            reader.take_row(7, "COO._reserved".to_string()),
712        ],
713
714        LayoutDescriptor::Csr(c) => vec![
715            reader.take_row(8, format!("nnz = {}", c.nnz)),
716            reader.take_row(8, "CSR._reserved".to_string()),
717        ],
718
719        LayoutDescriptor::Csc(c) => vec![
720            reader.take_row(8, format!("nnz = {}", c.nnz)),
721            reader.take_row(8, "CSC._reserved".to_string()),
722        ],
723
724        LayoutDescriptor::Hilbert(h) => vec![
725            reader.take_row(4, format!("hilbert_order = {}", h.hilbert_order)),
726            reader.take_row(4, format!("hilbert_rank = {}", h.hilbert_rank)),
727        ],
728
729        LayoutDescriptor::PrivateExtension(p) => {
730            let mut rows = vec![
731                reader.take_row(
732                    8,
733                    format!("extension_layout_id = 0x{:016X}", p.extension_layout_id),
734                ),
735                reader.take_row(
736                    4,
737                    format!("extension_data_length = {}", p.extension_data.len()),
738                ),
739            ];
740            if !p.extension_data.is_empty() {
741                rows.push(reader.take_row(p.extension_data.len(), "extension_data".to_string()));
742            }
743            rows
744        }
745
746        LayoutDescriptor::Csf(c) => {
747            let mut rows = vec![reader.take_row(8, format!("nnz = {}", c.nnz))];
748            for (i, &d) in c.mode_order.iter().enumerate() {
749                rows.push(reader.take_row(4, format!("mode_order[{i}] = {d}")));
750            }
751            rows.push(reader.take_row(8, "CSF._reserved".to_string()));
752            rows
753        }
754
755        LayoutDescriptor::BlockPaged(bp) => {
756            let kv_n = match bp.kv_role {
757                KvRole::Key => "key",
758                KvRole::Value => "value",
759                KvRole::Fused => "fused",
760                _ => "unknown",
761            };
762            let bt_n = match bp.block_table_index_type {
763                BlockTableIndexType::U32 => "uint32",
764                BlockTableIndexType::U64 => "uint64",
765                _ => "unknown",
766            };
767            let layer = match bp.layer_index {
768                Some(x) => x.to_string(),
769                None => "none".to_string(),
770            };
771            vec![
772                reader.take_row(4, format!("page_size = {}", bp.page_size)),
773                reader.take_row(8, format!("num_pages = {}", bp.num_pages)),
774                reader.take_row(4, format!("paged_axis = {}", bp.paged_axis)),
775                reader.take_row(4, format!("num_seqs = {}", bp.num_seqs)),
776                reader.take_row(1, format!("kv_role = {kv_n}")),
777                reader.take_row(4, format!("layer_index = {layer}")),
778                reader.take_row(1, format!("block_table_index_type = {bt_n}")),
779                reader.take_row(6, "block-paged._reserved".to_string()),
780            ]
781        }
782
783        LayoutDescriptor::Composite(c) => {
784            let rule_n = match &c.rule {
785                CompositionRule::Partition => "partition",
786                CompositionRule::Overlay(_) => "overlay",
787                CompositionRule::Group => "group",
788                _ => "unknown",
789            };
790            let op_n = match &c.rule {
791                CompositionRule::Overlay(CombineOp::Replace) => "replace",
792                CompositionRule::Overlay(CombineOp::Add) => "add",
793                _ => "n/a",
794            };
795            vec![
796                reader.take_row(1, format!("composition_rule = {rule_n}")),
797                reader.take_row(1, format!("combine_op = {op_n}")),
798                reader.take_row(2, "composite._reserved".to_string()),
799                reader.take_row(4, format!("member_count = {}", c.member_count)),
800            ]
801        }
802
803        LayoutDescriptor::Unknown(u) => {
804            if u.raw_bytes.is_empty() {
805                vec![]
806            } else {
807                vec![reader.take_row(
808                    u.raw_bytes.len(),
809                    format!("(unknown layout 0x{:02X} — raw bytes)", u.tag),
810                )]
811            }
812        }
813
814        // Required by #[non_exhaustive] — treat any future variant like Unknown.
815        _ => vec![],
816    }
817}
818
819fn tiled_rows(reader: &mut Reader<'_>, tiled: &TiledLayout) -> Vec<Row> {
820    let mut rows = Vec::new();
821
822    for (i, &v) in tiled.tile_shape.iter().enumerate() {
823        rows.push(reader.take_row(8, format!("tile_shape[{i}] = {v}")));
824    }
825    rows.push(reader.take_row(
826        1,
827        format!(
828            "outer_layout = 0x{:02X} ({})",
829            tiled.outer_layout,
830            layout_tag_name(tiled.outer_layout)
831        ),
832    ));
833    rows.push(reader.take_row(
834        1,
835        format!(
836            "inner_layout = 0x{:02X} ({})",
837            tiled.inner_layout,
838            layout_tag_name(tiled.inner_layout)
839        ),
840    ));
841    rows.push(reader.take_row(2, "tiled._reserved".to_string()));
842
843    if let Some(os) = &tiled.outer_strides {
844        for (i, &v) in os.strides.iter().enumerate() {
845            rows.push(reader.take_row(8, format!("outer_strides[{i}] = {v}")));
846        }
847    }
848    if let Some(is) = &tiled.inner_strides {
849        for (i, &v) in is.strides.iter().enumerate() {
850            rows.push(reader.take_row(8, format!("inner_strides[{i}] = {v}")));
851        }
852    } else if let Some(inner) = &tiled.inner_tiled {
853        rows.extend(tiled_rows(reader, inner));
854    }
855
856    rows
857}
858
859// ── Descriptor display ─────────────────────────────────────────────────────────
860
861/// Build rows for a tensor descriptor.
862///
863/// `base_offset` is added to all row offsets so they reflect the descriptor's
864/// absolute position within the file (pass 0 for standalone descriptor files).
865fn rows_from_descriptor(data: &[u8], desc: &TensorDescriptor, base_offset: usize) -> Vec<Row> {
866    // descriptor_length is at bytes 6..10 (little-endian u32).
867    let desc_len = u32::from_le_bytes(data[6..10].try_into().unwrap_or([0u8; 4])) as usize;
868    let mut reader = Reader::with_base(data, desc_len, base_offset);
869    let mut rows = Vec::new();
870
871    // Fixed header (20 bytes).
872    rows.push(reader.take_row(4, r#"magic = "HRRY""#.to_string()));
873    rows.push(reader.take_row(1, format!("version_major = {}", desc.version_major)));
874    rows.push(reader.take_row(1, format!("version_minor = {}", desc.version_minor)));
875    rows.push(reader.take_row(4, format!("descriptor_length = {desc_len}")));
876    rows.push(reader.take_row(4, format!("flags = 0x{:08X}", desc.flags().0)));
877    rows.push(reader.take_row(
878        1,
879        format!(
880            "type_tag = 0x{:02X} ({})",
881            desc.element_type.tag(),
882            desc.element_type
883        ),
884    ));
885    let ltag = desc.layout.tag();
886    rows.push(reader.take_row(
887        1,
888        format!("layout_tag = 0x{ltag:02X} ({})", layout_tag_name(ltag)),
889    ));
890    let rank = desc.shape.rank() as u32;
891    rows.push(reader.take_row(4, format!("rank = {rank}")));
892
893    // shape[0..rank] (rank × u64)
894    for (i, &dim) in desc.shape.dims().iter().enumerate() {
895        let display = if dim == DYNAMIC {
896            "(dynamic)".to_string()
897        } else {
898            dim.to_string()
899        };
900        rows.push(reader.take_row(8, format!("shape[{i}] = {display}")));
901    }
902
903    // byte_offset (u64)
904    rows.push(reader.take_row(8, format!("byte_offset = {}", desc.byte_offset)));
905
906    // Layout-specific fields.
907    rows.extend(layout_rows(&mut reader, &desc.layout));
908
909    // Buffer table.
910    rows.push(reader.take_row(1, format!("buffer_count = {}", desc.buffers.len())));
911
912    // Per-handle: byte_size u64 (8), alignment u32 (4), device_tag u8 (1),
913    //             sync_mode u8 (1), _reserved u8[2] — 16 bytes total (ADR-018).
914    for (b, buf) in desc.buffers.iter().enumerate() {
915        rows.push(reader.take_row(8, format!("buffer[{b}].byte_size = {}", buf.byte_size())));
916        rows.push(reader.take_row(4, format!("buffer[{b}].alignment = {}", buf.alignment())));
917        rows.push(reader.take_row(
918            1,
919            format!(
920                "buffer[{b}].device_tag = 0x{:02X} ({})",
921                buf.device_tag().to_byte(),
922                buf.device_tag()
923            ),
924        ));
925        rows.push(reader.take_row(
926            1,
927            format!(
928                "buffer[{b}].sync_mode = 0x{:02X} ({})",
929                buf.sync_mode().to_byte(),
930                buf.sync_mode()
931            ),
932        ));
933        rows.push(reader.take_row(2, format!("buffer[{b}]._reserved")));
934    }
935
936    // Optional sections in spec-mandated order: quantization, shard, statistics,
937    // extension-type.  The encoder writes them in this order; the cursor must
938    // advance in the same sequence.
939
940    // Quantization (flag bit 0)
941    if let Some(q) = &desc.quantization {
942        rows.push(reader.take_row(4, format!("quantization_length = {}", q.len())));
943        match QuantizationDescriptor::decode(q) {
944            Ok((qd, _)) => rows.extend(quant_rows(&mut reader, &qd)),
945            Err(_) if !q.is_empty() => {
946                rows.push(
947                    reader.take_row(q.len(), "quantization_descriptor (undecodable)".to_string()),
948                );
949            }
950            Err(_) => {}
951        }
952    }
953
954    // Shard (flag bit 1)
955    if let Some(s) = &desc.shard {
956        for (i, &v) in s.parent_shape.iter().enumerate() {
957            rows.push(reader.take_row(8, format!("parent_shape[{i}] = {v}")));
958        }
959        for (i, &v) in s.shard_offset.iter().enumerate() {
960            rows.push(reader.take_row(8, format!("shard_offset[{i}] = {v}")));
961        }
962    }
963
964    // Statistics (flag bit 3)
965    if let Some(st) = &desc.statistics {
966        rows.push(reader.take_row(
967            4,
968            format!("stats.computed_mask = 0x{:08X}", st.computed_mask.0),
969        ));
970        rows.push(reader.take_row(4, "stats._reserved".to_string()));
971        rows.push(reader.take_row(8, format!("stats.nnz = {}", st.nnz)));
972        rows.push(reader.take_row(8, format!("stats.sparsity_ratio = {}", st.sparsity_ratio)));
973        rows.push(reader.take_row(8, format!("stats.value_min = {}", st.value_min)));
974        rows.push(reader.take_row(8, format!("stats.value_max = {}", st.value_max)));
975        rows.push(reader.take_row(8, format!("stats.value_abs_max = {}", st.value_abs_max)));
976        rows.push(reader.take_row(8, format!("stats.value_mean = {}", st.value_mean)));
977        rows.push(reader.take_row(8, format!("stats.value_stddev = {}", st.value_stddev)));
978        rows.push(reader.take_row(1, format!("stats.nm_n = {}", st.nm_n)));
979        rows.push(reader.take_row(1, format!("stats.nm_m = {}", st.nm_m)));
980        rows.push(reader.take_row(1, format!("stats.has_nan = {}", u8::from(st.has_nan))));
981        rows.push(reader.take_row(1, format!("stats.has_inf = {}", u8::from(st.has_inf))));
982        rows.push(reader.take_row(4, "stats._reserved2".to_string()));
983    }
984
985    // Extension type (flag bit 2)
986    if let Some(ext) = &desc.extension_type {
987        rows.push(reader.take_row(4, format!("ext_type.bit_width = {}", ext.bit_width)));
988        rows.push(reader.take_row(
989            1,
990            format!("ext_type.packing_factor = {}", ext.packing_factor),
991        ));
992        rows.push(reader.take_row(1, format!("ext_type.is_float = {}", u8::from(ext.is_float))));
993        rows.push(reader.take_row(
994            1,
995            format!("ext_type.is_signed = {}", u8::from(ext.is_signed)),
996        ));
997        rows.push(reader.take_row(1, format!("ext_type.sign_bits = {}", ext.sign_bits)));
998        rows.push(reader.take_row(1, format!("ext_type.exponent_bits = {}", ext.exponent_bits)));
999        rows.push(reader.take_row(1, format!("ext_type.mantissa_bits = {}", ext.mantissa_bits)));
1000        rows.push(reader.take_row(2, "ext_type._reserved".to_string()));
1001        rows.push(reader.take_row(4, format!("ext_type.exponent_bias = {}", ext.exponent_bias)));
1002        rows.push(reader.take_row(1, format!("ext_type.has_nan = {}", u8::from(ext.has_nan))));
1003        rows.push(reader.take_row(1, format!("ext_type.has_inf = {}", u8::from(ext.has_inf))));
1004        rows.push(reader.take_row(2, "ext_type._reserved2".to_string()));
1005    }
1006
1007    // Composite member (flag bit 4) — appears after the extension-type section. Marks this
1008    // descriptor as a member of a composite (its head carries layout_tag 0x0B).
1009    if let Some(cm) = &desc.composite_member {
1010        let role_n = match cm.member_role {
1011            MemberRole::Base => "base",
1012            MemberRole::Correction => "correction",
1013            _ => "unknown",
1014        };
1015        rows.push(reader.take_row(1, format!("member_role = {role_n}")));
1016        rows.push(reader.take_row(15, "composite_member._reserved".to_string()));
1017    }
1018
1019    // Consume any trailing bytes within the descriptor window (future minor-version additions).
1020    if let Some(row) = reader.skip_to(desc_len) {
1021        rows.push(row);
1022    }
1023
1024    rows
1025}
1026
1027// ── HRRYFILE container inspection ─────────────────────────────────────────────
1028
1029fn section_row(title: &str) -> Row {
1030    Row {
1031        offset: 0,
1032        bytes: vec![],
1033        field: format!("── {title} ──"),
1034    }
1035}
1036
1037fn parse_kv_scalar(r: &mut FReader<'_>, rows: &mut Vec<Row>, label: &str, tag: u8) -> Result<()> {
1038    match tag {
1039        0x01 => {
1040            let (sl_off, sl) = r.take(4)?;
1041            let str_len = le_u32(&sl) as usize;
1042            rows.push(Row {
1043                offset: sl_off,
1044                bytes: sl,
1045                field: format!("{label}.str_len = {str_len}"),
1046            });
1047            let (sv_off, sv) = r.take(str_len)?;
1048            let s = String::from_utf8(sv.clone()).unwrap_or_else(|_| "(invalid UTF-8)".into());
1049            rows.push(Row {
1050                offset: sv_off,
1051                bytes: sv,
1052                field: format!("{label} = {s:?}"),
1053            });
1054        }
1055        0x02 => {
1056            let (vo, vb) = r.take(8)?;
1057            rows.push(Row {
1058                offset: vo,
1059                bytes: vb.clone(),
1060                field: format!("{label} = {}i64", le_i64(&vb)),
1061            });
1062        }
1063        0x03 => {
1064            let (vo, vb) = r.take(8)?;
1065            rows.push(Row {
1066                offset: vo,
1067                bytes: vb.clone(),
1068                field: format!("{label} = {}u64", le_u64(&vb)),
1069            });
1070        }
1071        0x04 => {
1072            let (vo, vb) = r.take(8)?;
1073            rows.push(Row {
1074                offset: vo,
1075                bytes: vb.clone(),
1076                field: format!("{label} = {}f64", le_f64(&vb)),
1077            });
1078        }
1079        0x05 => {
1080            let (vo, vb) = r.take(1)?;
1081            rows.push(Row {
1082                offset: vo,
1083                bytes: vb.clone(),
1084                field: format!("{label} = {}", vb[0] != 0),
1085            });
1086        }
1087        0x06 => {
1088            let (bl_off, bl) = r.take(4)?;
1089            let byte_len = le_u32(&bl) as usize;
1090            rows.push(Row {
1091                offset: bl_off,
1092                bytes: bl,
1093                field: format!("{label}.byte_len = {byte_len}"),
1094            });
1095            let (bv_off, bv) = r.take(byte_len)?;
1096            rows.push(Row {
1097                offset: bv_off,
1098                bytes: bv,
1099                field: format!("{label} = ({byte_len} bytes)"),
1100            });
1101        }
1102        _ => {
1103            rows.push(Row {
1104                offset: r.pos,
1105                bytes: vec![],
1106                field: format!("{label} = (unknown type 0x{tag:02X})"),
1107            });
1108        }
1109    }
1110    Ok(())
1111}
1112
1113fn parse_kv_section(
1114    data: &[u8],
1115    r: &mut FReader<'_>,
1116    rows: &mut Vec<Row>,
1117    kv_offset: usize,
1118    kv_length: usize,
1119) -> Result<()> {
1120    r.seek_to(kv_offset);
1121
1122    let (kc_off, kc) = r.take(4)?;
1123    let kv_count = le_u32(&kc) as usize;
1124    rows.push(section_row(&format!("KV METADATA ({kv_count} entries)")));
1125    rows.push(Row {
1126        offset: kc_off,
1127        bytes: kc,
1128        field: format!("kv_count = {kv_count}"),
1129    });
1130
1131    for i in 0..kv_count {
1132        let (kl_off, kl) = r.take(2)?;
1133        let key_len = le_u16(&kl) as usize;
1134        rows.push(Row {
1135            offset: kl_off,
1136            bytes: kl,
1137            field: format!("kv[{i}].key_len = {key_len}"),
1138        });
1139
1140        let (k_off, k) = r.take(key_len)?;
1141        let key = String::from_utf8(k.clone())
1142            .map_err(|_| Error::Hrryfile(format!("kv[{i}] key is not valid UTF-8")))?;
1143        rows.push(Row {
1144            offset: k_off,
1145            bytes: k,
1146            field: format!("kv[{i}].key = {key:?}"),
1147        });
1148
1149        let (tag_off, tag_b) = r.take(1)?;
1150        let tag = le_u8(&tag_b);
1151        rows.push(Row {
1152            offset: tag_off,
1153            bytes: tag_b,
1154            field: format!("kv[{i}].type = 0x{tag:02X} ({})", kv_tag_name(tag)),
1155        });
1156
1157        if tag == 0x07 {
1158            // Array: elem_type (1), count (4), then count * elem values
1159            let (et_off, et) = r.take(1)?;
1160            let elem_tag = le_u8(&et);
1161            rows.push(Row {
1162                offset: et_off,
1163                bytes: et,
1164                field: format!(
1165                    "kv[{i}].elem_type = 0x{elem_tag:02X} ({})",
1166                    kv_tag_name(elem_tag)
1167                ),
1168            });
1169            let (ac_off, ac) = r.take(4)?;
1170            let count = le_u32(&ac) as usize;
1171            rows.push(Row {
1172                offset: ac_off,
1173                bytes: ac,
1174                field: format!("kv[{i}].elem_count = {count}"),
1175            });
1176            for j in 0..count {
1177                parse_kv_scalar(r, rows, &format!("kv[{i}][{j}]"), elem_tag)?;
1178            }
1179        } else {
1180            parse_kv_scalar(r, rows, &format!("kv[{i}].value"), tag)?;
1181        }
1182    }
1183
1184    let _ = (data, kv_length); // consumed for clarity; bounds already validated by open()
1185    Ok(())
1186}
1187
1188// ── Data section rows ──────────────────────────────────────────────────────────
1189
1190/// Append rows displaying the tensor buffer `buf` in the chosen `DataMode`.
1191///
1192/// `buf_file_offset` is the byte offset of `buf` within the file — used for
1193/// the offset column in hex-mode rows.
1194fn append_data_rows(
1195    rows: &mut Vec<Row>,
1196    desc: &TensorDescriptor,
1197    buf: &[u8],
1198    buf_file_offset: usize,
1199    mode: DataMode,
1200) {
1201    let et = desc.element_type;
1202    let shape = desc.shape.dims();
1203    rows.push(section_row(&format!(
1204        "TENSOR DATA ({}, shape={shape:?}, {} bytes)",
1205        et,
1206        buf.len()
1207    )));
1208    match mode {
1209        DataMode::Hex => {
1210            for (i, chunk) in buf.chunks(16).enumerate() {
1211                rows.push(Row {
1212                    offset: buf_file_offset + i * 16,
1213                    bytes: chunk.to_vec(),
1214                    field: String::new(),
1215                });
1216            }
1217        }
1218        DataMode::Numpy => {
1219            let s = format_numpy(et, shape, buf);
1220            for line in s.lines() {
1221                rows.push(Row {
1222                    offset: 0,
1223                    bytes: vec![],
1224                    field: line.to_string(),
1225                });
1226            }
1227        }
1228        DataMode::None => {}
1229    }
1230}
1231
1232fn inspect_hrryfile_inner(data: &[u8], rows: &mut Vec<Row>, data_mode: DataMode) -> Result<()> {
1233    let file_size = data.len();
1234
1235    if file_size < FILE_HEADER_SIZE + TRAILER_SIZE {
1236        return Err(Error::Hrryfile(format!(
1237            "file too small: {file_size} bytes (minimum {})",
1238            FILE_HEADER_SIZE + TRAILER_SIZE
1239        )));
1240    }
1241
1242    let mut r = FReader::new(data);
1243
1244    // ── File header (64 bytes) ─────────────────────────────────────────────────
1245    rows.push(section_row("FILE HEADER (64 bytes)"));
1246
1247    let (magic_off, magic) = r.take(8)?;
1248    rows.push(Row {
1249        offset: magic_off,
1250        bytes: magic,
1251        field: r#"file_magic = "HRRYFILE""#.to_string(),
1252    });
1253
1254    let (vmaj_off, vmaj) = r.take(1)?;
1255    let version_major = le_u8(&vmaj);
1256    rows.push(Row {
1257        offset: vmaj_off,
1258        bytes: vmaj,
1259        field: format!("container_version_major = {version_major}"),
1260    });
1261
1262    let (vmin_off, vmin) = r.take(1)?;
1263    let version_minor = le_u8(&vmin);
1264    rows.push(Row {
1265        offset: vmin_off,
1266        bytes: vmin,
1267        field: format!("container_version_minor = {version_minor}"),
1268    });
1269
1270    rows.push(r.row(2, "_reserved".to_string())?);
1271
1272    let (fl_off, fl) = r.take(4)?;
1273    let flags = le_u32(&fl);
1274    rows.push(Row {
1275        offset: fl_off,
1276        bytes: fl,
1277        field: format!("flags = {}", file_flags_display(flags)),
1278    });
1279
1280    let (al_off, al) = r.take(4)?;
1281    let alignment = le_u32(&al);
1282    rows.push(Row {
1283        offset: al_off,
1284        bytes: al,
1285        field: format!("data_buffer_alignment = {alignment}"),
1286    });
1287
1288    let (fdo_off, fdo) = r.take(8)?;
1289    let first_desc_offset = le_u64(&fdo);
1290    rows.push(Row {
1291        offset: fdo_off,
1292        bytes: fdo,
1293        field: format!("first_descriptor_offset = {first_desc_offset}"),
1294    });
1295
1296    let (tch_off, tch) = r.take(8)?;
1297    let tensor_count_hint = le_u64(&tch);
1298    rows.push(Row {
1299        offset: tch_off,
1300        bytes: tch,
1301        field: if tensor_count_hint == u64::MAX {
1302            "tensor_count_hint = (unknown)".to_string()
1303        } else {
1304            format!("tensor_count_hint = {tensor_count_hint}")
1305        },
1306    });
1307
1308    rows.push(r.row(28, "_reserved_header".to_string())?);
1309
1310    // ── Trailer (last 40 bytes) ────────────────────────────────────────────────
1311    rows.push(section_row("TRAILER (40 bytes)"));
1312
1313    let trailer_start = file_size - TRAILER_SIZE;
1314    r.seek_to(trailer_start);
1315
1316    let (io_off, io) = r.take(8)?;
1317    let index_offset = le_u64(&io) as usize;
1318    rows.push(Row {
1319        offset: io_off,
1320        bytes: io,
1321        field: format!("index_offset = {index_offset}"),
1322    });
1323
1324    let (il_off, il) = r.take(8)?;
1325    let index_length = le_u64(&il) as usize;
1326    rows.push(Row {
1327        offset: il_off,
1328        bytes: il,
1329        field: format!("index_length = {index_length}"),
1330    });
1331
1332    let (ko_off, ko) = r.take(8)?;
1333    let kv_offset = le_u64(&ko) as usize;
1334    rows.push(Row {
1335        offset: ko_off,
1336        bytes: ko,
1337        field: format!("kv_offset = {kv_offset}"),
1338    });
1339
1340    let (kl_off, kl_b) = r.take(4)?;
1341    let kv_length = le_u32(&kl_b) as usize;
1342    rows.push(Row {
1343        offset: kl_off,
1344        bytes: kl_b,
1345        field: format!("kv_length = {kv_length}"),
1346    });
1347
1348    let (crc_off, crc_b) = r.take(4)?;
1349    let stored_crc = le_u32(&crc_b);
1350    rows.push(Row {
1351        offset: crc_off,
1352        bytes: crc_b,
1353        field: format!("index_crc32c = 0x{stored_crc:08X}"),
1354    });
1355
1356    rows.push(r.row(4, "_reserved".to_string())?);
1357
1358    let (tm_off, tm) = r.take(4)?;
1359    rows.push(Row {
1360        offset: tm_off,
1361        bytes: tm,
1362        field: r#"trailer_magic = "HRRY""#.to_string(),
1363    });
1364
1365    // ── Index ──────────────────────────────────────────────────────────────────
1366    let sorted_label = if flags & FLAG_SORTED_INDEX != 0 {
1367        ", sorted"
1368    } else {
1369        ""
1370    };
1371
1372    r.seek_to(index_offset);
1373
1374    let (ec_off, ec) = r.take(8)?;
1375    let entry_count = le_u64(&ec) as usize;
1376
1377    rows.push(section_row(&format!(
1378        "INDEX ({entry_count} entr{}{sorted_label})",
1379        if entry_count == 1 { "y" } else { "ies" }
1380    )));
1381    rows.push(Row {
1382        offset: ec_off,
1383        bytes: ec,
1384        field: format!("entry_count = {entry_count}"),
1385    });
1386
1387    struct IndexEntry {
1388        name: String,
1389        descriptor_offset: usize,
1390        descriptor_length: usize,
1391        data_offset: u64,
1392        data_length: u64,
1393    }
1394    let mut entries: Vec<IndexEntry> = Vec::with_capacity(entry_count);
1395
1396    for i in 0..entry_count {
1397        let (nl_off, nl) = r.take(2)?;
1398        let name_len = le_u16(&nl) as usize;
1399        rows.push(Row {
1400            offset: nl_off,
1401            bytes: nl,
1402            field: format!("entry[{i}].name_len = {name_len}"),
1403        });
1404
1405        let (n_off, n_b) = r.take(name_len)?;
1406        let name = String::from_utf8(n_b.clone())
1407            .map_err(|_| Error::Hrryfile(format!("entry[{i}] name is not valid UTF-8")))?;
1408        rows.push(Row {
1409            offset: n_off,
1410            bytes: n_b,
1411            field: format!("entry[{i}].name = {name:?}"),
1412        });
1413
1414        let (do_off, do_b) = r.take(8)?;
1415        let descriptor_offset = le_u64(&do_b) as usize;
1416        rows.push(Row {
1417            offset: do_off,
1418            bytes: do_b,
1419            field: format!("entry[{i}].descriptor_offset = {descriptor_offset}"),
1420        });
1421
1422        let (dl_off, dl_b) = r.take(4)?;
1423        let descriptor_length = le_u32(&dl_b) as usize;
1424        rows.push(Row {
1425            offset: dl_off,
1426            bytes: dl_b,
1427            field: format!("entry[{i}].descriptor_length = {descriptor_length}"),
1428        });
1429
1430        let (dto_off, dto) = r.take(8)?;
1431        let data_offset = le_u64(&dto);
1432        rows.push(Row {
1433            offset: dto_off,
1434            bytes: dto,
1435            field: format!("entry[{i}].data_offset = {data_offset}"),
1436        });
1437
1438        let (dtl_off, dtl) = r.take(8)?;
1439        let data_length = le_u64(&dtl);
1440        rows.push(Row {
1441            offset: dtl_off,
1442            bytes: dtl,
1443            field: format!("entry[{i}].data_length = {data_length}"),
1444        });
1445
1446        let (ef_off, ef) = r.take(4)?;
1447        let entry_flags = le_u32(&ef);
1448        rows.push(Row {
1449            offset: ef_off,
1450            bytes: ef,
1451            field: format!("entry[{i}].flags = 0x{entry_flags:08X}"),
1452        });
1453
1454        entries.push(IndexEntry {
1455            name,
1456            descriptor_offset,
1457            descriptor_length,
1458            data_offset,
1459            data_length,
1460        });
1461    }
1462
1463    // ── KV metadata (optional) ─────────────────────────────────────────────────
1464    let has_kv = (flags & FLAG_HAS_KV_METADATA != 0) || (kv_offset != 0 && kv_length != 0);
1465    if has_kv && kv_offset != 0 && kv_length != 0 {
1466        parse_kv_section(data, &mut r, rows, kv_offset, kv_length)?;
1467    }
1468
1469    // ── Per-tensor descriptors ─────────────────────────────────────────────────
1470    for entry in &entries {
1471        rows.push(section_row(&format!(
1472            "TENSOR DESCRIPTOR: {:?}  (data at {}, {} bytes)",
1473            entry.name, entry.data_offset, entry.data_length
1474        )));
1475
1476        let end = entry.descriptor_offset + entry.descriptor_length;
1477        let desc_data = data.get(entry.descriptor_offset..end).ok_or_else(|| {
1478            Error::Hrryfile(format!(
1479                "descriptor for {:?} is out of file bounds (offset={}, length={})",
1480                entry.name, entry.descriptor_offset, entry.descriptor_length
1481            ))
1482        })?;
1483
1484        match TensorDescriptor::decode(desc_data) {
1485            Ok(desc) => {
1486                rows.extend(rows_from_descriptor(
1487                    desc_data,
1488                    &desc,
1489                    entry.descriptor_offset,
1490                ));
1491                if data_mode != DataMode::None {
1492                    let data_start = entry.data_offset as usize;
1493                    let data_end = data_start.saturating_add(entry.data_length as usize);
1494                    if data_end <= data.len() && entry.data_length > 0 {
1495                        append_data_rows(
1496                            rows,
1497                            &desc,
1498                            &data[data_start..data_end],
1499                            data_start,
1500                            data_mode,
1501                        );
1502                    } else {
1503                        rows.push(section_row(&format!(
1504                            "TENSOR DATA {:?}: not available (data outside file bounds)",
1505                            entry.name
1506                        )));
1507                    }
1508                }
1509            }
1510            Err(e) => rows.push(Row {
1511                offset: entry.descriptor_offset,
1512                bytes: vec![],
1513                field: format!("ERROR decoding descriptor: {e}"),
1514            }),
1515        }
1516    }
1517
1518    Ok(())
1519}
1520
1521fn inspect_hrryfile(data: &[u8], data_mode: DataMode) -> (Vec<Row>, Option<Error>) {
1522    let mut rows = Vec::new();
1523    match inspect_hrryfile_inner(data, &mut rows, data_mode) {
1524        Ok(()) => (rows, None),
1525        Err(e) => (rows, Some(e)),
1526    }
1527}
1528
1529// ── Raw descriptor inspection ─────────────────────────────────────────────────
1530
1531fn inspect(data: &[u8], data_mode: DataMode) -> (Vec<Row>, Option<Error>) {
1532    // Dispatch to HRRYFILE container path when the file magic matches.
1533    if data.len() >= 8 && &data[..8] == FILE_MAGIC {
1534        return inspect_hrryfile(data, data_mode);
1535    }
1536
1537    match TensorDescriptor::decode(data) {
1538        Ok(desc) => {
1539            let desc_len = u32::from_le_bytes(data[6..10].try_into().unwrap_or([0u8; 4])) as usize;
1540            let mut rows = rows_from_descriptor(data, &desc, 0);
1541            if data_mode != DataMode::None {
1542                let buf = data.get(desc_len..).unwrap_or(&[]);
1543                if buf.is_empty() {
1544                    rows.push(section_row(
1545                        "TENSOR DATA: not present in this file (descriptor only)",
1546                    ));
1547                } else {
1548                    append_data_rows(&mut rows, &desc, buf, desc_len, data_mode);
1549                }
1550            }
1551            (rows, None)
1552        }
1553        Err(e) => {
1554            // Show the magic bytes (if present) so the caller can see what was read,
1555            // then let the error message explain why parsing failed.
1556            let partial = if data.len() >= 4 {
1557                vec![Row {
1558                    offset: 0,
1559                    bytes: data[..4].to_vec(),
1560                    field: format!(
1561                        "magic = {:?}",
1562                        std::str::from_utf8(&data[..4]).unwrap_or("(non-UTF-8)")
1563                    ),
1564                }]
1565            } else {
1566                vec![]
1567            };
1568            (partial, Some(Error::Parse(e)))
1569        }
1570    }
1571}
1572
1573// ── Hex table rendering ────────────────────────────────────────────────────────
1574
1575/// Format a byte slice as space-separated uppercase hex pairs (e.g. `"48 52 52 59"`).
1576fn hex_str(bytes: &[u8]) -> String {
1577    bytes
1578        .iter()
1579        .map(|b| format!("{b:02X}"))
1580        .collect::<Vec<_>>()
1581        .join(" ")
1582}
1583
1584/// Split `bytes` into groups of at most `per_line` bytes and format each as a
1585/// space-separated hex string.
1586fn hex_chunks(bytes: &[u8], per_line: usize) -> Vec<String> {
1587    if bytes.is_empty() {
1588        return vec![String::new()];
1589    }
1590    bytes.chunks(per_line).map(hex_str).collect()
1591}
1592
1593/// Print the 3-column hex table for `rows` to stdout.
1594///
1595/// Column widths:
1596/// - Offset: right-aligned in 6 chars
1597/// - Value (hex): left-aligned in 30 chars (10 bytes per line; wraps if longer)
1598/// - Field: remainder
1599///
1600/// Annotation rows (empty `bytes`) with a field starting with `"── "` are printed
1601/// as section separators with a blank line prefix.
1602fn print_table(rows: &[Row]) {
1603    println!("{:>6}  {:<30}  Field", "Offset", "Value (hex)");
1604    println!("{:->6}  {:-<30}  {:-<5}", "", "", "");
1605
1606    for row in rows {
1607        if row.bytes.is_empty() {
1608            if row.field.starts_with("── ") {
1609                // Section separator — blank line + indented title for readability.
1610                println!();
1611                println!("       {}", row.field);
1612            } else {
1613                println!("{:>6}  {:<30}  {}", "", "", row.field);
1614            }
1615        } else {
1616            let mut first = true;
1617            for chunk in hex_chunks(&row.bytes, 10) {
1618                if first {
1619                    println!("{:>6}  {:<30}  {}", row.offset, chunk, row.field);
1620                    first = false;
1621                } else {
1622                    println!("{:>6}  {:<30}", "", chunk);
1623                }
1624            }
1625        }
1626    }
1627}
1628
1629// ── Entry point ────────────────────────────────────────────────────────────────
1630
1631fn parse_args(args: &[String]) -> Result<(String, DataMode)> {
1632    let mut path: Option<String> = None;
1633    let mut data_mode = DataMode::None;
1634
1635    for arg in args.iter().skip(1) {
1636        if arg == "--data" || arg == "--data=numpy" {
1637            data_mode = DataMode::Numpy;
1638        } else if arg == "--data=hex" {
1639            data_mode = DataMode::Hex;
1640        } else if (arg.starts_with('-') && arg != "-") || path.is_some() {
1641            return Err(Error::Usage);
1642        } else {
1643            path = Some(arg.clone());
1644        }
1645    }
1646
1647    path.map(|p| (p, data_mode)).ok_or(Error::Usage)
1648}
1649
1650fn run() -> Result<()> {
1651    let args: Vec<String> = env::args().collect();
1652    let (path, data_mode) = parse_args(&args)?;
1653
1654    let data: Vec<u8> = if path == "-" {
1655        let mut buf = Vec::new();
1656        io::stdin().read_to_end(&mut buf)?;
1657        buf
1658    } else {
1659        fs::read(&path)?
1660    };
1661
1662    let (rows, err) = inspect(&data, data_mode);
1663    print_table(&rows);
1664
1665    if let Some(e) = err {
1666        let error_row = Row {
1667            offset: 0,
1668            bytes: vec![],
1669            field: format!("ERROR: {e}"),
1670        };
1671        print_table(&[error_row]);
1672        eprintln!("error: {e}");
1673        process::exit(1);
1674    }
1675
1676    Ok(())
1677}
1678
1679fn main() {
1680    if let Err(e) = run() {
1681        eprintln!("error: {e}");
1682        process::exit(1);
1683    }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use super::*;
1689    use hurray_core::ElementType;
1690
1691    // Build a minimal raw HRRY descriptor for testing.
1692    fn make_raw_descriptor(et: ElementType, dims: &[u64]) -> Vec<u8> {
1693        use hurray_core::{
1694            BufferHandle, LayoutDescriptor, MemoryClass, Shape, SyncMode, TensorDescriptor,
1695            DESCRIPTOR_VERSION_MAJOR, DESCRIPTOR_VERSION_MINOR,
1696        };
1697        let shape = Shape::new(dims.to_vec()).unwrap();
1698        let n: u64 = dims.iter().product();
1699        let byte_size = hurray_core::buffer_size_bytes(et, n);
1700        let bh = BufferHandle::with_memory_class(
1701            byte_size,
1702            64,
1703            hurray_core::DeviceTag::Cpu,
1704            SyncMode::ProducerSynced,
1705            MemoryClass::Standard,
1706        )
1707        .unwrap();
1708        let desc = TensorDescriptor::new(
1709            DESCRIPTOR_VERSION_MAJOR,
1710            DESCRIPTOR_VERSION_MINOR,
1711            et,
1712            shape,
1713            0,
1714            LayoutDescriptor::RowMajor,
1715            vec![bh],
1716            None,
1717            None,
1718            None,
1719            None,
1720        )
1721        .unwrap();
1722        desc.encode().unwrap()
1723    }
1724
1725    #[test]
1726    fn format_float_adds_dot() {
1727        assert_eq!(format_float(1.0), "1.");
1728        assert_eq!(format_float(1.5), "1.5");
1729        assert_eq!(format_float(f64::NAN), "nan");
1730        assert_eq!(format_float(f64::INFINITY), "inf");
1731        assert_eq!(format_float(f64::NEG_INFINITY), "-inf");
1732    }
1733
1734    #[test]
1735    fn format_scalar_float32() {
1736        let data = 1.0f32.to_le_bytes();
1737        assert_eq!(format_scalar(ElementType::Float32, &data, 0), "1.");
1738    }
1739
1740    #[test]
1741    fn format_scalar_int32() {
1742        let data = 42i32.to_le_bytes();
1743        assert_eq!(format_scalar(ElementType::Int32, &data, 0), "42");
1744    }
1745
1746    #[test]
1747    fn format_scalar_bool_true() {
1748        let data = [0b0000_0001u8];
1749        assert_eq!(format_scalar(ElementType::Bool, &data, 0), "True");
1750        assert_eq!(format_scalar(ElementType::Bool, &data, 1), "False");
1751    }
1752
1753    #[test]
1754    fn format_scalar_uint4_int4_lsb_first() {
1755        // 0x83: low nibble = 0x3, high nibble = 0x8 (LSB-first packing).
1756        let data = [0x83u8];
1757        assert_eq!(format_scalar(ElementType::Uint4, &data, 0), "3");
1758        assert_eq!(format_scalar(ElementType::Uint4, &data, 1), "8");
1759        // int4: 0x8 is the two's-complement value -8.
1760        assert_eq!(format_scalar(ElementType::Int4, &data, 0), "3");
1761        assert_eq!(format_scalar(ElementType::Int4, &data, 1), "-8");
1762    }
1763
1764    #[test]
1765    fn format_scalar_uint2_int2_lsb_first() {
1766        // 0b11_10_01_00: elements (LSB-first) 0,1,2,3.
1767        let data = [0b1110_0100u8];
1768        for (idx, want) in [(0, "0"), (1, "1"), (2, "2"), (3, "3")] {
1769            assert_eq!(format_scalar(ElementType::Uint2, &data, idx), want);
1770        }
1771        // int2: 0,1,-2,-1.
1772        for (idx, want) in [(0, "0"), (1, "1"), (2, "-2"), (3, "-1")] {
1773            assert_eq!(format_scalar(ElementType::Int2, &data, idx), want);
1774        }
1775    }
1776
1777    #[test]
1778    fn format_scalar_float8_e4m3() {
1779        // exp=0b0111 (bias 7 → 2^0), man=0 → 1.0. byte = 0b0_0111_000 = 0x38.
1780        assert_eq!(format_scalar(ElementType::Float8E4M3, &[0x38], 0), "1.");
1781        // exp=0b1000 (2^1), man=0 → 2.0. byte = 0x40.
1782        assert_eq!(format_scalar(ElementType::Float8E4M3, &[0x40], 0), "2.");
1783        // sign=1, exp=0b0111, man=0 → -1.0. byte = 0xB8.
1784        assert_eq!(format_scalar(ElementType::Float8E4M3, &[0xB8], 0), "-1.");
1785        // 0x7F = S0 exp1111 man111 → NaN (E4M3 has no inf; max exp + max mantissa is NaN).
1786        assert_eq!(format_scalar(ElementType::Float8E4M3, &[0x7F], 0), "nan");
1787        // 0x00 → +0.
1788        assert_eq!(format_scalar(ElementType::Float8E4M3, &[0x00], 0), "0.");
1789    }
1790
1791    #[test]
1792    fn format_scalar_float8_e5m2() {
1793        // exp=0b01111 (bias 15 → 2^0), man=0 → 1.0. byte = 0b0_01111_00 = 0x3C.
1794        assert_eq!(format_scalar(ElementType::Float8E5M2, &[0x3C], 0), "1.");
1795        // exp=0b11111, man=0 → inf (E5M2 has inf/nan like IEEE).
1796        assert_eq!(format_scalar(ElementType::Float8E5M2, &[0x7C], 0), "inf");
1797        // exp=0b11111, man!=0 → NaN.
1798        assert_eq!(format_scalar(ElementType::Float8E5M2, &[0x7D], 0), "nan");
1799    }
1800
1801    #[test]
1802    fn format_scalar_float8_e8m0_scale() {
1803        // E8M0 is an unsigned power-of-two scale: byte 127 → 2^0 = 1.0.
1804        assert_eq!(format_scalar(ElementType::Float8E8M0, &[127], 0), "1.");
1805        assert_eq!(format_scalar(ElementType::Float8E8M0, &[128], 0), "2.");
1806        // 0x00 and 0xFF are the reserved NaN codes.
1807        assert_eq!(format_scalar(ElementType::Float8E8M0, &[0x00], 0), "nan");
1808        assert_eq!(format_scalar(ElementType::Float8E8M0, &[0xFF], 0), "nan");
1809    }
1810
1811    #[test]
1812    fn format_scalar_float4_e2m1() {
1813        // e2m1, bias 1: exp=0b01, man=0 → 2^0 = 1.0. code = 0b010 = 2 (low nibble).
1814        assert_eq!(format_scalar(ElementType::Float4E2M1, &[0x02], 0), "1.");
1815        // exp=0b11, man=1 → (1.5)·2^(3-1) = 6.0. code = 0b111 = 7.
1816        assert_eq!(format_scalar(ElementType::Float4E2M1, &[0x07], 0), "6.");
1817        // high nibble is the element at idx 1.
1818        assert_eq!(format_scalar(ElementType::Float4E2M1, &[0x20], 1), "1.");
1819    }
1820
1821    #[test]
1822    fn format_scalar_float6_packing() {
1823        // e2m3, bias 1: exp=0b01, man=0 → 1.0. 6-bit code = 0b001_000 = 0x08.
1824        assert_eq!(
1825            format_scalar(ElementType::Float6E2M3, &[0x08, 0, 0], 0),
1826            "1."
1827        );
1828        // e3m2, bias 3: exp=0b011, man=0 → 1.0. 6-bit code = 0b011_00 = 0x0C.
1829        assert_eq!(
1830            format_scalar(ElementType::Float6E3M2, &[0x0C, 0, 0], 0),
1831            "1."
1832        );
1833    }
1834
1835    #[test]
1836    fn quant_rows_span_matches_encoded_len() {
1837        use hurray_core::quantization::PerTensorAffine;
1838        // The rendered rows must consume exactly the encoded descriptor — no more, no
1839        // less — or the cursor desyncs for the sections that follow quantization.
1840        let desc = QuantizationDescriptor::PerTensorAffine(PerTensorAffine::new(0.5, 128).unwrap());
1841        let bytes = desc.encode_to_vec();
1842        let mut reader = Reader::with_base(&bytes, bytes.len(), 0);
1843        let rows = quant_rows(&mut reader, &desc);
1844        let span: usize = rows.iter().map(|r| r.bytes.len()).sum();
1845        assert_eq!(span, bytes.len());
1846        // Header row decodes the scheme tag; a value row surfaces the scale.
1847        assert!(rows[0].field.contains("per-tensor-affine"));
1848        assert!(rows.iter().any(|r| r.field.contains("scale = 0.5")));
1849    }
1850
1851    #[test]
1852    fn format_numpy_1d_int32() {
1853        let data: Vec<u8> = [1i32, 2, 3].iter().flat_map(|v| v.to_le_bytes()).collect();
1854        let s = format_numpy(ElementType::Int32, &[3], &data);
1855        assert_eq!(s, "[1 2 3]");
1856    }
1857
1858    #[test]
1859    fn format_numpy_2d_float32() {
1860        let data: Vec<u8> = [1.0f32, 2.0, 3.0, 4.0]
1861            .iter()
1862            .flat_map(|v| v.to_le_bytes())
1863            .collect();
1864        let s = format_numpy(ElementType::Float32, &[2, 2], &data);
1865        assert!(s.starts_with("[[1. 2.]"), "got: {s}");
1866        assert!(s.contains("[3. 4.]"), "got: {s}");
1867    }
1868
1869    #[test]
1870    fn format_numpy_truncates_large() {
1871        let n = 2000u64;
1872        let data: Vec<u8> = (0..n).flat_map(|i| (i as i32).to_le_bytes()).collect();
1873        let s = format_numpy(ElementType::Int32, &[n], &data);
1874        assert!(s.contains("..."), "expected truncation: {s}");
1875    }
1876
1877    #[test]
1878    fn parse_args_no_data() {
1879        let args = ["prog", "file.hrry"].map(String::from);
1880        let (path, mode) = parse_args(&args).unwrap();
1881        assert_eq!(path, "file.hrry");
1882        assert_eq!(mode, DataMode::None);
1883    }
1884
1885    #[test]
1886    fn parse_args_data_numpy() {
1887        let args = ["prog", "--data", "file.hrry"].map(String::from);
1888        let (path, mode) = parse_args(&args).unwrap();
1889        assert_eq!(path, "file.hrry");
1890        assert_eq!(mode, DataMode::Numpy);
1891    }
1892
1893    #[test]
1894    fn parse_args_data_hex() {
1895        let args = ["prog", "--data=hex", "file.hrry"].map(String::from);
1896        let (_, mode) = parse_args(&args).unwrap();
1897        assert_eq!(mode, DataMode::Hex);
1898    }
1899
1900    #[test]
1901    fn parse_args_unknown_flag_is_error() {
1902        let args = ["prog", "--foo", "file.hrry"].map(String::from);
1903        assert!(parse_args(&args).is_err());
1904    }
1905
1906    #[test]
1907    fn inspect_raw_descriptor_no_data() {
1908        let desc_bytes = make_raw_descriptor(ElementType::Float32, &[2, 3]);
1909        let (rows, err) = inspect(&desc_bytes, DataMode::None);
1910        assert!(err.is_none());
1911        assert!(rows.iter().any(|r| r.field.contains("float32")));
1912    }
1913
1914    #[test]
1915    fn inspect_raw_descriptor_numpy_mode_no_buffer() {
1916        let desc_bytes = make_raw_descriptor(ElementType::Float32, &[2, 3]);
1917        let (rows, err) = inspect(&desc_bytes, DataMode::Numpy);
1918        assert!(err.is_none());
1919        // Buffer not present → section note, no panic
1920        assert!(rows
1921            .iter()
1922            .any(|r| r.field.contains("TENSOR DATA") || r.field.contains("not present")));
1923    }
1924
1925    #[test]
1926    fn inspect_raw_descriptor_numpy_mode_with_buffer() {
1927        let mut file = make_raw_descriptor(ElementType::Float32, &[3]);
1928        // Append the 3 float32 values [1, 2, 3] after the descriptor.
1929        for v in [1.0f32, 2.0, 3.0] {
1930            file.extend_from_slice(&v.to_le_bytes());
1931        }
1932        let (rows, err) = inspect(&file, DataMode::Numpy);
1933        assert!(err.is_none());
1934        let all = rows
1935            .iter()
1936            .map(|r| r.field.as_str())
1937            .collect::<Vec<_>>()
1938            .join("\n");
1939        assert!(all.contains("[1. 2. 3.]"), "expected numpy row in: {all}");
1940    }
1941}