Skip to main content

qualia_core_db/container_10d/
node_section.rs

1//! `.10d` Tensor10D NODE section — the 40-byte epistemic atom in the container
2//! (P0.5).
3//!
4//! A NODE section wraps a set of `Tensor10D` records (the 40-byte
5//! `[q,v,w,x,y,z,t,α,μ,σ]` stride) as a self-describing `.10d` section. It
6//! supports two byte-equivalent layouts:
7//!
8//! - **AoS** (array-of-structs): `N × Tensor10D` records back-to-back — the
9//!   natural `Tensor10D` layout, identical to what
10//!   `tensor/buffer_export.rs::write_tensor_buffer` produces (minus its
11//!   32-byte `Q42*` header, which the `.10d` container header replaces).
12//! - **SoA** (structure-of-arrays): ten contiguous lanes, one per axis —
13//!   lane 0 = all `q` values, lane 1 = all `v` values, …, lane 9 = all `σ`
14//!   values. This is the "page-friendly" layout the design doc §4.1 names:
15//!   "any single axis is a contiguous strided read."
16//!
17//! **AoS↔SoA is byte-identical** (lossless transpose): converting AoS→SoA→AoS
18//! (or SoA→AoS→SoA) reproduces the original bytes exactly, because the
19//! transpose is just a reordering of the same `N×10` `f32` values with no
20//! precision loss. **Per-axis SoA lane reads match AoS field reads**: reading
21//! axis `i` for node `j` from the SoA layout yields the same `f32` as reading
22//! field `i` from the `j`-th `Tensor10D` in the AoS layout.
23//!
24//! **`write_tensor_q_at` semantics:** the wavefunction-collapse write (setting
25//! `q` for one node, returning the previous `q`) works on the NODE section in
26//! either layout — for AoS it writes the first `f32` of the `j`-th record; for
27//! SoA it writes lane 0 (`q`) at position `j`. The semantics match
28//! `tensor/buffer_export.rs::write_tensor_q_at` exactly (same return-the-prev-q
29//! contract), with the only difference being the byte offset (the NODE section
30//! has a 16-byte mini-header where `buffer_export.rs` has a 32-byte `Q42*`
31//! header).
32//!
33//! **Determinism + CRC:** two encodes of the same tensor set in the same layout
34//! are byte-identical (the AoS/SoA transpose is deterministic). The per-section
35//! CRC-32C (P0.2) catches a flipped bit in the payload. The whole-file CRC-32C
36//! (P0.3) catches header corruption.
37//!
38//! **Spec-reserved (NOT yet implemented):** the mini-header's `reserved` bytes
39//! are reserved for future per-axis SoA lane offset table, a q-superposition
40//! render/export mask (the design doc's "render/export default to a
41//! ground-truth-only mask; Sandbox nodes not citable as provenance until
42//! collapsed"), and a GSR-result back-pointer. These are governance/attestation
43//! concerns (the `SpecReservedGovernance` / `SpecReservedTemporalIndex`
44//! section types) and are NOT wired here — P0.5 is the atom, not the
45//! attestation layer.
46
47use bytemuck::{Pod, Zeroable};
48
49use crate::tensor::Tensor10D;
50
51/// Section payload mini-header size in bytes.
52pub const NODE_MINI_HEADER_SIZE: usize = 16;
53
54/// `Tensor10D` record size in bytes (10 × f32).
55pub const TENSOR10D_SIZE: usize = 40;
56
57/// Number of axes (lanes).
58pub const AXIS_COUNT: usize = 10;
59
60/// Layout tag: 0 = AoS (array-of-structs), 1 = SoA (structure-of-arrays).
61pub const LAYOUT_AOS: u8 = 0;
62pub const LAYOUT_SOA: u8 = 1;
63
64/// Maximum node count the NODE section will accept. Bounds against a
65/// hostile/malformed file: the 42MB Sentinel ceiling / 40 bytes per node
66/// = ~1M nodes max; 1M is a comfortable upper bound for a single section
67/// while keeping the mini-header's `node_count` field well within range.
68pub const MAX_NODE_COUNT: usize = 1_048_576; // 2^20
69
70/// The 16-byte NODE-section mini-header. `repr(C)`, naturally aligned, no
71/// padding.
72///
73/// ```text
74/// offset  size  field
75/// 0       4     node_count:u32
76/// 4       1     layout:u8        (0=AoS, 1=SoA)
77/// 5       1     reserved_u8     (must be zero)
78/// 6       2     reserved_u16    (must be zero)
79/// 8       8     reserved_u64    (must be zero — future: q-mask, GSR back-pointer)
80/// ```
81#[repr(C)]
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Pod, Zeroable)]
83pub struct NodeMiniHeader {
84    pub node_count: u32,
85    pub layout: u8,
86    pub reserved_u8: u8,
87    pub reserved_u16: u16,
88    pub reserved_u64: u64,
89}
90
91impl NodeMiniHeader {
92    /// Total payload byte length for `count` nodes (mini-header + data).
93    /// Same for AoS and SoA (N×40 either way).
94    #[inline]
95    pub const fn payload_bytes(count: usize) -> usize {
96        NODE_MINI_HEADER_SIZE + count * TENSOR10D_SIZE
97    }
98}
99
100/// NODE-section read/write error.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum NodeSectionError {
103    /// The payload is too short for the mini-header.
104    PayloadTooShort { got: usize, need: usize },
105    /// The `layout` byte is not `LAYOUT_AOS` or `LAYOUT_SOA`.
106    UnknownLayout { got: u8 },
107    /// A reserved field in the mini-header is non-zero.
108    NonZeroReserved { field: &'static str },
109    /// `node_count` exceeds `MAX_NODE_COUNT`.
110    NodeCountTooLarge { got: u32, max: usize },
111    /// The payload is too short for `node_count` records.
112    PayloadTruncated { expected: usize, got: usize },
113    /// A node index is out of range.
114    IndexOutOfRange { index: usize, count: usize },
115}
116
117impl std::fmt::Display for NodeSectionError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            Self::PayloadTooShort { got, need } => {
121                write!(f, "10d NODE payload too short: got {got}, need {need}")
122            }
123            Self::UnknownLayout { got } => write!(
124                f,
125                "10d NODE unknown layout byte {got} (expected 0=AoS or 1=SoA)"
126            ),
127            Self::NonZeroReserved { field } => {
128                write!(f, "10d NODE non-zero reserved field {field:?}")
129            }
130            Self::NodeCountTooLarge { got, max } => {
131                write!(f, "10d NODE node_count {got} exceeds max {max}")
132            }
133            Self::PayloadTruncated { expected, got } => write!(
134                f,
135                "10d NODE payload truncated: expected {expected}, got {got}"
136            ),
137            Self::IndexOutOfRange { index, count } => {
138                write!(f, "10d NODE index {index} out of range (count={count})")
139            }
140        }
141    }
142}
143
144impl std::error::Error for NodeSectionError {}
145
146/// Parse and validate the NODE-section mini-header. Returns the header and the
147/// total payload byte length it claims.
148pub fn parse_node_header(payload: &[u8]) -> Result<(NodeMiniHeader, usize), NodeSectionError> {
149    if payload.len() < NODE_MINI_HEADER_SIZE {
150        return Err(NodeSectionError::PayloadTooShort {
151            got: payload.len(),
152            need: NODE_MINI_HEADER_SIZE,
153        });
154    }
155    let header: NodeMiniHeader = *bytemuck::from_bytes(&payload[..NODE_MINI_HEADER_SIZE]);
156    if header.layout != LAYOUT_AOS && header.layout != LAYOUT_SOA {
157        return Err(NodeSectionError::UnknownLayout { got: header.layout });
158    }
159    if header.reserved_u8 != 0 {
160        return Err(NodeSectionError::NonZeroReserved {
161            field: "reserved_u8",
162        });
163    }
164    if header.reserved_u16 != 0 {
165        return Err(NodeSectionError::NonZeroReserved {
166            field: "reserved_u16",
167        });
168    }
169    if header.reserved_u64 != 0 {
170        return Err(NodeSectionError::NonZeroReserved {
171            field: "reserved_u64",
172        });
173    }
174    let count = header.node_count as usize;
175    if count > MAX_NODE_COUNT {
176        return Err(NodeSectionError::NodeCountTooLarge {
177            got: header.node_count,
178            max: MAX_NODE_COUNT,
179        });
180    }
181    let total = NodeMiniHeader::payload_bytes(count);
182    if payload.len() < total {
183        return Err(NodeSectionError::PayloadTruncated {
184            expected: total,
185            got: payload.len(),
186        });
187    }
188    Ok((header, total))
189}
190
191/// Write a tensor set as a NODE section in AoS layout into a caller-supplied
192/// buffer. Returns the bytes written. Zero-heap.
193pub fn write_node_section_aos(
194    tensors: &[Tensor10D],
195    out: &mut [u8],
196) -> Result<usize, NodeSectionError> {
197    let need = NodeMiniHeader::payload_bytes(tensors.len());
198    if out.len() < need {
199        return Err(NodeSectionError::PayloadTruncated {
200            expected: need,
201            got: out.len(),
202        });
203    }
204    if tensors.len() > MAX_NODE_COUNT {
205        return Err(NodeSectionError::NodeCountTooLarge {
206            got: tensors.len() as u32,
207            max: MAX_NODE_COUNT,
208        });
209    }
210    let header = NodeMiniHeader {
211        node_count: tensors.len() as u32,
212        layout: LAYOUT_AOS,
213        reserved_u8: 0,
214        reserved_u16: 0,
215        reserved_u64: 0,
216    };
217    let header_bytes: &[u8; NODE_MINI_HEADER_SIZE] = bytemuck::cast_ref(&header);
218    out[..NODE_MINI_HEADER_SIZE].copy_from_slice(header_bytes);
219    let mut off = NODE_MINI_HEADER_SIZE;
220    for t in tensors {
221        // bytemuck only Pod-implements [u8; N] for N<=32; use bytes_of for 40-byte Tensor10D.
222        let tb = bytemuck::bytes_of(t);
223        debug_assert_eq!(tb.len(), TENSOR10D_SIZE);
224        out[off..off + TENSOR10D_SIZE].copy_from_slice(tb);
225        off += TENSOR10D_SIZE;
226    }
227    Ok(off)
228}
229
230/// Write a tensor set as a NODE section in SoA layout into a caller-supplied
231/// buffer. Returns the bytes written. Zero-heap.
232///
233/// SoA lane layout: lane `axis` occupies bytes `[16 + axis*N*4 .. 16 + (axis+1)*N*4)`.
234/// Lane 0 = `q`, lane 1 = `v`, …, lane 9 = `σ` (matching `AXIS_ORDER`).
235pub fn write_node_section_soa(
236    tensors: &[Tensor10D],
237    out: &mut [u8],
238) -> Result<usize, NodeSectionError> {
239    let need = NodeMiniHeader::payload_bytes(tensors.len());
240    if out.len() < need {
241        return Err(NodeSectionError::PayloadTruncated {
242            expected: need,
243            got: out.len(),
244        });
245    }
246    if tensors.len() > MAX_NODE_COUNT {
247        return Err(NodeSectionError::NodeCountTooLarge {
248            got: tensors.len() as u32,
249            max: MAX_NODE_COUNT,
250        });
251    }
252    let n = tensors.len();
253    let header = NodeMiniHeader {
254        node_count: n as u32,
255        layout: LAYOUT_SOA,
256        reserved_u8: 0,
257        reserved_u16: 0,
258        reserved_u64: 0,
259    };
260    let header_bytes: &[u8; NODE_MINI_HEADER_SIZE] = bytemuck::cast_ref(&header);
261    out[..NODE_MINI_HEADER_SIZE].copy_from_slice(header_bytes);
262    // For each axis, write the lane: out[16 + axis*n*4 + j*4] = tensors[j].field[axis]
263    for axis in 0..AXIS_COUNT {
264        let lane_start = NODE_MINI_HEADER_SIZE + axis * n * 4;
265        for j in 0..n {
266            let val = tensor_field(tensors[j], axis);
267            let off = lane_start + j * 4;
268            out[off..off + 4].copy_from_slice(&val.to_le_bytes());
269        }
270    }
271    Ok(need)
272}
273
274/// Read one `Tensor10D` by index from a NODE section payload (dispatches on
275/// layout). Zero-heap.
276pub fn read_node(payload: &[u8], index: usize) -> Result<Tensor10D, NodeSectionError> {
277    let (header, _) = parse_node_header(payload)?;
278    let count = header.node_count as usize;
279    if index >= count {
280        return Err(NodeSectionError::IndexOutOfRange { index, count });
281    }
282    match header.layout {
283        LAYOUT_AOS => read_node_aos(payload, index),
284        LAYOUT_SOA => read_node_soa(payload, index),
285        _ => Err(NodeSectionError::UnknownLayout { got: header.layout }),
286    }
287}
288
289/// Read one `Tensor10D` by index from an AoS-layout NODE section. Zero-heap.
290pub fn read_node_aos(payload: &[u8], index: usize) -> Result<Tensor10D, NodeSectionError> {
291    let (header, _) = parse_node_header(payload)?;
292    let count = header.node_count as usize;
293    if index >= count {
294        return Err(NodeSectionError::IndexOutOfRange { index, count });
295    }
296    let off = NODE_MINI_HEADER_SIZE + index * TENSOR10D_SIZE;
297    Ok(*bytemuck::from_bytes(&payload[off..off + TENSOR10D_SIZE]))
298}
299
300/// Read one `f32` lane value (axis `axis`, node `index`) from an SoA-layout
301/// NODE section. Zero-heap. This is the "per-axis SoA lane read" the P0.5
302/// acceptance gate names.
303pub fn read_node_soa_lane(
304    payload: &[u8],
305    axis: usize,
306    index: usize,
307) -> Result<f32, NodeSectionError> {
308    if axis >= AXIS_COUNT {
309        return Err(NodeSectionError::IndexOutOfRange {
310            index: axis,
311            count: AXIS_COUNT,
312        });
313    }
314    let (header, _) = parse_node_header(payload)?;
315    let count = header.node_count as usize;
316    if index >= count {
317        return Err(NodeSectionError::IndexOutOfRange { index, count });
318    }
319    let n = count;
320    let off = NODE_MINI_HEADER_SIZE + axis * n * 4 + index * 4;
321    Ok(f32::from_le_bytes(
322        payload[off..off + 4].try_into().unwrap(),
323    ))
324}
325
326/// Read one `Tensor10D` by index from an SoA-layout NODE section (assembles
327/// from ten lane reads). Zero-heap.
328pub fn read_node_soa(payload: &[u8], index: usize) -> Result<Tensor10D, NodeSectionError> {
329    let (header, _) = parse_node_header(payload)?;
330    let count = header.node_count as usize;
331    if index >= count {
332        return Err(NodeSectionError::IndexOutOfRange { index, count });
333    }
334    let mut t = Tensor10D::default();
335    for axis in 0..AXIS_COUNT {
336        let val = read_node_soa_lane(payload, axis, index)?;
337        set_tensor_field(&mut t, axis, val);
338    }
339    Ok(t)
340}
341
342/// Write the `q` field (axis 0) for one node in a NODE section — the
343/// wavefunction-collapse semantics matching `tensor/buffer_export.rs::
344/// write_tensor_q_at`. Returns the previous `q` value. Works on either layout.
345/// Zero-heap.
346pub fn write_node_q_at(payload: &mut [u8], index: usize, q: f32) -> Result<f32, NodeSectionError> {
347    let (header, _) = parse_node_header(payload)?;
348    let count = header.node_count as usize;
349    if index >= count {
350        return Err(NodeSectionError::IndexOutOfRange { index, count });
351    }
352    let n = count;
353    let off = match header.layout {
354        LAYOUT_AOS => NODE_MINI_HEADER_SIZE + index * TENSOR10D_SIZE,
355        LAYOUT_SOA => NODE_MINI_HEADER_SIZE + 0 * n * 4 + index * 4, // lane 0 = q
356        _ => return Err(NodeSectionError::UnknownLayout { got: header.layout }),
357    };
358    let prev = f32::from_le_bytes(payload[off..off + 4].try_into().unwrap());
359    payload[off..off + 4].copy_from_slice(&q.to_le_bytes());
360    Ok(prev)
361}
362
363/// Transpose an AoS-layout NODE section payload to SoA into a caller-supplied
364/// output buffer. The output buffer must be at least `total` bytes. Zero-heap.
365/// This is the primary AoS→SoA path.
366pub fn transpose_aos_to_soa(payload: &[u8], out: &mut [u8]) -> Result<usize, NodeSectionError> {
367    let (header, total) = parse_node_header(payload)?;
368    if header.layout != LAYOUT_AOS {
369        return Err(NodeSectionError::UnknownLayout { got: header.layout });
370    }
371    if out.len() < total {
372        return Err(NodeSectionError::PayloadTruncated {
373            expected: total,
374            got: out.len(),
375        });
376    }
377    let n = header.node_count as usize;
378    // Write the SoA mini-header.
379    let soa_header = NodeMiniHeader {
380        node_count: header.node_count,
381        layout: LAYOUT_SOA,
382        reserved_u8: 0,
383        reserved_u16: 0,
384        reserved_u64: 0,
385    };
386    let header_bytes: &[u8; NODE_MINI_HEADER_SIZE] = bytemuck::cast_ref(&soa_header);
387    out[..NODE_MINI_HEADER_SIZE].copy_from_slice(header_bytes);
388    // For each axis, for each node, read the field from the AoS record and
389    // write it to the SoA lane.
390    for axis in 0..AXIS_COUNT {
391        let lane_start = NODE_MINI_HEADER_SIZE + axis * n * 4;
392        for j in 0..n {
393            let aos_off = NODE_MINI_HEADER_SIZE + j * TENSOR10D_SIZE + axis * 4;
394            let val = f32::from_le_bytes(payload[aos_off..aos_off + 4].try_into().unwrap());
395            let off = lane_start + j * 4;
396            out[off..off + 4].copy_from_slice(&val.to_le_bytes());
397        }
398    }
399    Ok(total)
400}
401
402/// Transpose an SoA-layout NODE section payload to AoS into a caller-supplied
403/// output buffer. The output buffer must be at least `total` bytes. Zero-heap.
404pub fn transpose_soa_to_aos(payload: &[u8], out: &mut [u8]) -> Result<usize, NodeSectionError> {
405    let (header, total) = parse_node_header(payload)?;
406    if header.layout != LAYOUT_SOA {
407        return Err(NodeSectionError::UnknownLayout { got: header.layout });
408    }
409    if out.len() < total {
410        return Err(NodeSectionError::PayloadTruncated {
411            expected: total,
412            got: out.len(),
413        });
414    }
415    let n = header.node_count as usize;
416    let aos_header = NodeMiniHeader {
417        node_count: header.node_count,
418        layout: LAYOUT_AOS,
419        reserved_u8: 0,
420        reserved_u16: 0,
421        reserved_u64: 0,
422    };
423    let header_bytes: &[u8; NODE_MINI_HEADER_SIZE] = bytemuck::cast_ref(&aos_header);
424    out[..NODE_MINI_HEADER_SIZE].copy_from_slice(header_bytes);
425    for j in 0..n {
426        let record_off = NODE_MINI_HEADER_SIZE + j * TENSOR10D_SIZE;
427        for axis in 0..AXIS_COUNT {
428            let lane_off = NODE_MINI_HEADER_SIZE + axis * n * 4 + j * 4;
429            let val = f32::from_le_bytes(payload[lane_off..lane_off + 4].try_into().unwrap());
430            let off = record_off + axis * 4;
431            out[off..off + 4].copy_from_slice(&val.to_le_bytes());
432        }
433    }
434    Ok(total)
435}
436
437// --- helpers ---
438
439/// Read one `f32` field by axis index from a `Tensor10D`.
440#[inline]
441fn tensor_field(t: Tensor10D, axis: usize) -> f32 {
442    match axis {
443        0 => t.q,
444        1 => t.v,
445        2 => t.w,
446        3 => t.x,
447        4 => t.y,
448        5 => t.z,
449        6 => t.t,
450        7 => t.alpha,
451        8 => t.mu,
452        9 => t.sigma,
453        _ => unreachable!("axis out of range"),
454    }
455}
456
457/// Set one `f32` field by axis index on a `Tensor10D`.
458#[inline]
459fn set_tensor_field(t: &mut Tensor10D, axis: usize, val: f32) {
460    match axis {
461        0 => t.q = val,
462        1 => t.v = val,
463        2 => t.w = val,
464        3 => t.x = val,
465        4 => t.y = val,
466        5 => t.z = val,
467        6 => t.t = val,
468        7 => t.alpha = val,
469        8 => t.mu = val,
470        9 => t.sigma = val,
471        _ => unreachable!("axis out of range"),
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    // Axis *names* (for bit-exact assertion messages) — only the numeric AXIS_COUNT is used by the
479    // production transpose, so AXIS_ORDER lives with the tests that reference it.
480    use crate::container_10d::axis_role::AXIS_ORDER;
481    use crate::container_10d::crc32c::crc32c;
482    use crate::container_10d::header::Container10dHeader;
483    use crate::container_10d::section::{
484        encode_container, parse_section_table, AlignmentTier, SectionInput, SectionType,
485    };
486
487    fn sample_tensors() -> [Tensor10D; 3] {
488        [
489            Tensor10D::new(0.0, 0.0, 0.0, 0.1, 0.2, 0.3, 0.0, 1.0, 0.0, 0.5),
490            Tensor10D::new(0.5, 1.0, 2.0, 0.4, 0.5, 0.6, 1.0, 0.8, 0.2, 0.75),
491            Tensor10D::new(999.0, 2.0, 3.0, 0.7, 0.8, 0.9, 2.0, 0.6, 0.9, 0.25), // Sandbox q
492        ]
493    }
494
495    #[test]
496    fn mini_header_is_pod_with_exact_size() {
497        assert_eq!(std::mem::size_of::<NodeMiniHeader>(), NODE_MINI_HEADER_SIZE);
498        assert_eq!(std::mem::offset_of!(NodeMiniHeader, node_count), 0);
499        assert_eq!(std::mem::offset_of!(NodeMiniHeader, layout), 4);
500        assert_eq!(std::mem::offset_of!(NodeMiniHeader, reserved_u8), 5);
501        assert_eq!(std::mem::offset_of!(NodeMiniHeader, reserved_u16), 6);
502        assert_eq!(std::mem::offset_of!(NodeMiniHeader, reserved_u64), 8);
503    }
504
505    #[test]
506    fn aos_section_reads_back_tensor10d_for_tensor10d_identical() {
507        let tensors = sample_tensors();
508        let need = NodeMiniHeader::payload_bytes(tensors.len());
509        let mut payload = vec![0u8; need];
510        let n = write_node_section_aos(&tensors, &mut payload).expect("aos write");
511        assert_eq!(n, need);
512        for i in 0..tensors.len() {
513            let read = read_node(&payload, i).expect("aos read");
514            assert_eq!(read, tensors[i], "AoS node {i} must read back identical");
515        }
516    }
517
518    #[test]
519    fn soa_section_reads_back_tensor10d_for_tensor10d_identical() {
520        let tensors = sample_tensors();
521        let need = NodeMiniHeader::payload_bytes(tensors.len());
522        let mut payload = vec![0u8; need];
523        let n = write_node_section_soa(&tensors, &mut payload).expect("soa write");
524        assert_eq!(n, need);
525        for i in 0..tensors.len() {
526            let read = read_node(&payload, i).expect("soa read");
527            assert_eq!(read, tensors[i], "SoA node {i} must read back identical");
528        }
529    }
530
531    #[test]
532    fn per_axis_soa_lane_reads_match_aos_field_reads() {
533        let tensors = sample_tensors();
534        let need = NodeMiniHeader::payload_bytes(tensors.len());
535        let mut aos = vec![0u8; need];
536        let mut soa = vec![0u8; need];
537        write_node_section_aos(&tensors, &mut aos).expect("aos write");
538        write_node_section_soa(&tensors, &mut soa).expect("soa write");
539        for axis in 0..AXIS_COUNT {
540            for j in 0..tensors.len() {
541                let aos_val = tensor_field(read_node_aos(&aos, j).expect("aos read"), axis);
542                let soa_val = read_node_soa_lane(&soa, axis, j).expect("soa lane read");
543                assert_eq!(
544                    aos_val.to_bits(),
545                    soa_val.to_bits(),
546                    "axis {} ({}) node {} : AoS field read must match SoA lane read (bit-exact)",
547                    axis,
548                    AXIS_ORDER[axis],
549                    j
550                );
551            }
552        }
553    }
554
555    #[test]
556    fn aos_to_soa_to_aos_is_byte_identical() {
557        let tensors = sample_tensors();
558        let need = NodeMiniHeader::payload_bytes(tensors.len());
559        let mut aos = vec![0u8; need];
560        let mut soa = vec![0u8; need];
561        let mut aos2 = vec![0u8; need];
562        write_node_section_aos(&tensors, &mut aos).expect("aos write");
563        transpose_aos_to_soa(&aos, &mut soa).expect("aos->soa");
564        transpose_soa_to_aos(&soa, &mut aos2).expect("soa->aos");
565        assert_eq!(&aos[..], &aos2[..], "AoS→SoA→AoS must be byte-identical");
566    }
567
568    #[test]
569    fn soa_to_aos_to_soa_is_byte_identical() {
570        let tensors = sample_tensors();
571        let need = NodeMiniHeader::payload_bytes(tensors.len());
572        let mut soa = vec![0u8; need];
573        let mut aos = vec![0u8; need];
574        let mut soa2 = vec![0u8; need];
575        write_node_section_soa(&tensors, &mut soa).expect("soa write");
576        transpose_soa_to_aos(&soa, &mut aos).expect("soa->aos");
577        transpose_aos_to_soa(&aos, &mut soa2).expect("aos->soa");
578        assert_eq!(&soa[..], &soa2[..], "SoA→AoS→SoA must be byte-identical");
579    }
580
581    #[test]
582    fn aos_and_soa_payloads_have_identical_crc() {
583        // Same tensor set, two layouts — the CRC over the payload differs
584        // (the byte order differs), but the CRC over the *semantic content*
585        // (the tensor values) is the same. The P0.5 gate is that both layouts
586        // are valid; the CRC is per-layout. This test confirms both payloads
587        // are deterministic (same input → same CRC) and that the two layouts
588        // are NOT byte-identical (they're different byte orderings of the same
589        // values).
590        let tensors = sample_tensors();
591        let need = NodeMiniHeader::payload_bytes(tensors.len());
592        let mut aos_a = vec![0u8; need];
593        let mut aos_b = vec![0u8; need];
594        let mut soa_a = vec![0u8; need];
595        let mut soa_b = vec![0u8; need];
596        write_node_section_aos(&tensors, &mut aos_a).expect("aos a");
597        write_node_section_aos(&tensors, &mut aos_b).expect("aos b");
598        write_node_section_soa(&tensors, &mut soa_a).expect("soa a");
599        write_node_section_soa(&tensors, &mut soa_b).expect("soa b");
600        // Determinism: same layout twice = same bytes.
601        assert_eq!(&aos_a[..], &aos_b[..], "AoS must be deterministic");
602        assert_eq!(&soa_a[..], &soa_b[..], "SoA must be deterministic");
603        // The two layouts are different byte orderings (not byte-identical).
604        assert_ne!(&aos_a[..], &soa_a[..], "AoS and SoA are different layouts");
605        // But both have a valid CRC (the per-section CRC is computed by the
606        // section-table writer; here we just confirm the payload is stable).
607        assert_eq!(crc32c(&aos_a[..]), crc32c(&aos_b[..]));
608        assert_eq!(crc32c(&soa_a[..]), crc32c(&soa_b[..]));
609    }
610
611    #[test]
612    fn write_node_q_at_aos_matches_buffer_export_semantics() {
613        let mut tensors = sample_tensors();
614        let need = NodeMiniHeader::payload_bytes(tensors.len());
615        let mut payload = vec![0u8; need];
616        write_node_section_aos(&tensors, &mut payload).expect("aos write");
617        // Collapse node 1's q to 0.0 (ground truth). Prev q should be 0.5.
618        let prev = write_node_q_at(&mut payload, 1, 0.0).expect("q write");
619        assert!((prev - 0.5).abs() < 1e-6, "prev q must be 0.5, got {prev}");
620        let t = read_node(&payload, 1).expect("read after collapse");
621        assert!(t.q.abs() < 1e-6, "q must be collapsed to 0.0");
622        // The other fields are unchanged.
623        assert!((t.x - 0.4).abs() < 1e-6);
624        // Mirror the same collapse on the source array for consistency.
625        tensors[1].q = 0.0;
626        assert_eq!(read_node(&payload, 1).expect("read"), tensors[1]);
627    }
628
629    #[test]
630    fn write_node_q_at_soa_matches_buffer_export_semantics() {
631        let mut tensors = sample_tensors();
632        let need = NodeMiniHeader::payload_bytes(tensors.len());
633        let mut payload = vec![0u8; need];
634        write_node_section_soa(&tensors, &mut payload).expect("soa write");
635        // Collapse node 2's q (currently 999.0 Sandbox) to 0.0.
636        let prev = write_node_q_at(&mut payload, 2, 0.0).expect("q write");
637        assert!(
638            (prev - 999.0).abs() < 1e-4,
639            "prev q must be 999.0, got {prev}"
640        );
641        let t = read_node(&payload, 2).expect("read after collapse");
642        assert!(t.q.abs() < 1e-6, "q must be collapsed to 0.0");
643        // Other fields unchanged.
644        assert!((t.sigma - 0.25).abs() < 1e-6);
645        tensors[2].q = 0.0;
646        assert_eq!(read_node(&payload, 2).expect("read"), tensors[2]);
647    }
648
649    #[test]
650    fn write_node_q_at_out_of_range_rejects() {
651        let tensors = sample_tensors();
652        let need = NodeMiniHeader::payload_bytes(tensors.len());
653        let mut payload = vec![0u8; need];
654        write_node_section_aos(&tensors, &mut payload).expect("aos write");
655        let err = write_node_q_at(&mut payload, 99, 0.0).expect_err("oob must reject");
656        assert!(
657            matches!(err, NodeSectionError::IndexOutOfRange { .. }),
658            "{err}"
659        );
660    }
661
662    #[test]
663    fn node_section_round_trips_through_10d_container_with_per_section_crc() {
664        // Wrap a NODE section in a full .10d container and round-trip it
665        // through the P0.2 section table (per-section CRC) + P0.3 whole-file
666        // CRC. This is the integration test that P0.5 + P0.2 + P0.3 work
667        // together.
668        let tensors = sample_tensors();
669        let node_need = NodeMiniHeader::payload_bytes(tensors.len());
670        let mut node_payload = vec![0u8; node_need];
671        write_node_section_soa(&tensors, &mut node_payload).expect("soa write");
672
673        let h = Container10dHeader::proposed();
674        let inputs = [SectionInput {
675            section_type: SectionType::Tensor10DNodes,
676            alignment_tier: AlignmentTier::CacheLine,
677            stride: 0, // blob at the section-table level (mini-header inside)
678            element_count: 0,
679            payload: &node_payload,
680        }];
681        let mut out = vec![0u8; 512];
682        let n = encode_container(&h, &inputs, &mut out).expect("container encode");
683        crate::container_10d::integrity::seal_whole_file_crc32c(&mut out[..n]);
684
685        // Verify whole-file CRC.
686        crate::container_10d::integrity::verify_whole_file_crc32c(&mut out[..n])
687            .expect("whole-file CRC");
688
689        let parsed_h = Container10dHeader::parse(&out[..n]).expect("header parse");
690        let descs = parse_section_table(&out[..n], &parsed_h).expect("table parse");
691        assert_eq!(descs.len(), 1);
692        assert_eq!(descs[0].section_type, SectionType::Tensor10DNodes as u8);
693
694        // Extract the NODE payload and read the tensors back.
695        let p_off = descs[0].byte_offset as usize;
696        let p_len = descs[0].byte_length as usize;
697        let node_payload_back = &out[p_off..p_off + p_len];
698        for i in 0..tensors.len() {
699            let t = read_node(node_payload_back, i).expect("node read from container");
700            assert_eq!(t, tensors[i], "container round-trip node {i}");
701        }
702    }
703
704    #[test]
705    fn flipped_payload_bit_in_node_section_is_caught_by_per_section_crc() {
706        let tensors = sample_tensors();
707        let node_need = NodeMiniHeader::payload_bytes(tensors.len());
708        let mut node_payload = vec![0u8; node_need];
709        write_node_section_aos(&tensors, &mut node_payload).expect("aos write");
710
711        let h = Container10dHeader::proposed();
712        let inputs = [SectionInput {
713            section_type: SectionType::Tensor10DNodes,
714            alignment_tier: AlignmentTier::CacheLine,
715            stride: 0,
716            element_count: 0,
717            payload: &node_payload,
718        }];
719        let mut out = vec![0u8; 512];
720        let n = encode_container(&h, &inputs, &mut out).expect("encode");
721        let parsed_h = Container10dHeader::parse(&out[..n]).expect("header parse");
722        let descs = parse_section_table(&out[..n], &parsed_h).expect("clean table parses");
723        let p_off = descs[0].byte_offset as usize;
724        // Flip a bit in the NODE payload (inside the tensor data, past the mini-header).
725        out[p_off + NODE_MINI_HEADER_SIZE + 5] ^= 0x01;
726        let err =
727            parse_section_table(&out[..n], &parsed_h).expect_err("flipped bit must be caught");
728        assert!(
729            matches!(
730                err,
731                crate::container_10d::section::SectionTableError::CrcMismatch { .. }
732            ),
733            "{err}"
734        );
735    }
736
737    #[test]
738    fn unknown_layout_is_rejected() {
739        let mut payload = vec![0u8; NODE_MINI_HEADER_SIZE + 40];
740        let mut header = NodeMiniHeader::zeroed();
741        header.node_count = 1;
742        header.layout = 99; // unknown
743        let header_bytes: &[u8; NODE_MINI_HEADER_SIZE] = bytemuck::cast_ref(&header);
744        payload[..NODE_MINI_HEADER_SIZE].copy_from_slice(header_bytes);
745        let err = parse_node_header(&payload).expect_err("unknown layout must reject");
746        assert!(
747            matches!(err, NodeSectionError::UnknownLayout { got: 99 }),
748            "{err}"
749        );
750    }
751
752    #[test]
753    fn non_zero_reserved_field_is_rejected() {
754        let mut payload = vec![0u8; NODE_MINI_HEADER_SIZE + 40];
755        let mut header = NodeMiniHeader::zeroed();
756        header.node_count = 1;
757        header.layout = LAYOUT_AOS;
758        header.reserved_u64 = 1;
759        let header_bytes: &[u8; NODE_MINI_HEADER_SIZE] = bytemuck::cast_ref(&header);
760        payload[..NODE_MINI_HEADER_SIZE].copy_from_slice(header_bytes);
761        let err = parse_node_header(&payload).expect_err("non-zero reserved must reject");
762        assert!(
763            matches!(err, NodeSectionError::NonZeroReserved { .. }),
764            "{err}"
765        );
766    }
767
768    #[test]
769    fn node_count_too_large_is_rejected() {
770        let mut payload = vec![0u8; NODE_MINI_HEADER_SIZE];
771        let mut header = NodeMiniHeader::zeroed();
772        header.node_count = (MAX_NODE_COUNT + 1) as u32;
773        header.layout = LAYOUT_AOS;
774        let header_bytes: &[u8; NODE_MINI_HEADER_SIZE] = bytemuck::cast_ref(&header);
775        payload[..NODE_MINI_HEADER_SIZE].copy_from_slice(header_bytes);
776        let err = parse_node_header(&payload).expect_err("too-large count must reject");
777        assert!(
778            matches!(err, NodeSectionError::NodeCountTooLarge { .. }),
779            "{err}"
780        );
781    }
782
783    #[test]
784    fn empty_node_section_round_trips() {
785        let tensors: [Tensor10D; 0] = [];
786        let need = NodeMiniHeader::payload_bytes(0);
787        assert_eq!(need, NODE_MINI_HEADER_SIZE);
788        let mut payload = vec![0u8; need];
789        write_node_section_aos(&tensors, &mut payload).expect("empty aos write");
790        let (header, total) = parse_node_header(&payload).expect("empty parse");
791        assert_eq!(header.node_count, 0);
792        assert_eq!(total, NODE_MINI_HEADER_SIZE);
793    }
794}