Implement WiFi CSI-based vital sign detection and RVF model container: - Pure-Rust radix-2 DIT FFT with Hann windowing and parabolic interpolation - FIR bandpass filter (windowed-sinc, Hamming) for breathing (0.1-0.5 Hz) and heartbeat (0.8-2.0 Hz) band isolation - VitalSignDetector with rolling buffers (30s breathing, 15s heartbeat) - RVF binary container with 64-byte SegmentHeader, CRC32 integrity, 6 segment types (Vec, Manifest, Quant, Meta, Witness, Profile) - RvfBuilder/RvfReader with file I/O and VitalSignConfig support - Server integration: --benchmark, --load-rvf, --save-rvf CLI flags - REST endpoint /api/v1/vital-signs and WebSocket vital_signs field - 98 tests (32 unit + 16 RVF integration + 18 vital signs integration) - Benchmark: 7,313 frames/sec (136μs/frame), 365x real-time at 20 Hz Co-Authored-By: claude-flow <ruv@ruv.net>
557 lines
17 KiB
Rust
557 lines
17 KiB
Rust
//! Integration tests for the RVF (RuVector Format) container module.
|
|
//!
|
|
//! These tests exercise the public RvfBuilder and RvfReader APIs through
|
|
//! the library crate's public interface. They complement the inline unit
|
|
//! tests in rvf_container.rs by testing from the perspective of an external
|
|
//! consumer.
|
|
//!
|
|
//! Test matrix:
|
|
//! - Empty builder produces valid (empty) container
|
|
//! - Full round-trip: manifest + weights + metadata -> build -> read -> verify
|
|
//! - Segment type tagging and ordering
|
|
//! - Magic byte corruption is rejected
|
|
//! - Float32 precision is preserved bit-for-bit
|
|
//! - Large payload (1M weights) round-trip
|
|
//! - Multiple metadata segments coexist
|
|
//! - File I/O round-trip
|
|
//! - Witness/proof segment verification
|
|
//! - Write/read benchmark for ~10MB container
|
|
|
|
use wifi_densepose_sensing_server::rvf_container::{
|
|
RvfBuilder, RvfReader, VitalSignConfig,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_rvf_builder_empty() {
|
|
let builder = RvfBuilder::new();
|
|
let data = builder.build();
|
|
|
|
// Empty builder produces zero bytes (no segments => no headers)
|
|
assert!(
|
|
data.is_empty(),
|
|
"empty builder should produce empty byte vec"
|
|
);
|
|
|
|
// Reader should parse an empty container with zero segments
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse empty container");
|
|
assert_eq!(reader.segment_count(), 0);
|
|
assert_eq!(reader.total_size(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_round_trip() {
|
|
let mut builder = RvfBuilder::new();
|
|
|
|
// Add all segment types
|
|
builder.add_manifest("vital-signs-v1", "0.1.0", "Vital sign detection model");
|
|
|
|
let weights: Vec<f32> = (0..100).map(|i| i as f32 * 0.01).collect();
|
|
builder.add_weights(&weights);
|
|
|
|
let metadata = serde_json::json!({
|
|
"training_epochs": 50,
|
|
"loss": 0.023,
|
|
"optimizer": "adam",
|
|
});
|
|
builder.add_metadata(&metadata);
|
|
|
|
let data = builder.build();
|
|
assert!(!data.is_empty(), "container with data should not be empty");
|
|
|
|
// Alignment: every segment should start on a 64-byte boundary
|
|
assert_eq!(
|
|
data.len() % 64,
|
|
0,
|
|
"total size should be a multiple of 64 bytes"
|
|
);
|
|
|
|
// Parse back
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse container");
|
|
assert_eq!(reader.segment_count(), 3);
|
|
|
|
// Verify manifest
|
|
let manifest = reader
|
|
.manifest()
|
|
.expect("should have manifest");
|
|
assert_eq!(manifest["model_id"], "vital-signs-v1");
|
|
assert_eq!(manifest["version"], "0.1.0");
|
|
assert_eq!(manifest["description"], "Vital sign detection model");
|
|
|
|
// Verify weights
|
|
let decoded_weights = reader
|
|
.weights()
|
|
.expect("should have weights");
|
|
assert_eq!(decoded_weights.len(), weights.len());
|
|
for (i, (&original, &decoded)) in weights.iter().zip(decoded_weights.iter()).enumerate() {
|
|
assert_eq!(
|
|
original.to_bits(),
|
|
decoded.to_bits(),
|
|
"weight[{i}] mismatch"
|
|
);
|
|
}
|
|
|
|
// Verify metadata
|
|
let decoded_meta = reader
|
|
.metadata()
|
|
.expect("should have metadata");
|
|
assert_eq!(decoded_meta["training_epochs"], 50);
|
|
assert_eq!(decoded_meta["optimizer"], "adam");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_segment_types() {
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("test", "1.0", "test model");
|
|
builder.add_weights(&[1.0, 2.0]);
|
|
builder.add_metadata(&serde_json::json!({"key": "value"}));
|
|
builder.add_witness(
|
|
"sha256:abc123",
|
|
&serde_json::json!({"accuracy": 0.95}),
|
|
);
|
|
|
|
let data = builder.build();
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
|
|
|
assert_eq!(reader.segment_count(), 4);
|
|
|
|
// Each segment type should be present
|
|
assert!(reader.manifest().is_some(), "manifest should be present");
|
|
assert!(reader.weights().is_some(), "weights should be present");
|
|
assert!(reader.metadata().is_some(), "metadata should be present");
|
|
assert!(reader.witness().is_some(), "witness should be present");
|
|
|
|
// Verify segment order via segment IDs (monotonically increasing)
|
|
let ids: Vec<u64> = reader
|
|
.segments()
|
|
.map(|(h, _)| h.segment_id)
|
|
.collect();
|
|
assert_eq!(ids, vec![0, 1, 2, 3], "segment IDs should be 0,1,2,3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_magic_validation() {
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("test", "1.0", "test");
|
|
let mut data = builder.build();
|
|
|
|
// Corrupt the magic bytes in the first segment header
|
|
// Magic is at offset 0x00..0x04
|
|
data[0] = 0xDE;
|
|
data[1] = 0xAD;
|
|
data[2] = 0xBE;
|
|
data[3] = 0xEF;
|
|
|
|
let result = RvfReader::from_bytes(&data);
|
|
assert!(
|
|
result.is_err(),
|
|
"corrupted magic should fail to parse"
|
|
);
|
|
|
|
let err = result.unwrap_err();
|
|
assert!(
|
|
err.contains("magic"),
|
|
"error message should mention 'magic', got: {}",
|
|
err
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_weights_f32_precision() {
|
|
// Test specific float32 edge cases
|
|
let weights: Vec<f32> = vec![
|
|
0.0,
|
|
1.0,
|
|
-1.0,
|
|
f32::MIN_POSITIVE,
|
|
f32::MAX,
|
|
f32::MIN,
|
|
f32::EPSILON,
|
|
std::f32::consts::PI,
|
|
std::f32::consts::E,
|
|
1.0e-30,
|
|
1.0e30,
|
|
-0.0,
|
|
0.123456789,
|
|
1.0e-45, // subnormal
|
|
];
|
|
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_weights(&weights);
|
|
let data = builder.build();
|
|
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
|
let decoded = reader.weights().expect("should have weights");
|
|
|
|
assert_eq!(decoded.len(), weights.len());
|
|
for (i, (&original, &parsed)) in weights.iter().zip(decoded.iter()).enumerate() {
|
|
assert_eq!(
|
|
original.to_bits(),
|
|
parsed.to_bits(),
|
|
"weight[{i}] bit-level mismatch: original={original} (0x{:08X}), parsed={parsed} (0x{:08X})",
|
|
original.to_bits(),
|
|
parsed.to_bits(),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_large_payload() {
|
|
// 1 million f32 weights = 4 MB of payload data
|
|
let num_weights = 1_000_000;
|
|
let weights: Vec<f32> = (0..num_weights)
|
|
.map(|i| (i as f32 * 0.000001).sin())
|
|
.collect();
|
|
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("large-test", "1.0", "Large payload test");
|
|
builder.add_weights(&weights);
|
|
let data = builder.build();
|
|
|
|
// Container should be at least header + weights bytes
|
|
assert!(
|
|
data.len() >= 64 + num_weights * 4,
|
|
"container should be large enough, got {} bytes",
|
|
data.len()
|
|
);
|
|
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse large container");
|
|
let decoded = reader.weights().expect("should have weights");
|
|
|
|
assert_eq!(
|
|
decoded.len(),
|
|
num_weights,
|
|
"all 1M weights should round-trip"
|
|
);
|
|
|
|
// Spot-check several values
|
|
for idx in [0, 1, 100, 1000, 500_000, 999_999] {
|
|
assert_eq!(
|
|
weights[idx].to_bits(),
|
|
decoded[idx].to_bits(),
|
|
"weight[{idx}] mismatch"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_multiple_metadata_segments() {
|
|
// The current builder only stores one metadata segment, but we can add
|
|
// multiple by adding metadata and then other segments to verify all coexist.
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("multi-meta", "1.0", "Multiple segment types");
|
|
|
|
let meta1 = serde_json::json!({"training_config": {"optimizer": "adam"}});
|
|
builder.add_metadata(&meta1);
|
|
|
|
builder.add_vital_config(&VitalSignConfig::default());
|
|
builder.add_quant_info("int8", 0.0078125, -128);
|
|
|
|
let data = builder.build();
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
|
|
|
assert_eq!(
|
|
reader.segment_count(),
|
|
4,
|
|
"should have 4 segments (manifest + meta + vital_config + quant)"
|
|
);
|
|
|
|
assert!(reader.manifest().is_some());
|
|
assert!(reader.metadata().is_some());
|
|
assert!(reader.vital_config().is_some());
|
|
assert!(reader.quant_info().is_some());
|
|
|
|
// Verify metadata content
|
|
let meta = reader.metadata().unwrap();
|
|
assert_eq!(meta["training_config"]["optimizer"], "adam");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_file_io() {
|
|
let tmp_dir = tempfile::tempdir().expect("should create temp dir");
|
|
let file_path = tmp_dir.path().join("test_model.rvf");
|
|
|
|
let weights: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4, 0.5];
|
|
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("file-io-test", "1.0.0", "File I/O test model");
|
|
builder.add_weights(&weights);
|
|
builder.add_metadata(&serde_json::json!({"created": "2026-02-28"}));
|
|
|
|
// Write to file
|
|
builder
|
|
.write_to_file(&file_path)
|
|
.expect("should write to file");
|
|
|
|
// Read back from file
|
|
let reader = RvfReader::from_file(&file_path).expect("should read from file");
|
|
|
|
assert_eq!(reader.segment_count(), 3);
|
|
|
|
let manifest = reader.manifest().expect("should have manifest");
|
|
assert_eq!(manifest["model_id"], "file-io-test");
|
|
|
|
let decoded_weights = reader.weights().expect("should have weights");
|
|
assert_eq!(decoded_weights.len(), weights.len());
|
|
for (a, b) in decoded_weights.iter().zip(weights.iter()) {
|
|
assert_eq!(a.to_bits(), b.to_bits());
|
|
}
|
|
|
|
let meta = reader.metadata().expect("should have metadata");
|
|
assert_eq!(meta["created"], "2026-02-28");
|
|
|
|
// Verify file size matches in-memory serialization
|
|
let in_memory = builder.build();
|
|
let file_meta = std::fs::metadata(&file_path).expect("should stat file");
|
|
assert_eq!(
|
|
file_meta.len() as usize,
|
|
in_memory.len(),
|
|
"file size should match serialized size"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_witness_proof() {
|
|
let training_hash = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
let metrics = serde_json::json!({
|
|
"accuracy": 0.957,
|
|
"loss": 0.023,
|
|
"epochs": 200,
|
|
"dataset_size": 50000,
|
|
});
|
|
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("witnessed-model", "2.0", "Model with witness proof");
|
|
builder.add_weights(&[1.0, 2.0, 3.0]);
|
|
builder.add_witness(training_hash, &metrics);
|
|
|
|
let data = builder.build();
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
|
|
|
let witness = reader.witness().expect("should have witness segment");
|
|
assert_eq!(
|
|
witness["training_hash"],
|
|
training_hash,
|
|
"training hash should round-trip"
|
|
);
|
|
assert_eq!(witness["metrics"]["accuracy"], 0.957);
|
|
assert_eq!(witness["metrics"]["epochs"], 200);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_benchmark_write_read() {
|
|
// Create a container with ~10 MB of weights
|
|
let num_weights = 2_500_000; // 10 MB of f32 data
|
|
let weights: Vec<f32> = (0..num_weights)
|
|
.map(|i| (i as f32 * 0.0001).sin())
|
|
.collect();
|
|
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("benchmark-model", "1.0", "Benchmark test");
|
|
builder.add_weights(&weights);
|
|
builder.add_metadata(&serde_json::json!({"benchmark": true}));
|
|
|
|
// Benchmark write (serialization)
|
|
let write_start = std::time::Instant::now();
|
|
let data = builder.build();
|
|
let write_elapsed = write_start.elapsed();
|
|
|
|
let size_mb = data.len() as f64 / (1024.0 * 1024.0);
|
|
let write_speed = size_mb / write_elapsed.as_secs_f64();
|
|
|
|
println!(
|
|
"RVF write benchmark: {:.1} MB in {:.2}ms = {:.0} MB/s",
|
|
size_mb,
|
|
write_elapsed.as_secs_f64() * 1000.0,
|
|
write_speed,
|
|
);
|
|
|
|
// Benchmark read (deserialization + CRC validation)
|
|
let read_start = std::time::Instant::now();
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse benchmark container");
|
|
let read_elapsed = read_start.elapsed();
|
|
|
|
let read_speed = size_mb / read_elapsed.as_secs_f64();
|
|
|
|
println!(
|
|
"RVF read benchmark: {:.1} MB in {:.2}ms = {:.0} MB/s",
|
|
size_mb,
|
|
read_elapsed.as_secs_f64() * 1000.0,
|
|
read_speed,
|
|
);
|
|
|
|
// Verify correctness
|
|
let decoded_weights = reader.weights().expect("should have weights");
|
|
assert_eq!(decoded_weights.len(), num_weights);
|
|
assert_eq!(weights[0].to_bits(), decoded_weights[0].to_bits());
|
|
assert_eq!(
|
|
weights[num_weights - 1].to_bits(),
|
|
decoded_weights[num_weights - 1].to_bits()
|
|
);
|
|
|
|
// Write and read should be reasonably fast
|
|
assert!(
|
|
write_speed > 10.0,
|
|
"write speed {:.0} MB/s is too slow",
|
|
write_speed
|
|
);
|
|
assert!(
|
|
read_speed > 10.0,
|
|
"read speed {:.0} MB/s is too slow",
|
|
read_speed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_content_hash_integrity() {
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_metadata(&serde_json::json!({"integrity": "test"}));
|
|
let mut data = builder.build();
|
|
|
|
// Corrupt one byte in the payload area (after the 64-byte header)
|
|
if data.len() > 65 {
|
|
data[65] ^= 0xFF;
|
|
let result = RvfReader::from_bytes(&data);
|
|
assert!(
|
|
result.is_err(),
|
|
"corrupted payload should fail CRC32 hash check"
|
|
);
|
|
assert!(
|
|
result.unwrap_err().contains("hash mismatch"),
|
|
"error should mention hash mismatch"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_truncated_data() {
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("truncation-test", "1.0", "Truncation test");
|
|
builder.add_weights(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
|
let data = builder.build();
|
|
|
|
// Truncating at header boundary or within payload should fail
|
|
for truncate_at in [0, 10, 32, 63, 64, 65, 80] {
|
|
if truncate_at < data.len() {
|
|
let truncated = &data[..truncate_at];
|
|
let result = RvfReader::from_bytes(truncated);
|
|
// Empty or partial-header data: either returns empty or errors
|
|
if truncate_at < 64 {
|
|
// Less than one header: reader returns 0 segments (no error on empty)
|
|
// or fails if partial header data is present
|
|
// The reader skips if offset + HEADER_SIZE > data.len()
|
|
if truncate_at == 0 {
|
|
assert!(
|
|
result.is_ok() && result.unwrap().segment_count() == 0,
|
|
"empty data should parse as 0 segments"
|
|
);
|
|
}
|
|
} else {
|
|
// Has header but truncated payload
|
|
assert!(
|
|
result.is_err(),
|
|
"truncated at {truncate_at} bytes should fail"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_empty_weights() {
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_weights(&[]);
|
|
let data = builder.build();
|
|
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
|
let weights = reader.weights().expect("should have weights segment");
|
|
assert!(weights.is_empty(), "empty weight vector should round-trip");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_vital_config_round_trip() {
|
|
let config = VitalSignConfig {
|
|
breathing_low_hz: 0.15,
|
|
breathing_high_hz: 0.45,
|
|
heartrate_low_hz: 0.9,
|
|
heartrate_high_hz: 1.8,
|
|
min_subcarriers: 64,
|
|
window_size: 1024,
|
|
confidence_threshold: 0.7,
|
|
};
|
|
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_vital_config(&config);
|
|
let data = builder.build();
|
|
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
|
let decoded = reader
|
|
.vital_config()
|
|
.expect("should have vital config");
|
|
|
|
assert!(
|
|
(decoded.breathing_low_hz - 0.15).abs() < f64::EPSILON,
|
|
"breathing_low_hz mismatch"
|
|
);
|
|
assert!(
|
|
(decoded.breathing_high_hz - 0.45).abs() < f64::EPSILON,
|
|
"breathing_high_hz mismatch"
|
|
);
|
|
assert!(
|
|
(decoded.heartrate_low_hz - 0.9).abs() < f64::EPSILON,
|
|
"heartrate_low_hz mismatch"
|
|
);
|
|
assert!(
|
|
(decoded.heartrate_high_hz - 1.8).abs() < f64::EPSILON,
|
|
"heartrate_high_hz mismatch"
|
|
);
|
|
assert_eq!(decoded.min_subcarriers, 64);
|
|
assert_eq!(decoded.window_size, 1024);
|
|
assert!(
|
|
(decoded.confidence_threshold - 0.7).abs() < f64::EPSILON,
|
|
"confidence_threshold mismatch"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_info_struct() {
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_manifest("info-test", "2.0", "Info struct test");
|
|
builder.add_weights(&[1.0, 2.0, 3.0]);
|
|
builder.add_vital_config(&VitalSignConfig::default());
|
|
builder.add_witness("sha256:test", &serde_json::json!({"ok": true}));
|
|
|
|
let data = builder.build();
|
|
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
|
let info = reader.info();
|
|
|
|
assert_eq!(info.segment_count, 4);
|
|
assert!(info.total_size > 0);
|
|
assert!(info.manifest.is_some());
|
|
assert!(info.has_weights);
|
|
assert!(info.has_vital_config);
|
|
assert!(info.has_witness);
|
|
assert!(!info.has_quant_info, "no quant segment was added");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rvf_alignment_invariant() {
|
|
// Every container should have total size that is a multiple of 64
|
|
for num_weights in [0, 1, 10, 100, 255, 256, 1000] {
|
|
let weights: Vec<f32> = (0..num_weights).map(|i| i as f32).collect();
|
|
let mut builder = RvfBuilder::new();
|
|
builder.add_weights(&weights);
|
|
let data = builder.build();
|
|
|
|
assert_eq!(
|
|
data.len() % 64,
|
|
0,
|
|
"container with {num_weights} weights should be 64-byte aligned, got {} bytes",
|
|
data.len()
|
|
);
|
|
}
|
|
}
|