mirror of
https://github.com/ruvnet/RuView
synced 2026-08-08 20:11:43 +00:00
237325a117
The training/firmware boundary needs a stable serialization for the
temporal head's weights, distinct from the kernel scaffold and the
firmware ABI. This commit defines that format on the host side. The
firmware-side mirrored loader lands when the toolchain unblocks.
Format:
- Header (24 B): magic 'RVNE' / version 1 / dtype flag
(FP32 / FP16) / input_dim / n_q_heads / n_kv_heads / head_dim /
n_layers / n_classes / weights_len.
- Body: weights_len bytes of flat per-layer weights.
- Footer (4 B): CRC32 IEEE 802.3 over everything before, same
polynomial used by temporal_task.c so a blob produced here parses
on the firmware unchanged.
Layout decisions:
- Little-endian throughout (Xtensa native).
- Weights kept as Vec<u8> rather than Vec<f32>/Vec<f16> so the no_std
firmware loader (which may not have the `half` crate) can mmap and
read either dtype directly.
- Versioning is hard-break: bumping `version` means firmware refuses
to load. Optional fields go behind reserved flag bits, never by
field reorder. Documented inline.
Validation surface:
- `WeightBlobHeader::validate()` catches zero dims, invalid GQA
ratios (n_q_heads % n_kv_heads != 0), n_layers=0, n_classes<2.
Same checks fire from `WeightBlob::parse()` so the firmware can't
accidentally accept a blob the host should have rejected.
- `WeightBlob::parse()` enforces magic / version / size / CRC
before exposing weights to the caller.
Tests (8/8 passing, alongside 5/5 sparse smoke = 13/13 total):
- roundtrip_fp32, roundtrip_fp16
- parse_rejects_bad_magic, _wrong_version, _size_mismatch,
_crc_corruption, _invalid_gqa_ratio_in_header
- header_constants_match_wire_layout (anchor)
What's deliberately NOT in this commit:
- The firmware-side mirrored loader (deferred to the iteration that
unblocks the esp Rust toolchain — no point shipping a parser that
can't be compiled).
- Per-layer weight ordering. The blob is a flat byte-buffer; the
interpretation of per-layer offsets is the kernel's contract,
documented in the eventual model module (ADR-095 §3.2 follow-up).
Co-Authored-By: claude-flow <ruv@ruv.net>
141 lines
4.9 KiB
Rust
141 lines
4.9 KiB
Rust
//! Roundtrip + corruption-detection tests for the temporal head's
|
|
//! weight-blob wire format. The format is the contract between
|
|
//! host-side training and firmware-side inference — when this test
|
|
//! file changes, both ends update in lockstep.
|
|
|
|
use wifi_densepose_temporal::{
|
|
WeightBlob, WeightBlobHeader, WeightDtype, WEIGHT_BLOB_HEADER_LEN, WEIGHT_BLOB_MAGIC,
|
|
WEIGHT_BLOB_VERSION,
|
|
};
|
|
|
|
fn header_default() -> WeightBlobHeader {
|
|
WeightBlobHeader {
|
|
dtype: WeightDtype::F32,
|
|
input_dim: 16,
|
|
n_q_heads: 4,
|
|
n_kv_heads: 1,
|
|
head_dim: 32,
|
|
n_layers: 2,
|
|
n_classes: 4,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_fp32() {
|
|
let header = header_default();
|
|
let weights: Vec<u8> = (0..1024).map(|i| (i & 0xFF) as u8).collect();
|
|
let blob = WeightBlob::new(header, weights).expect("ok");
|
|
let serialized = blob.serialize();
|
|
let parsed = WeightBlob::parse(&serialized).expect("parse");
|
|
assert_eq!(parsed.header.input_dim, 16);
|
|
assert_eq!(parsed.header.n_q_heads, 4);
|
|
assert_eq!(parsed.header.n_kv_heads, 1);
|
|
assert_eq!(parsed.header.head_dim, 32);
|
|
assert_eq!(parsed.header.n_layers, 2);
|
|
assert_eq!(parsed.header.n_classes, 4);
|
|
assert_eq!(parsed.header.dtype, WeightDtype::F32);
|
|
assert_eq!(parsed.weights.len(), 1024);
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_fp16() {
|
|
let header = WeightBlobHeader {
|
|
dtype: WeightDtype::F16,
|
|
..header_default()
|
|
};
|
|
// FP16 means 2 bytes per element — 512 bytes = 256 elements.
|
|
let weights: Vec<u8> = (0..512).map(|i| (i & 0xFF) as u8).collect();
|
|
let blob = WeightBlob::new(header, weights).expect("ok");
|
|
let serialized = blob.serialize();
|
|
let parsed = WeightBlob::parse(&serialized).expect("parse");
|
|
assert_eq!(parsed.header.dtype, WeightDtype::F16);
|
|
assert_eq!(parsed.weights.len(), 512);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_bad_magic() {
|
|
let header = header_default();
|
|
let blob = WeightBlob::new(header, vec![0u8; 16]).expect("ok");
|
|
let mut bytes = blob.serialize();
|
|
bytes[0] = 0xFF; // corrupt magic
|
|
let err = WeightBlob::parse(&bytes).err().expect("rejected");
|
|
assert!(format!("{err}").contains("magic"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_wrong_version() {
|
|
let header = header_default();
|
|
let blob = WeightBlob::new(header, vec![0u8; 16]).expect("ok");
|
|
let mut bytes = blob.serialize();
|
|
bytes[4] = 99; // bump version
|
|
bytes[5] = 0;
|
|
let err = WeightBlob::parse(&bytes).err().expect("rejected");
|
|
assert!(format!("{err}").contains("version"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_size_mismatch() {
|
|
let header = header_default();
|
|
let blob = WeightBlob::new(header, vec![0u8; 64]).expect("ok");
|
|
let mut bytes = blob.serialize();
|
|
// truncate the weights region by 4 bytes — total length now
|
|
// doesn't match the weights_len field.
|
|
bytes.drain(WEIGHT_BLOB_HEADER_LEN..WEIGHT_BLOB_HEADER_LEN + 4);
|
|
let err = WeightBlob::parse(&bytes).err().expect("rejected");
|
|
assert!(format!("{err}").contains("length") || format!("{err}").contains("CRC"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_crc_corruption() {
|
|
let header = header_default();
|
|
let blob = WeightBlob::new(header, vec![0xAAu8; 32]).expect("ok");
|
|
let mut bytes = blob.serialize();
|
|
// flip a bit in the middle of the weights region
|
|
let mid = WEIGHT_BLOB_HEADER_LEN + 5;
|
|
bytes[mid] ^= 0x01;
|
|
let err = WeightBlob::parse(&bytes).err().expect("rejected");
|
|
assert!(format!("{err}").contains("CRC"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_invalid_gqa_ratio_in_header() {
|
|
// Manually craft bytes where n_q_heads % n_kv_heads != 0 to ensure
|
|
// header.validate() fires from inside parse(). Easiest: build a
|
|
// valid blob then patch the n_kv_heads field.
|
|
let header = header_default();
|
|
let blob = WeightBlob::new(header, vec![0u8; 16]).expect("ok");
|
|
let mut bytes = blob.serialize();
|
|
// n_kv_heads is at offset 12..14; set it to 3 so 4 % 3 != 0.
|
|
bytes[12] = 3;
|
|
bytes[13] = 0;
|
|
// Re-CRC so we can be sure the validator (not the CRC) is what
|
|
// rejects this case.
|
|
let new_crc = crc32_ieee(&bytes[..bytes.len() - 4]);
|
|
let crc_off = bytes.len() - 4;
|
|
bytes[crc_off..].copy_from_slice(&new_crc.to_le_bytes());
|
|
let err = WeightBlob::parse(&bytes).err().expect("rejected");
|
|
assert!(format!("{err}").to_lowercase().contains("gqa"));
|
|
}
|
|
|
|
#[test]
|
|
fn header_constants_match_wire_layout() {
|
|
// Anchor the public constants so they can't drift silently.
|
|
assert_eq!(WEIGHT_BLOB_MAGIC, 0x5256_4E45);
|
|
assert_eq!(WEIGHT_BLOB_VERSION, 1);
|
|
assert_eq!(WEIGHT_BLOB_HEADER_LEN, 24);
|
|
}
|
|
|
|
// Mirror of the production CRC32 so the size-mismatch / GQA tests can
|
|
// re-CRC after their patch. Kept out of the public API.
|
|
fn crc32_ieee(data: &[u8]) -> u32 {
|
|
let mut crc = 0xFFFF_FFFFu32;
|
|
for &b in data {
|
|
crc ^= b as u32;
|
|
for _ in 0..8 {
|
|
let mask = 0u32.wrapping_sub(crc & 1);
|
|
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
|
|
}
|
|
}
|
|
!crc
|
|
}
|