1use bytemuck::{Pod, Zeroable};
48
49use crate::tensor::Tensor10D;
50
51pub const NODE_MINI_HEADER_SIZE: usize = 16;
53
54pub const TENSOR10D_SIZE: usize = 40;
56
57pub const AXIS_COUNT: usize = 10;
59
60pub const LAYOUT_AOS: u8 = 0;
62pub const LAYOUT_SOA: u8 = 1;
63
64pub const MAX_NODE_COUNT: usize = 1_048_576; #[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 #[inline]
95 pub const fn payload_bytes(count: usize) -> usize {
96 NODE_MINI_HEADER_SIZE + count * TENSOR10D_SIZE
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum NodeSectionError {
103 PayloadTooShort { got: usize, need: usize },
105 UnknownLayout { got: u8 },
107 NonZeroReserved { field: &'static str },
109 NodeCountTooLarge { got: u32, max: usize },
111 PayloadTruncated { expected: usize, got: usize },
113 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
146pub 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
191pub 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 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
230pub 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 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
274pub 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
289pub 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
300pub 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
326pub 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
342pub 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, _ => 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
363pub 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 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 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
402pub 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#[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#[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 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), ]
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 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 assert_eq!(&aos_a[..], &aos_b[..], "AoS must be deterministic");
602 assert_eq!(&soa_a[..], &soa_b[..], "SoA must be deterministic");
603 assert_ne!(&aos_a[..], &soa_a[..], "AoS and SoA are different layouts");
605 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 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 assert!((t.x - 0.4).abs() < 1e-6);
624 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 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 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 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, 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 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 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 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; 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}