mirror of
https://github.com/ruvnet/RuView
synced 2026-06-26 13:03:19 +00:00
feat(core): canonical frame decoder — capture-to-claim replay (ADR-136)
The encode half of the ADR-136 frame contract existed (ComplexSample, to_canonical_bytes, witness_hash) but there was no decoder: a captured canonical frame could be witnessed but never reconstructed, blocking replay-from-capture. CsiFrame::from_canonical_bytes is the exact inverse: same id, metadata, complex payload, and witness hash (tested as the round-trip law AC7 — the replayed frame re-encodes byte-identically). Amplitude/phase are recomputed from the payload (projections, not independent state). Every malformed-input class fails closed (AC8): header truncation -> Truncated, payload truncation -> PayloadMismatch, unknown discriminants, non-UTF-8 device id, trailing bytes. Nil calibration uuid decodes as None per the documented encoding. Core: 36 tests pass. Workspace gate: 2,937 passed / 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
This commit is contained in:
@@ -616,6 +616,188 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors decoding a frame from its canonical bytes.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum CanonicalDecodeError {
|
||||
/// The buffer ended before the layout was fully read.
|
||||
#[error("canonical buffer truncated at byte {at} (need {need} more)")]
|
||||
Truncated {
|
||||
/// Byte offset where reading failed.
|
||||
at: usize,
|
||||
/// How many more bytes were needed.
|
||||
need: usize,
|
||||
},
|
||||
/// A discriminant byte held an unknown value.
|
||||
#[error("invalid {field} discriminant {value}")]
|
||||
BadDiscriminant {
|
||||
/// Which field failed.
|
||||
field: &'static str,
|
||||
/// The offending byte.
|
||||
value: u8,
|
||||
},
|
||||
/// The device-id bytes were not UTF-8.
|
||||
#[error("device id is not valid UTF-8")]
|
||||
BadDeviceId,
|
||||
/// Shape (nrows × ncols) disagrees with the remaining payload length.
|
||||
#[error("payload length mismatch: shape {rows}x{cols} needs {expect} bytes, found {found}")]
|
||||
PayloadMismatch {
|
||||
/// Declared rows.
|
||||
rows: usize,
|
||||
/// Declared cols.
|
||||
cols: usize,
|
||||
/// Bytes the shape implies.
|
||||
expect: usize,
|
||||
/// Bytes actually present.
|
||||
found: usize,
|
||||
},
|
||||
/// Trailing bytes after the declared payload.
|
||||
#[error("{0} trailing bytes after payload")]
|
||||
TrailingBytes(usize),
|
||||
}
|
||||
|
||||
/// Byte cursor for the canonical layout.
|
||||
struct Cursor<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], CanonicalDecodeError> {
|
||||
if self.b.len() - self.at < n {
|
||||
return Err(CanonicalDecodeError::Truncated {
|
||||
at: self.at,
|
||||
need: n - (self.b.len() - self.at),
|
||||
});
|
||||
}
|
||||
let s = &self.b[self.at..self.at + n];
|
||||
self.at += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, CanonicalDecodeError> {
|
||||
Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, CanonicalDecodeError> {
|
||||
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i64(&mut self) -> Result<i64, CanonicalDecodeError> {
|
||||
Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
|
||||
}
|
||||
fn f32(&mut self) -> Result<f32, CanonicalDecodeError> {
|
||||
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0] as i8)
|
||||
}
|
||||
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
|
||||
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
/// Reconstruct a frame from its [`to_canonical_bytes`] encoding — the
|
||||
/// replay half of the ADR-136 contract. Round-trip law (tested):
|
||||
/// `from_canonical_bytes(f.to_canonical_bytes())` yields a frame with the
|
||||
/// **same id, metadata, payload, and witness hash** as `f`.
|
||||
///
|
||||
/// Amplitude/phase are recomputed from the complex payload (they are
|
||||
/// projections, not independent state).
|
||||
///
|
||||
/// [`to_canonical_bytes`]: crate::traits::CanonicalFrame::to_canonical_bytes
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
|
||||
/// device id, shape/payload disagreement, or trailing bytes — every
|
||||
/// malformed input fails closed.
|
||||
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, CanonicalDecodeError> {
|
||||
let mut c = Cursor { b: bytes, at: 0 };
|
||||
|
||||
let id = FrameId::from_uuid(c.uuid()?);
|
||||
|
||||
let seconds = c.i64()?;
|
||||
let nanos = c.u32()?;
|
||||
let dev_len = c.u32()? as usize;
|
||||
let device_id = core::str::from_utf8(c.take(dev_len)?)
|
||||
.map_err(|_| CanonicalDecodeError::BadDeviceId)?
|
||||
.to_string();
|
||||
let frequency_band = match c.u8()? {
|
||||
0 => FrequencyBand::Band2_4GHz,
|
||||
1 => FrequencyBand::Band5GHz,
|
||||
2 => FrequencyBand::Band6GHz,
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "frequency_band",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let channel = c.u8()?;
|
||||
let bandwidth_mhz = c.u16()?;
|
||||
let tx_antennas = c.u8()?;
|
||||
let rx_antennas = c.u8()?;
|
||||
let spacing_mm = match c.u8()? {
|
||||
1 => Some(c.f32()?),
|
||||
0 => {
|
||||
c.take(4)?; // reserved zero bytes
|
||||
None
|
||||
}
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "spacing_mm",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let rssi_dbm = c.i8()?;
|
||||
let noise_floor_dbm = c.i8()?;
|
||||
let sequence_number = c.u32()?;
|
||||
let cal = c.uuid()?;
|
||||
let calibration_id = if cal == Uuid::nil() { None } else { Some(cal) };
|
||||
let model_id = c.u16()?;
|
||||
let model_version = c.u16()?;
|
||||
|
||||
let rows = c.u32()? as usize;
|
||||
let cols = c.u32()? as usize;
|
||||
let expect = rows.saturating_mul(cols).saturating_mul(16);
|
||||
let found = bytes.len() - c.at;
|
||||
if found < expect {
|
||||
return Err(CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found });
|
||||
}
|
||||
let mut samples = Vec::with_capacity(rows * cols);
|
||||
for _ in 0..rows * cols {
|
||||
let raw: [u8; 16] = c.take(16)?.try_into().unwrap();
|
||||
samples.push(ComplexSample::from_le_bytes(raw).0);
|
||||
}
|
||||
if c.at != bytes.len() {
|
||||
return Err(CanonicalDecodeError::TrailingBytes(bytes.len() - c.at));
|
||||
}
|
||||
let data = Array2::from_shape_vec((rows, cols), samples).map_err(|_| {
|
||||
CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found }
|
||||
})?;
|
||||
|
||||
let metadata = CsiMetadata {
|
||||
timestamp: Timestamp { seconds, nanos },
|
||||
device_id: DeviceId::new(device_id),
|
||||
frequency_band,
|
||||
channel,
|
||||
bandwidth_mhz,
|
||||
antenna_config: AntennaConfig { tx_antennas, rx_antennas, spacing_mm },
|
||||
rssi_dbm,
|
||||
noise_floor_dbm,
|
||||
sequence_number,
|
||||
calibration_id,
|
||||
model_id,
|
||||
model_version,
|
||||
};
|
||||
|
||||
let amplitude = data.mapv(num_complex::Complex::norm);
|
||||
let phase = data.mapv(num_complex::Complex::arg);
|
||||
Ok(Self { id, metadata, data, amplitude, phase })
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Types
|
||||
// =============================================================================
|
||||
@@ -1307,6 +1489,85 @@ mod tests {
|
||||
assert_ne!(frame.witness_hash(), frame2.witness_hash());
|
||||
}
|
||||
|
||||
/// AC7 — replay: `from_canonical_bytes` is the exact inverse of
|
||||
/// `to_canonical_bytes` — same id, metadata, payload, and witness hash.
|
||||
/// This is the capture-to-claim law: a stored canonical capture replays to
|
||||
/// a frame the pipeline cannot distinguish from the original.
|
||||
#[test]
|
||||
fn ac7_canonical_round_trip_replays_identically() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("node-α"), FrequencyBand::Band6GHz, 37);
|
||||
meta.set_calibration(uuid::Uuid::new_v4());
|
||||
meta.set_model(9, 0x0203);
|
||||
meta.antenna_config.spacing_mm = Some(62.5);
|
||||
meta.rssi_dbm = -41;
|
||||
meta.sequence_number = 123_456;
|
||||
let data = Array2::from_shape_fn((2, 56), |(r, c)| {
|
||||
Complex64::new((r as f64 + 1.0) * (c as f64).cos(), (c as f64 * 0.1).tan())
|
||||
});
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).expect("decodes");
|
||||
|
||||
assert_eq!(replayed.id, frame.id);
|
||||
// Field-wise metadata equality (CsiMetadata has no PartialEq; the
|
||||
// byte-identical re-encoding below covers every field regardless).
|
||||
assert_eq!(replayed.metadata.device_id, frame.metadata.device_id);
|
||||
assert_eq!(replayed.metadata.calibration_id, frame.metadata.calibration_id);
|
||||
assert_eq!(replayed.metadata.model_version, frame.metadata.model_version);
|
||||
assert_eq!(replayed.metadata.antenna_config.spacing_mm, Some(62.5));
|
||||
assert_eq!(replayed.data, frame.data);
|
||||
// Witness equality — the strongest statement of equivalence.
|
||||
assert_eq!(replayed.witness_hash(), frame.witness_hash());
|
||||
// Re-encoding is byte-identical.
|
||||
assert_eq!(replayed.to_canonical_bytes(), bytes);
|
||||
// Projections recomputed consistently.
|
||||
assert_eq!(replayed.amplitude, frame.amplitude);
|
||||
}
|
||||
|
||||
/// AC8 — the decoder fails closed on every malformed-input class.
|
||||
#[test]
|
||||
fn ac8_canonical_decode_fails_closed() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Truncation anywhere fails: in the payload it is caught by the
|
||||
// shape-vs-length check (PayloadMismatch); in the header by Truncated.
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..bytes.len() - 1]),
|
||||
Err(CanonicalDecodeError::PayloadMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..10]),
|
||||
Err(CanonicalDecodeError::Truncated { .. })
|
||||
));
|
||||
|
||||
// Trailing junk fails.
|
||||
let mut padded = bytes.clone();
|
||||
padded.extend_from_slice(&[0u8; 3]);
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&padded),
|
||||
Err(CanonicalDecodeError::TrailingBytes(3))
|
||||
));
|
||||
|
||||
// Bad frequency-band discriminant fails. Band byte sits right after
|
||||
// id(16) + seconds(8) + nanos(4) + dev_len(4) + dev("n" = 1).
|
||||
let mut bad = bytes.clone();
|
||||
bad[16 + 8 + 4 + 4 + 1] = 9;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bad),
|
||||
Err(CanonicalDecodeError::BadDiscriminant { field: "frequency_band", value: 9 })
|
||||
));
|
||||
|
||||
// A nil calibration uuid decodes as None (the documented encoding).
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(replayed.metadata.calibration_id, None);
|
||||
}
|
||||
|
||||
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
|
||||
#[cfg(feature = "serde")]
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user