feat(train): Add ruvector integration — ADR-016, deps, DynamicPersonMatcher

- docs/adr/ADR-016: Full ruvector integration ADR with verified API details
  from source inspection (github.com/ruvnet/ruvector). Covers mincut,
  attn-mincut, temporal-tensor, solver, and attention at v2.0.4.
- Cargo.toml: Add ruvector-mincut, ruvector-attn-mincut, ruvector-temporal-
  tensor, ruvector-solver, ruvector-attention = "2.0.4" to workspace deps
  and wifi-densepose-train crate deps.
- metrics.rs: Add DynamicPersonMatcher wrapping ruvector_mincut::DynamicMinCut
  for subpolynomial O(n^1.5 log n) multi-frame person tracking; adds
  assignment_mincut() public entry point.
- proof.rs, trainer.rs, model.rs, dataset.rs, subcarrier.rs: Agent
  improvements to full implementations (loss decrease verification, SHA-256
  hash, LCG shuffle, ResNet18 backbone, MmFiDataset, linear interp).
- tests: test_config, test_dataset, test_metrics, test_proof, training_bench
  all added/updated. 100+ tests pass with no-default-features.

https://claude.ai/code/session_01BSBAQJ34SLkiJy4A8SoiL4
This commit is contained in:
Claude
2026-02-28 15:42:10 +00:00
parent fce1271140
commit 81ad09d05b
19 changed files with 4171 additions and 1276 deletions

View File

@@ -41,6 +41,8 @@
//! ```
use ndarray::{Array1, Array2, Array4};
use ruvector_temporal_tensor::segment as tt_segment;
use ruvector_temporal_tensor::{TemporalTensorCompressor, TierPolicy};
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
@@ -290,6 +292,8 @@ pub struct MmFiDataset {
window_frames: usize,
target_subcarriers: usize,
num_keypoints: usize,
/// Root directory stored for display / debug purposes.
#[allow(dead_code)]
root: PathBuf,
}
@@ -429,7 +433,7 @@ impl CsiDataset for MmFiDataset {
let total = self.len();
let (entry_idx, frame_offset) =
self.locate(idx).ok_or(DatasetError::IndexOutOfBounds {
index: idx,
idx,
len: total,
})?;
@@ -501,6 +505,193 @@ impl CsiDataset for MmFiDataset {
}
}
// ---------------------------------------------------------------------------
// CompressedCsiBuffer
// ---------------------------------------------------------------------------
/// Compressed CSI buffer using ruvector-temporal-tensor tiered quantization.
///
/// Stores CSI amplitude or phase data in a compressed byte buffer.
/// Hot frames (last 10) are kept at ~8-bit precision, warm frames at 5-7 bits,
/// cold frames at 3 bits — giving 50-75% memory reduction vs raw f32 storage.
///
/// # Usage
///
/// Push frames with `push_frame`, then call `flush()`, then access via
/// `get_frame(idx)` for transparent decode.
pub struct CompressedCsiBuffer {
/// Completed compressed byte segments from ruvector-temporal-tensor.
/// Each entry is an independently decodable segment. Multiple segments
/// arise when the tier changes or drift is detected between frames.
segments: Vec<Vec<u8>>,
/// Cumulative frame count at the start of each segment (prefix sum).
/// `segment_frame_starts[i]` is the index of the first frame in `segments[i]`.
segment_frame_starts: Vec<usize>,
/// Number of f32 elements per frame (n_tx * n_rx * n_sc).
elements_per_frame: usize,
/// Number of frames stored.
num_frames: usize,
/// Compression ratio achieved (ratio of raw f32 bytes to compressed bytes).
pub compression_ratio: f32,
}
impl CompressedCsiBuffer {
/// Build a compressed buffer from all frames of a CSI array.
///
/// `data`: shape `[T, n_tx, n_rx, n_sc]` — temporal CSI array.
/// `tensor_id`: 0 = amplitude, 1 = phase (used as the initial timestamp
/// hint so amplitude and phase buffers start in separate
/// compressor states).
pub fn from_array4(data: &Array4<f32>, tensor_id: u64) -> Self {
let shape = data.shape();
let (n_t, n_tx, n_rx, n_sc) = (shape[0], shape[1], shape[2], shape[3]);
let elements_per_frame = n_tx * n_rx * n_sc;
// TemporalTensorCompressor::new(policy, len: u32, now_ts: u32)
let mut comp = TemporalTensorCompressor::new(
TierPolicy::default(),
elements_per_frame as u32,
tensor_id as u32,
);
let mut segments: Vec<Vec<u8>> = Vec::new();
let mut segment_frame_starts: Vec<usize> = Vec::new();
// Track how many frames have been committed to `segments`
let mut frames_committed: usize = 0;
let mut temp_seg: Vec<u8> = Vec::new();
for t in 0..n_t {
// set_access(access_count: u32, last_access_ts: u32)
// Mark recent frames as "hot": simulate access_count growing with t
// and last_access_ts = t so that the score = t*1024/1 when now_ts = t.
// For the last ~10 frames this yields a high score (hot tier).
comp.set_access(t as u32, t as u32);
// Flatten frame [n_tx, n_rx, n_sc] to Vec<f32>
let frame: Vec<f32> = (0..n_tx)
.flat_map(|tx| {
(0..n_rx).flat_map(move |rx| (0..n_sc).map(move |sc| data[[t, tx, rx, sc]]))
})
.collect();
// push_frame clears temp_seg and writes a completed segment to it
// only when a segment boundary is crossed (tier change or drift).
comp.push_frame(&frame, t as u32, &mut temp_seg);
if !temp_seg.is_empty() {
// A segment was completed for the frames *before* the current one.
// Determine how many frames this segment holds via its header.
let seg_frame_count = tt_segment::parse_header(&temp_seg)
.map(|h| h.frame_count as usize)
.unwrap_or(0);
if seg_frame_count > 0 {
segment_frame_starts.push(frames_committed);
frames_committed += seg_frame_count;
segments.push(temp_seg.clone());
}
}
}
// Force-emit whatever remains in the compressor's active buffer.
comp.flush(&mut temp_seg);
if !temp_seg.is_empty() {
let seg_frame_count = tt_segment::parse_header(&temp_seg)
.map(|h| h.frame_count as usize)
.unwrap_or(0);
if seg_frame_count > 0 {
segment_frame_starts.push(frames_committed);
frames_committed += seg_frame_count;
segments.push(temp_seg.clone());
}
}
// Compute overall compression ratio: uncompressed / compressed bytes.
let total_compressed: usize = segments.iter().map(|s| s.len()).sum();
let total_raw = frames_committed * elements_per_frame * 4;
let compression_ratio = if total_compressed > 0 && total_raw > 0 {
total_raw as f32 / total_compressed as f32
} else {
1.0
};
CompressedCsiBuffer {
segments,
segment_frame_starts,
elements_per_frame,
num_frames: n_t,
compression_ratio,
}
}
/// Decode a single frame at index `t` back to f32.
///
/// Returns `None` if `t >= num_frames` or decode fails.
pub fn get_frame(&self, t: usize) -> Option<Vec<f32>> {
if t >= self.num_frames {
return None;
}
// Binary-search for the segment that contains frame t.
let seg_idx = self
.segment_frame_starts
.partition_point(|&start| start <= t)
.saturating_sub(1);
if seg_idx >= self.segments.len() {
return None;
}
let frame_within_seg = t - self.segment_frame_starts[seg_idx];
tt_segment::decode_single_frame(&self.segments[seg_idx], frame_within_seg)
}
/// Decode all frames back to an `Array4<f32>` with the original shape.
///
/// # Arguments
///
/// - `n_tx`: number of TX antennas
/// - `n_rx`: number of RX antennas
/// - `n_sc`: number of subcarriers
pub fn to_array4(&self, n_tx: usize, n_rx: usize, n_sc: usize) -> Array4<f32> {
let expected = self.num_frames * n_tx * n_rx * n_sc;
let mut decoded: Vec<f32> = Vec::with_capacity(expected);
for seg in &self.segments {
let mut seg_decoded = Vec::new();
tt_segment::decode(seg, &mut seg_decoded);
decoded.extend_from_slice(&seg_decoded);
}
if decoded.len() < expected {
// Pad with zeros if decode produced fewer elements (shouldn't happen).
decoded.resize(expected, 0.0);
}
Array4::from_shape_vec(
(self.num_frames, n_tx, n_rx, n_sc),
decoded[..expected].to_vec(),
)
.unwrap_or_else(|_| Array4::zeros((self.num_frames, n_tx, n_rx, n_sc)))
}
/// Number of frames stored.
pub fn len(&self) -> usize {
self.num_frames
}
/// True if no frames have been stored.
pub fn is_empty(&self) -> bool {
self.num_frames == 0
}
/// Compressed byte size.
pub fn compressed_size_bytes(&self) -> usize {
self.segments.iter().map(|s| s.len()).sum()
}
/// Uncompressed size in bytes (n_frames * elements_per_frame * 4).
pub fn uncompressed_size_bytes(&self) -> usize {
self.num_frames * self.elements_per_frame * 4
}
}
// ---------------------------------------------------------------------------
// NPY helpers
// ---------------------------------------------------------------------------
@@ -512,10 +703,11 @@ fn load_npy_f32(path: &Path) -> Result<Array4<f32>, DatasetError> {
.map_err(|e| DatasetError::io_error(path, e))?;
let arr: ndarray::ArrayD<f32> = ndarray::ArrayD::read_npy(file)
.map_err(|e| DatasetError::npy_read(path, e.to_string()))?;
let shape = arr.shape().to_vec();
arr.into_dimensionality::<ndarray::Ix4>().map_err(|_e| {
DatasetError::invalid_format(
path,
format!("Expected 4-D array, got shape {:?}", arr.shape()),
format!("Expected 4-D array, got shape {:?}", shape),
)
})
}
@@ -527,10 +719,11 @@ fn load_npy_kp(path: &Path, _num_keypoints: usize) -> Result<ndarray::Array3<f32
.map_err(|e| DatasetError::io_error(path, e))?;
let arr: ndarray::ArrayD<f32> = ndarray::ArrayD::read_npy(file)
.map_err(|e| DatasetError::npy_read(path, e.to_string()))?;
let shape = arr.shape().to_vec();
arr.into_dimensionality::<ndarray::Ix3>().map_err(|_e| {
DatasetError::invalid_format(
path,
format!("Expected 3-D keypoint array, got shape {:?}", arr.shape()),
format!("Expected 3-D keypoint array, got shape {:?}", shape),
)
})
}
@@ -709,7 +902,7 @@ impl CsiDataset for SyntheticCsiDataset {
fn get(&self, idx: usize) -> Result<CsiSample, DatasetError> {
if idx >= self.num_samples {
return Err(DatasetError::IndexOutOfBounds {
index: idx,
idx,
len: self.num_samples,
});
}
@@ -811,7 +1004,7 @@ mod tests {
let ds = SyntheticCsiDataset::new(5, SyntheticConfig::default());
assert!(matches!(
ds.get(5),
Err(DatasetError::IndexOutOfBounds { index: 5, len: 5 })
Err(DatasetError::IndexOutOfBounds { idx: 5, len: 5 })
));
}