diff --git a/crates/ruvector-temporal-tensor/src/quantizer.rs b/crates/ruvector-temporal-tensor/src/quantizer.rs index 4424553e..d038e380 100644 --- a/crates/ruvector-temporal-tensor/src/quantizer.rs +++ b/crates/ruvector-temporal-tensor/src/quantizer.rs @@ -113,7 +113,8 @@ pub fn quantize_and_pack_f32( for &v in chunk { let mut q: i32 = 0; if v.is_finite() { - q = (v * inv_scale).round() as i32; + let scaled = v * inv_scale; + q = if scaled >= 0.0 { (scaled + 0.5) as i32 } else { (scaled - 0.5) as i32 }; q = q.clamp(-127, 127); } out.push((q + 127) as u8); @@ -145,7 +146,7 @@ pub fn quantize_and_pack_f32( let mut q: i32 = 0; if v.is_finite() { let scaled = v * inv_scale; - q = scaled.round() as i32; + q = if scaled >= 0.0 { (scaled + 0.5) as i32 } else { (scaled - 0.5) as i32 }; q = q.clamp(-qmax_i, qmax_i); } @@ -218,6 +219,72 @@ pub fn dequantize_f32( return; } + // Fast path: 3-bit dequantization processes 8 values from 3 bytes. + // 8 values * 3 bits = 24 bits = 3 bytes exactly, avoiding the bit accumulator. + // LSB-first packing layout for 8 values in 3 bytes: + // byte0 = v0 | (v1 << 3) | ((v2 & 0x3) << 6) + // byte1 = (v2 >> 2) | (v3 << 1) | (v4 << 4) | ((v5 & 0x1) << 7) + // byte2 = (v5 >> 1) | (v6 << 2) | (v7 << 5) + if bits == 3 { + let bias = 3i32; // qmax for 3-bit + let mut out_idx = 0usize; + let mut byte_idx = 0usize; + for _frame in 0..frame_count { + let mut pos = 0usize; + let mut group_idx = 0usize; + while pos < tensor_len { + let group_end = (pos + group_len).min(tensor_len); + let scale = if group_idx < scales_f32.len() { + scales_f32[group_idx] + } else { + 0.0 + }; + // Process 8 values at a time from 3 bytes + while pos + 8 <= group_end && byte_idx + 3 <= data.len() { + let b0 = data[byte_idx] as u32; + let b1 = data[byte_idx + 1] as u32; + let b2 = data[byte_idx + 2] as u32; + byte_idx += 3; + + out[out_idx] = ((b0 & 0x7) as i32 - bias) as f32 * scale; + out[out_idx + 1] = (((b0 >> 3) & 0x7) as i32 - bias) as f32 * scale; + out[out_idx + 2] = ((((b0 >> 6) | (b1 << 2)) & 0x7) as i32 - bias) as f32 * scale; + out[out_idx + 3] = (((b1 >> 1) & 0x7) as i32 - bias) as f32 * scale; + out[out_idx + 4] = (((b1 >> 4) & 0x7) as i32 - bias) as f32 * scale; + out[out_idx + 5] = ((((b1 >> 7) | (b2 << 1)) & 0x7) as i32 - bias) as f32 * scale; + out[out_idx + 6] = (((b2 >> 2) & 0x7) as i32 - bias) as f32 * scale; + out[out_idx + 7] = (((b2 >> 5) & 0x7) as i32 - bias) as f32 * scale; + out_idx += 8; + pos += 8; + } + // Handle remaining values (< 8) with a local bit accumulator + if pos < group_end { + let remaining = group_end - pos; + let mut acc: u64 = 0; + let mut acc_bits: u32 = 0; + while acc_bits < (remaining as u32) * 3 && byte_idx < data.len() { + acc |= (data[byte_idx] as u64) << acc_bits; + acc_bits += 8; + byte_idx += 1; + } + for _ in 0..remaining { + if acc_bits < 3 { + break; + } + let u = (acc & 0x7) as i32; + acc >>= 3; + acc_bits -= 3; + out[out_idx] = (u - bias) as f32 * scale; + out_idx += 1; + pos += 1; + } + } + group_idx += 1; + } + } + return; + } + // Generic path for sub-byte bit widths. let bias = qmax; let bits_u32 = bits as u32; diff --git a/crates/ruvector-temporal-tensor/src/store.rs b/crates/ruvector-temporal-tensor/src/store.rs index e501c6fc..4973f195 100644 --- a/crates/ruvector-temporal-tensor/src/store.rs +++ b/crates/ruvector-temporal-tensor/src/store.rs @@ -27,7 +27,7 @@ //! assert_eq!(store.block_count(), 1); //! //! let mut out = vec![0.0f32; 64]; -//! let n = store.get(key, &mut out).unwrap(); +//! let n = store.get(key, &mut out, 1).unwrap(); //! assert_eq!(n, 64); //! ``` @@ -337,6 +337,63 @@ fn block_checksum(packed: &[u8], scale: f32) -> u32 { crc32(&buf) } +// --------------------------------------------------------------------------- +// TickResult +// --------------------------------------------------------------------------- + +/// Summary of actions taken during a budgeted maintenance tick. +#[derive(Debug, Default)] +pub struct TickResult { + /// Number of blocks promoted to a hotter tier. + pub upgrades: u32, + /// Number of blocks demoted to a colder tier. + pub downgrades: u32, + /// Number of blocks evicted to Tier0. + pub evictions: u32, + /// Total bytes freed by evictions and downgrades. + pub bytes_freed: usize, + /// Number of budget operations consumed. + pub ops_used: u32, + /// Total migration candidates identified before budget limits. + pub candidates_found: u32, +} + +// --------------------------------------------------------------------------- +// Type adapters: store types <-> tiering types +// --------------------------------------------------------------------------- + +/// Convert a store [`Tier`] to a [`crate::tiering::Tier`]. +fn to_tiering_tier(tier: Tier) -> crate::tiering::Tier { + match tier { + Tier::Tier0 => crate::tiering::Tier::Tier0, + Tier::Tier1 => crate::tiering::Tier::Tier1, + Tier::Tier2 => crate::tiering::Tier::Tier2, + Tier::Tier3 => crate::tiering::Tier::Tier3, + } +} + +/// Convert a [`crate::tiering::Tier`] to a store [`Tier`]. +fn from_tiering_tier(tier: crate::tiering::Tier) -> Tier { + match tier { + crate::tiering::Tier::Tier0 => Tier::Tier0, + crate::tiering::Tier::Tier1 => Tier::Tier1, + crate::tiering::Tier::Tier2 => Tier::Tier2, + crate::tiering::Tier::Tier3 => Tier::Tier3, + } +} + +/// Build a [`crate::tiering::BlockMeta`] from a store [`BlockMeta`] at time `now`. +fn to_tiering_meta(meta: &BlockMeta, now: u64) -> crate::tiering::BlockMeta { + crate::tiering::BlockMeta { + ema_rate: meta.ema_rate, + access_window: meta.window, + last_access: meta.last_access_at, + access_count: meta.access_count as u64, + current_tier: to_tiering_tier(meta.tier), + tier_since: now.saturating_sub(meta.tier_age as u64), + } +} + // --------------------------------------------------------------------------- // TieredStore // --------------------------------------------------------------------------- @@ -365,6 +422,9 @@ pub struct TieredStore { tier1_keys: Vec, tier2_keys: Vec, tier3_keys: Vec, + + /// Witness log for auditing tiering decisions. + witness_log: crate::metrics::WitnessLog, } /// Smoothing constant for the exponential moving average of access rate. @@ -382,6 +442,7 @@ impl TieredStore { tier1_keys: Vec::new(), tier2_keys: Vec::new(), tier3_keys: Vec::new(), + witness_log: crate::metrics::WitnessLog::new(10_000), } } @@ -391,6 +452,32 @@ impl TieredStore { self.block_bytes } + /// Access the witness log. + pub fn witness_log(&self) -> &crate::metrics::WitnessLog { + &self.witness_log + } + + /// Access the witness log mutably. + pub fn witness_log_mut(&mut self) -> &mut crate::metrics::WitnessLog { + &mut self.witness_log + } + + /// Compute current aggregate metrics. + pub fn metrics(&self) -> crate::metrics::StoreMetrics { + let mut m = crate::metrics::StoreMetrics::new(); + m.total_blocks = self.index.len() as u64; + m.tier0_blocks = self.index.values().filter(|b| b.tier == Tier::Tier0).count() as u64; + m.tier1_blocks = self.tier1_keys.len() as u64; + m.tier2_blocks = self.tier2_keys.len() as u64; + m.tier3_blocks = self.tier3_keys.len() as u64; + m.tier1_bytes = self.tier1_data.values().map(|d| d.packed.len() as u64).sum(); + m.tier2_bytes = self.tier2_data.values().map(|d| d.packed.len() as u64).sum(); + m.tier3_bytes = self.tier3_data.values().map(|d| d.packed.len() as u64).sum(); + m.total_evictions = self.witness_log.count_evictions() as u64; + m.tier_flips_last_minute = self.witness_log.tier_flip_rate(60, self.index.len() as u64); + m + } + /// Quantize `data` at the bit width for `tier` and store the block. /// /// If a block with the same key already exists, it is replaced (the old @@ -454,11 +541,21 @@ impl TieredStore { }; self.index.insert(key, meta); + // Record witness event for the write. + self.witness_log.record(now, crate::metrics::WitnessEvent::Access { + key, + score: 0.0, + tier, + }); + Ok(()) } /// Dequantize the block identified by `key` into `out`. /// + /// `now` is the current tick counter, used to update access statistics + /// and record a witness event. + /// /// Returns the number of f32 elements written to `out`. /// /// # Errors @@ -467,31 +564,47 @@ impl TieredStore { /// - [`StoreError::BlockNotFound`] if no block exists for `key`. /// - [`StoreError::ChecksumMismatch`] if the stored checksum does not /// match a freshly computed checksum of the payload. - pub fn get(&self, key: BlockKey, out: &mut [f32]) -> Result { + pub fn get(&mut self, key: BlockKey, out: &mut [f32], now: u64) -> Result { let meta = self.index.get(&key).ok_or(StoreError::BlockNotFound)?; if meta.tier == Tier::Tier0 { return Err(StoreError::TensorEvicted); } + let tier = meta.tier; + let scale = meta.scale; + let bits = meta.bits; + let checksum = meta.checksum; + let block = self - .data_map(meta.tier) + .data_map(tier) .and_then(|m| m.get(&key)) .ok_or(StoreError::BlockNotFound)?; // Verify integrity. - let actual_crc = block_checksum(&block.packed, meta.scale); - if actual_crc != meta.checksum { + let actual_crc = block_checksum(&block.packed, scale); + if actual_crc != checksum { return Err(StoreError::ChecksumMismatch); } let n = dequantize_block( &block.packed, - meta.scale, - meta.bits, + scale, + bits, block.element_count as usize, out, ); + + // Update access statistics. + self.touch(key, now); + + // Record witness event. + self.witness_log.record(now, crate::metrics::WitnessEvent::Access { + key, + score: 0.0, // score not computed during basic get + tier, + }); + Ok(n) } @@ -588,6 +701,9 @@ impl TieredStore { return Ok(()); } + let bytes_freed = meta.block_bytes as usize; + let evict_ts = meta.last_access_at; + // Mutate metadata before touching the data maps (avoids a second // lookup since we already have the mutable reference). meta.tier = Tier::Tier0; @@ -600,6 +716,13 @@ impl TieredStore { self.remove_data(old_tier, key); self.remove_from_bucket(old_tier, key); + // Record witness event for the eviction. + self.witness_log.record(evict_ts, crate::metrics::WitnessEvent::Eviction { + key, + score: 0.0, + bytes_freed, + }); + Ok(()) } @@ -647,6 +770,292 @@ impl TieredStore { Tier::Tier0 => {} } } + + // -- tiering-aware methods ----------------------------------------------- + + /// Run a budgeted maintenance tick. + /// + /// Evaluates all blocks, selects migration candidates, and executes + /// tier transitions within the given byte and operation budgets. + /// Returns a summary of actions taken. + pub fn tick( + &mut self, + config: &crate::tiering::TierConfig, + now: u64, + budget_bytes: usize, + budget_ops: u32, + ) -> TickResult { + let mut result = TickResult::default(); + + // Step 1: Collect all blocks and convert to tiering types. + // Use sequential indices as tiering::BlockKey values to avoid collisions. + let store_keys: Vec = self.index.keys().copied().collect(); + if store_keys.is_empty() { + return result; + } + + let tiering_blocks: Vec<(crate::tiering::BlockKey, crate::tiering::BlockMeta)> = + store_keys + .iter() + .enumerate() + .map(|(idx, key)| { + let meta = &self.index[key]; + ( + crate::tiering::BlockKey(idx as u64), + to_tiering_meta(meta, now), + ) + }) + .collect(); + + let blocks_ref: Vec<(crate::tiering::BlockKey, &crate::tiering::BlockMeta)> = + tiering_blocks.iter().map(|(k, m)| (*k, m)).collect(); + + // Step 2: Select migration candidates (upgrades first by highest score, + // then downgrades by lowest score). + let candidates = crate::tiering::select_candidates(config, now, &blocks_ref); + result.candidates_found = candidates.len() as u32; + + // Step 3: Process candidates within budget. + let mut remaining_bytes = budget_bytes; + let mut remaining_ops = budget_ops; + let mut migrated = std::collections::HashSet::new(); + + for candidate in &candidates { + if remaining_ops == 0 { + break; + } + + let store_key = store_keys[candidate.key.0 as usize]; + let target_tier = from_tiering_tier(candidate.target_tier); + let current_tier = from_tiering_tier(candidate.current_tier); + + let old_bytes = self + .index + .get(&store_key) + .map(|m| m.block_bytes as usize) + .unwrap_or(0); + + // Check byte budget. + if old_bytes > remaining_bytes { + continue; + } + + if target_tier == Tier::Tier0 { + // Eviction. + if self.evict(store_key, ReconstructPolicy::None).is_ok() { + result.evictions += 1; + result.bytes_freed += old_bytes; + remaining_ops -= 1; + result.ops_used += 1; + remaining_bytes = remaining_bytes.saturating_sub(old_bytes); + migrated.insert(store_key); + } + } else { + // Tier migration. + let warm_bytes: usize = + self.tier2_data.values().map(|b| b.packed.len()).sum(); + let target_bits = crate::tiering::bits_for_tier( + config, + to_tiering_tier(target_tier), + warm_bytes, + ); + + let old_tier_u8 = current_tier as u8; + let new_tier_u8 = target_tier as u8; + + if self.migrate_block(store_key, target_tier, target_bits).is_ok() { + let new_bytes = self + .index + .get(&store_key) + .map(|m| m.block_bytes as usize) + .unwrap_or(0); + + if new_tier_u8 < old_tier_u8 { + // Upgrade (hotter tier). + result.upgrades += 1; + } else { + // Downgrade (colder tier). + result.downgrades += 1; + result.bytes_freed += old_bytes.saturating_sub(new_bytes); + } + + // Record witness event for the tier change. + let reason = if new_tier_u8 < old_tier_u8 { + crate::metrics::TierChangeReason::ScoreUpgrade + } else { + crate::metrics::TierChangeReason::ScoreDowngrade + }; + self.witness_log.record( + now, + crate::metrics::WitnessEvent::TierChange { + key: store_key, + from_tier: current_tier, + to_tier: target_tier, + score: candidate.score, + reason, + }, + ); + + remaining_ops -= 1; + result.ops_used += 1; + remaining_bytes = remaining_bytes.saturating_sub(old_bytes); + migrated.insert(store_key); + } + } + } + + // Step 4: For blocks not migrated, increment tier_age and call tick_decay. + for key in &store_keys { + if migrated.contains(key) { + continue; + } + if let Some(meta) = self.index.get_mut(key) { + meta.tier_age = meta.tier_age.saturating_add(1); + // Apply tick_decay via the tiering module. + let mut tm = crate::tiering::BlockMeta { + ema_rate: meta.ema_rate, + access_window: meta.window, + last_access: meta.last_access_at, + access_count: meta.access_count as u64, + current_tier: to_tiering_tier(meta.tier), + tier_since: now.saturating_sub(meta.tier_age as u64), + }; + crate::tiering::tick_decay(config, &mut tm); + meta.ema_rate = tm.ema_rate; + meta.window = tm.access_window; + } + } + + // Record a maintenance witness event. + self.witness_log.record( + now, + crate::metrics::WitnessEvent::Maintenance { + upgrades: result.upgrades, + downgrades: result.downgrades, + evictions: result.evictions, + bytes_freed: result.bytes_freed, + budget_remaining_bytes: remaining_bytes.min(u32::MAX as usize) as u32, + budget_remaining_ops: remaining_ops, + }, + ); + + result + } + + /// Migrate a single block from one tier to another. + /// + /// Re-quantizes the data at the target tier's bit width. The block's + /// metadata is updated with the new tier, bits, scale, checksum, and + /// `tier_age` is reset to 0. + fn migrate_block( + &mut self, + key: BlockKey, + target_tier: Tier, + target_bits: u8, + ) -> Result<(), StoreError> { + // Read current metadata (copy fields to release the borrow). + let meta = self.index.get(&key).ok_or(StoreError::BlockNotFound)?; + let old_tier = meta.tier; + let old_bits = meta.bits; + let old_scale = meta.scale; + + if old_tier == Tier::Tier0 { + return Err(StoreError::TensorEvicted); + } + if target_tier == Tier::Tier0 { + return Err(StoreError::InvalidBlock); + } + + // Dequantize the old data to f32 within a limited scope so the + // immutable borrow on self (through data_map) is released before + // we need mutable access. + let (element_count, f32_data) = { + let block = self + .data_map(old_tier) + .and_then(|m| m.get(&key)) + .ok_or(StoreError::BlockNotFound)?; + let ec = block.element_count; + let mut data = vec![0.0f32; ec as usize]; + dequantize_block(&block.packed, old_scale, old_bits, ec as usize, &mut data); + (ec, data) + }; + + // Re-quantize at the target bit width. + let (packed, scale) = quantize_block(&f32_data, target_bits); + let checksum = block_checksum(&packed, scale); + let byte_count = packed.len() as u32; + let new_block = BlockData { + element_count, + packed, + }; + + // Remove from old tier. + self.remove_data(old_tier, key); + self.remove_from_bucket(old_tier, key); + + // Insert into target tier. + match target_tier { + Tier::Tier1 => { self.tier1_data.insert(key, new_block); } + Tier::Tier2 => { self.tier2_data.insert(key, new_block); } + Tier::Tier3 => { self.tier3_data.insert(key, new_block); } + Tier::Tier0 => unreachable!(), + } + self.add_to_bucket(target_tier, key); + + // Update metadata. + let meta = self.index.get_mut(&key).unwrap(); + meta.tier = target_tier; + meta.bits = target_bits; + meta.scale = scale; + meta.checksum = checksum; + meta.tier_age = 0; + meta.block_bytes = byte_count; + + Ok(()) + } + + /// Compute the current score for a block using the enhanced tiering + /// algorithm (EMA + popcount + recency). + /// + /// Returns `None` if the block does not exist. + pub fn score_block( + &self, + key: BlockKey, + config: &crate::tiering::TierConfig, + now: u64, + ) -> Option { + let meta = self.index.get(&key)?; + let tm = to_tiering_meta(meta, now); + Some(crate::tiering::compute_score(config, now, &tm)) + } + + /// Record an access event using the enhanced tiering algorithm. + /// + /// Updates `ema_rate`, `access_window`, `last_access_at`, and + /// `access_count` using the configurable alpha from [`TierConfig`]. + /// Does nothing if the key is not present. + pub fn touch_block( + &mut self, + key: BlockKey, + config: &crate::tiering::TierConfig, + now: u64, + ) { + if let Some(meta) = self.index.get_mut(&key) { + let mut tm = crate::tiering::BlockMeta { + ema_rate: meta.ema_rate, + access_window: meta.window, + last_access: meta.last_access_at, + access_count: meta.access_count as u64, + current_tier: to_tiering_tier(meta.tier), + tier_since: now.saturating_sub(meta.tier_age as u64), + }; + crate::tiering::touch(config, now, &mut tm); + meta.ema_rate = tm.ema_rate; + meta.window = tm.access_window; + meta.last_access_at = tm.last_access; + meta.access_count = tm.access_count.min(u32::MAX as u64) as u32; + } + } } // --------------------------------------------------------------------------- @@ -851,7 +1260,7 @@ mod tests { store.put(key, &data, Tier::Tier1, 0).unwrap(); let mut out = vec![0.0f32; 64]; - let n = store.get(key, &mut out).unwrap(); + let n = TieredStore::get(&mut store, key, &mut out, 1).unwrap(); assert_eq!(n, 64); for (i, (&orig, &dec)) in data.iter().zip(out.iter()).enumerate() { @@ -875,7 +1284,7 @@ mod tests { assert_eq!(meta.created_at, 100); let mut out = vec![0.0f32; 32]; - let n = store.get(key, &mut out).unwrap(); + let n = TieredStore::get(&mut store, key, &mut out, 101).unwrap(); assert_eq!(n, 32); let max_val = data.iter().map(|v| v.abs()).fold(0.0f32, f32::max); @@ -887,10 +1296,10 @@ mod tests { #[test] fn test_store_get_not_found() { - let store = TieredStore::new(4096); + let mut store = TieredStore::new(4096); let key = make_key(99, 0); let mut out = vec![0.0f32; 8]; - assert_eq!(store.get(key, &mut out), Err(StoreError::BlockNotFound)); + assert_eq!(TieredStore::get(&mut store, key, &mut out, 0), Err(StoreError::BlockNotFound)); } #[test] @@ -927,7 +1336,7 @@ mod tests { // Data is gone; read should fail with TensorEvicted. let mut out = vec![0.0f32; 64]; - assert_eq!(store.get(key, &mut out), Err(StoreError::TensorEvicted)); + assert_eq!(TieredStore::get(&mut store, key, &mut out, 1), Err(StoreError::TensorEvicted)); // Tier1 should be empty; Tier0 count should be 1. assert_eq!(store.tier_count(Tier::Tier1), 0); @@ -1259,7 +1668,127 @@ mod tests { // Read back a hot block. let mut out = vec![0.0f32; 32]; - let n = store.get(make_key(1, 0), &mut out).unwrap(); + let n = TieredStore::get(&mut store, make_key(1, 0), &mut out, 30).unwrap(); assert_eq!(n, 32); } + + // -- tick / score / touch_block ----------------------------------------- + + #[test] + fn test_tick_empty_store() { + let mut store = TieredStore::new(4096); + let config = crate::tiering::TierConfig::default(); + let result = store.tick(&config, 100, 1_000_000, 100); + assert_eq!(result.upgrades, 0); + assert_eq!(result.downgrades, 0); + assert_eq!(result.evictions, 0); + assert_eq!(result.bytes_freed, 0); + assert_eq!(result.ops_used, 0); + assert_eq!(result.candidates_found, 0); + } + + #[test] + fn test_tick_migrates_cold_to_hot() { + let mut store = TieredStore::new(4096); + let key = make_key(1, 0); + let data: Vec = (0..64).map(|i| i as f32 * 0.1).collect(); + + // Put block in Tier3 (cold). + store.put(key, &data, Tier::Tier3, 0).unwrap(); + assert_eq!(store.tier_count(Tier::Tier3), 1); + + // Simulate a highly-accessed block by directly setting metadata + // fields so that the tiering score exceeds t1 + hysteresis. + if let Some(meta) = store.index.get_mut(&key) { + meta.ema_rate = 1.0; + meta.window = u64::MAX; // all 64 bits set + meta.last_access_at = 100; + meta.access_count = 100; + meta.tier_age = 10; // past default min_residency (5) + } + + let config = crate::tiering::TierConfig::default(); + let result = store.tick(&config, 100, 1_000_000, 100); + + assert!(result.upgrades > 0, "expected at least one upgrade, got {}", result.upgrades); + assert_eq!(result.downgrades, 0); + assert!(result.candidates_found > 0); + + let meta = store.meta(key).unwrap(); + assert_eq!(meta.tier, Tier::Tier1, "block should be in Tier1 after upgrade"); + assert_eq!(meta.bits, 8, "Tier1 should use 8-bit quantization"); + assert_eq!(meta.tier_age, 0, "tier_age should reset after migration"); + + // The block should still be readable. + let mut out = vec![0.0f32; 64]; + let n = TieredStore::get(&mut store, key, &mut out, 101).unwrap(); + assert_eq!(n, 64); + } + + #[test] + fn test_tick_respects_budget_ops() { + let mut store = TieredStore::new(4096); + let data: Vec = (0..64).map(|i| i as f32 * 0.1).collect(); + + // Create 5 blocks in Tier3, all hot enough to warrant migration. + for i in 0..5u32 { + let key = make_key(i as u128 + 1, 0); + store.put(key, &data, Tier::Tier3, 0).unwrap(); + if let Some(meta) = store.index.get_mut(&key) { + meta.ema_rate = 1.0; + meta.window = u64::MAX; + meta.last_access_at = 100; + meta.access_count = 100; + meta.tier_age = 10; + } + } + + let config = crate::tiering::TierConfig::default(); + // Budget only 2 ops. + let result = store.tick(&config, 100, 1_000_000, 2); + + assert_eq!(result.ops_used, 2, "should use exactly 2 ops"); + assert_eq!(result.upgrades, 2, "should upgrade only 2 blocks"); + assert!(result.candidates_found >= 5, "should find all 5 candidates"); + } + + #[test] + fn test_touch_block_updates_ema_and_window() { + let mut store = TieredStore::new(4096); + let key = make_key(1, 0); + store.put(key, &[1.0; 16], Tier::Tier1, 0).unwrap(); + + let config = crate::tiering::TierConfig::default(); + + // Initial state: ema_rate is 0 after put. + let meta = store.meta(key).unwrap(); + assert_eq!(meta.ema_rate, 0.0); + + // Touch at tick 5. + store.touch_block(key, &config, 5); + let meta = store.meta(key).unwrap(); + + // tiering::touch sets ema_rate = alpha + (1 - alpha) * old_ema + // = 0.3 + 0.7 * 0.0 = 0.3 + assert!( + (meta.ema_rate - config.alpha).abs() < 1e-6, + "ema_rate={}, expected={}", + meta.ema_rate, + config.alpha, + ); + assert_eq!(meta.last_access_at, 5); + // Window should have bit 0 set after touch. + assert_ne!(meta.window & 1, 0, "bit 0 should be set"); + // Elapsed = 5 ticks from 0, so window = (initial << 5) | 1. + // Initial window from put is 1, so: (1 << 5) | 1 = 0b100001. + assert_eq!(meta.window, (1u64 << 5) | 1); + } + + #[test] + fn test_score_block_none_for_missing() { + let store = TieredStore::new(4096); + let config = crate::tiering::TierConfig::default(); + let result = store.score_block(make_key(99, 0), &config, 100); + assert_eq!(result, None); + } } diff --git a/crates/ruvector-temporal-tensor/tests/integration.rs b/crates/ruvector-temporal-tensor/tests/integration.rs new file mode 100644 index 00000000..7abaad24 --- /dev/null +++ b/crates/ruvector-temporal-tensor/tests/integration.rs @@ -0,0 +1,605 @@ +//! End-to-end integration tests for the temporal tensor store. +//! +//! Exercises the full lifecycle: put, get, tier migration, delta compression, +//! quantization quality, eviction, checksums, witness logging, and factor +//! reconstruction. +//! +//! Run via: `cargo test -p ruvector-temporal-tensor --test integration` + +use ruvector_temporal_tensor::store::{ + BlockKey, Tier, TieredStore, ReconstructPolicy, StoreError, +}; +use ruvector_temporal_tensor::tiering::{self, TierConfig}; +use ruvector_temporal_tensor::delta::{ + DeltaChain, FactorSet, compute_delta, encode_delta, decode_delta, +}; +use ruvector_temporal_tensor::metrics::{ + WitnessLog, WitnessEvent, TierChangeReason, +}; +use ruvector_temporal_tensor::quantizer; +use ruvector_temporal_tensor::segment; +use ruvector_temporal_tensor::{TemporalTensorCompressor, TierPolicy}; + +// --------------------------------------------------------------------------- +// Deterministic PRNG (LCG) -- no external deps +// --------------------------------------------------------------------------- + +/// Simple linear congruential generator. Constants from Knuth MMIX. +struct SimpleRng { + state: u64, +} + +impl SimpleRng { + fn new(seed: u64) -> Self { + Self { state: seed } + } + + fn next_u64(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.state + } + + fn next_f64(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + + fn next_f32(&mut self) -> f32 { + self.next_f64() as f32 + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_key(tid: u128, idx: u32) -> BlockKey { + BlockKey { tensor_id: tid, block_index: idx } +} + +/// Map tiering module Tier to store module Tier. +fn tiering_to_store_tier(t: tiering::Tier) -> Tier { + match t { + tiering::Tier::Tier0 => Tier::Tier0, + tiering::Tier::Tier1 => Tier::Tier1, + tiering::Tier::Tier2 => Tier::Tier2, + tiering::Tier::Tier3 => Tier::Tier3, + } +} + +// =========================================================================== +// 1. Full Lifecycle Test +// =========================================================================== + +/// Put 100 blocks as hot, simulate 1000 ticks touching only 10, then verify +/// that the 90 untouched blocks migrate to colder tiers. +#[test] +fn test_full_lifecycle() { + let mut store = TieredStore::new(4096); + let tier_config = TierConfig::default(); + let n_elems = 64; + + let mut rng = SimpleRng::new(42); + let block_data: Vec> = (0..100) + .map(|_| (0..n_elems).map(|_| rng.next_f32() * 2.0 - 1.0).collect()) + .collect(); + + // Put 100 blocks as Tier1 (hot). + for i in 0..100u32 { + store.put(make_key(1, i), &block_data[i as usize], Tier::Tier1, 0).unwrap(); + } + assert_eq!(store.tier_count(Tier::Tier1), 100); + assert_eq!(store.block_count(), 100); + + // Parallel tiering metadata for migration scoring. + let mut tiering_metas: Vec = + (0..100).map(|_| tiering::BlockMeta::new(0)).collect(); + + // Simulate 1000 ticks -- only blocks 0..10 are accessed. + for tick in 1..=1000u64 { + for i in 0..10 { + store.touch(make_key(1, i as u32), tick); + tiering::touch(&tier_config, tick, &mut tiering_metas[i]); + } + for i in 10..100 { + tiering::tick_decay(&tier_config, &mut tiering_metas[i]); + } + } + + // Apply tier migration decisions. + let mut migrated = 0u32; + for i in 0..100u32 { + if let Some(target) = tiering::choose_tier(&tier_config, 1000, &tiering_metas[i as usize]) { + let st = tiering_to_store_tier(target); + if st != Tier::Tier0 { + store.put(make_key(1, i), &block_data[i as usize], st, 1000).unwrap(); + migrated += 1; + } + } + } + + let tier1 = store.tier_count(Tier::Tier1); + let tier2 = store.tier_count(Tier::Tier2); + let tier3 = store.tier_count(Tier::Tier3); + + assert!(migrated > 0, "expected migrations, got none"); + assert!(tier1 < 100, "expected fewer Tier1 blocks after migration, got {}", tier1); + assert!(tier1 <= 20, "hot blocks should be ~10, got {}", tier1); + assert!(tier2 + tier3 >= 80, "expected >=80 in lower tiers, got {} + {}", tier2, tier3); + assert_eq!(store.block_count(), 100); +} + +// =========================================================================== +// 2. Delta Chain Lifecycle Test +// =========================================================================== + +/// Build a delta chain with 5 incremental deltas, reconstruct, compact, +/// verify encode/decode roundtrip. +#[test] +fn test_delta_chain_lifecycle() { + let n = 256; + let mut rng = SimpleRng::new(99); + let base: Vec = (0..n).map(|_| rng.next_f32() * 2.0 - 1.0).collect(); + let mut chain = DeltaChain::new(base.clone(), 8); + + // Build 5 incremental deltas (~10% change each). + let mut current = base.clone(); + for epoch in 0..5u64 { + let mut next = current.clone(); + for i in 0..n { + if (rng.next_u64() % 10) == 0 { + next[i] += (rng.next_f32() - 0.5) * 0.1; + } + } + let delta = compute_delta(¤t, &next, 1, 0, epoch, 0.001, 0.5) + .expect("delta should be computable for ~10% change"); + chain.append(delta).unwrap(); + current = next; + } + assert_eq!(chain.chain_len(), 5); + + // Reconstruct and verify accuracy against the final state. + let reconstructed = chain.reconstruct(); + assert_eq!(reconstructed.len(), n); + for i in 0..n { + let err = (reconstructed[i] - current[i]).abs(); + assert!(err < 0.01, "recon err at {}: {} vs {} (err={})", i, reconstructed[i], current[i], err); + } + + // Encode/decode the last delta and verify roundtrip. + let last_delta = compute_delta(&base, ¤t, 1, 0, 99, 0.001, 1.1).unwrap(); + let encoded = encode_delta(&last_delta); + let decoded = decode_delta(&encoded).unwrap(); + assert_eq!(decoded.header.tensor_id, 1); + assert_eq!(decoded.entries.len(), last_delta.entries.len()); + + // Compact the chain; delta list drops to 0 but state is preserved. + let before_compact = reconstructed.clone(); + chain.compact(); + assert_eq!(chain.chain_len(), 0); + + let after_compact = chain.reconstruct(); + for i in 0..n { + let err = (after_compact[i] - before_compact[i]).abs(); + assert!(err < 1e-6, "compact mismatch at {}: {} vs {}", i, after_compact[i], before_compact[i]); + } +} + +// =========================================================================== +// 3. Quantization Quality Sweep +// =========================================================================== + +/// For each bit width (8, 7, 5, 3) verify MSE and max relative error +/// stay within ADR-023 bounds. +#[test] +fn test_quality_sweep_all_tiers() { + let n_elems = 256; + let mut rng = SimpleRng::new(7777); + + // Sinusoidal + noise with guaranteed minimum magnitude. + let data: Vec = (0..n_elems) + .map(|i| { + let base = (i as f32 * 0.05).sin(); + let noise = (rng.next_f32() - 0.5) * 0.1; + let val = base + noise; + if val.abs() < 0.05 { + if val >= 0.0 { 0.05 + rng.next_f32() * 0.1 } else { -0.05 - rng.next_f32() * 0.1 } + } else { + val + } + }) + .collect(); + + let max_abs: f32 = data.iter().map(|v| v.abs()).fold(0.0f32, f32::max); + + // Store-backed tiers: (tier, bound_vs_max, label). + let store_configs: &[(Tier, f64, &str)] = &[ + (Tier::Tier1, 0.01, "8-bit/Tier1"), + (Tier::Tier2, 0.02, "7-bit/Tier2"), + (Tier::Tier3, 0.35, "3-bit/Tier3"), + ]; + + let mut store = TieredStore::new(4096); + for &(tier, bound, label) in store_configs { + let key = make_key(tier as u128 + 100, 0); + store.put(key, &data, tier, 0).unwrap(); + + let mut out = vec![0.0f32; n_elems]; + let n = store.get(key, &mut out, 0).unwrap(); + assert_eq!(n, n_elems); + + let mut max_rel = 0.0f64; + let mut mse = 0.0f64; + for i in 0..n_elems { + let err = (data[i] - out[i]) as f64; + mse += err * err; + let rel = err.abs() / max_abs as f64; + if rel > max_rel { max_rel = rel; } + } + mse /= n_elems as f64; + + assert!(max_rel < bound, "{}: max_rel {:.4} >= bound {:.4} (MSE={:.8})", label, max_rel, bound, mse); + } + + // 5-bit via groupwise quantizer directly (no store tier for 5-bit). + { + let scales = quantizer::compute_scales(&data, 64, 5); + let mut packed = Vec::new(); + quantizer::quantize_and_pack(&data, &scales, 64, 5, &mut packed); + let mut decoded = Vec::new(); + quantizer::dequantize(&packed, &scales, 64, 5, n_elems, 1, &mut decoded); + + let mut max_rel = 0.0f64; + for i in 0..n_elems { + let err = (data[i] - decoded[i]) as f64; + let rel = err.abs() / max_abs as f64; + if rel > max_rel { max_rel = rel; } + } + assert!(max_rel < 0.07, "5-bit: max_rel {:.4} >= 0.07", max_rel); + } +} + +// =========================================================================== +// 4. Store Persistence Roundtrip +// =========================================================================== + +/// Put 50 blocks with varied data and tiers, get each back and verify data +/// and metadata. +#[test] +fn test_store_put_get_roundtrip() { + let mut store = TieredStore::new(4096); + let mut rng = SimpleRng::new(1234); + let n_elems = 64; + let tiers = [Tier::Tier1, Tier::Tier2, Tier::Tier3]; + + let mut block_data: Vec> = Vec::new(); + let mut block_tiers: Vec = Vec::new(); + + for i in 0..50u32 { + let d: Vec = (0..n_elems).map(|_| rng.next_f32() * 2.0 - 1.0).collect(); + let tier = tiers[(i % 3) as usize]; + store.put(make_key(42, i), &d, tier, i as u64).unwrap(); + block_data.push(d); + block_tiers.push(tier); + } + assert_eq!(store.block_count(), 50); + + for i in 0..50u32 { + let key = make_key(42, i); + let mut out = vec![0.0f32; n_elems]; + let n = store.get(key, &mut out, i as u64).unwrap(); + assert_eq!(n, n_elems); + + let meta = store.meta(key).unwrap(); + assert_eq!(meta.tier, block_tiers[i as usize]); + assert_eq!(meta.created_at, i as u64); + + let max_abs: f32 = block_data[i as usize].iter().map(|v| v.abs()).fold(0.0f32, f32::max); + let tol = match block_tiers[i as usize] { + Tier::Tier1 => max_abs * 0.01, + Tier::Tier2 => max_abs * 0.02, + Tier::Tier3 => max_abs * 0.35, + Tier::Tier0 => unreachable!(), + } + .max(1e-6); + + for j in 0..n_elems { + let err = (block_data[i as usize][j] - out[j]).abs(); + assert!(err < tol, "block {} elem {}: err={} tol={}", i, j, err, tol); + } + } +} + +// =========================================================================== +// 5. Eviction and Tier0 +// =========================================================================== + +/// Put a block at Tier1, evict it, verify reads fail and metadata reflects +/// eviction state. +#[test] +fn test_eviction_to_tier0() { + let mut store = TieredStore::new(4096); + let key = make_key(1, 0); + let data = vec![1.0f32; 64]; + + store.put(key, &data, Tier::Tier1, 0).unwrap(); + assert_eq!(store.tier_count(Tier::Tier1), 1); + assert!(store.total_bytes() > 0); + + store.evict(key, ReconstructPolicy::None).unwrap(); + + // Read should fail. + let mut out = vec![0.0f32; 64]; + assert_eq!(store.get(key, &mut out, 1), Err(StoreError::TensorEvicted)); + + // Metadata should reflect Tier0. + let meta = store.meta(key).unwrap(); + assert_eq!(meta.tier, Tier::Tier0); + assert_eq!(meta.bits, 0); + assert_eq!(meta.block_bytes, 0); + assert_eq!(meta.reconstruct, ReconstructPolicy::None); + + assert_eq!(store.tier_count(Tier::Tier1), 0); + assert_eq!(store.tier_count(Tier::Tier0), 1); + assert_eq!(store.block_count(), 1); + assert_eq!(store.total_bytes(), 0); +} + +// =========================================================================== +// 6. Checksum Integrity +// =========================================================================== + +/// Verify that checksums are non-zero and deterministic for the same data. +#[test] +fn test_checksum_integrity() { + let mut store = TieredStore::new(4096); + let data: Vec = (0..128).map(|i| (i as f32) * 0.1).collect(); + + let key1 = make_key(1, 0); + store.put(key1, &data, Tier::Tier1, 0).unwrap(); + let cksum1 = store.meta(key1).unwrap().checksum; + assert_ne!(cksum1, 0, "checksum should be non-zero for non-trivial data"); + + // Same data under a different key produces the same checksum. + let key2 = make_key(1, 1); + store.put(key2, &data, Tier::Tier1, 0).unwrap(); + assert_eq!(store.meta(key2).unwrap().checksum, cksum1); + + // Different data produces a different checksum. + let other: Vec = (0..128).map(|i| (i as f32) * 0.2).collect(); + let key3 = make_key(1, 2); + store.put(key3, &other, Tier::Tier1, 0).unwrap(); + assert_ne!(store.meta(key3).unwrap().checksum, cksum1); +} + +// =========================================================================== +// 7. Multi-Tensor Store +// =========================================================================== + +/// Blocks from 3 different tensor_ids are stored and retrieved independently. +#[test] +fn test_multiple_tensors() { + let mut store = TieredStore::new(4096); + let n_elems = 32; + let mut rng = SimpleRng::new(555); + + let tensor_ids: [u128; 3] = [100, 200, 300]; + let mut all_data: Vec>> = Vec::new(); + + for &tid in &tensor_ids { + let mut tensor_blocks = Vec::new(); + for blk in 0..5u32 { + let d: Vec = (0..n_elems).map(|_| rng.next_f32() * 2.0 - 1.0).collect(); + store.put(make_key(tid, blk), &d, Tier::Tier1, 0).unwrap(); + tensor_blocks.push(d); + } + all_data.push(tensor_blocks); + } + assert_eq!(store.block_count(), 15); + + for (t_idx, &tid) in tensor_ids.iter().enumerate() { + for blk in 0..5u32 { + let key = make_key(tid, blk); + let mut out = vec![0.0f32; n_elems]; + let n = store.get(key, &mut out, 0).unwrap(); + assert_eq!(n, n_elems); + + let meta = store.meta(key).unwrap(); + assert_eq!(meta.key.tensor_id, tid); + assert_eq!(meta.key.block_index, blk); + + let orig = &all_data[t_idx][blk as usize]; + let max_abs: f32 = orig.iter().map(|v| v.abs()).fold(0.0f32, f32::max); + let tol = (max_abs * 0.01).max(1e-6); + for j in 0..n_elems { + let err = (orig[j] - out[j]).abs(); + assert!(err < tol, "tid={} blk={} j={}: err={}", tid, blk, j, err); + } + } + } +} + +// =========================================================================== +// 8. Stress Test +// =========================================================================== + +/// Put 1000 blocks with random tiers, touch random blocks 10000 times, +/// verify no panics and all blocks remain readable. +#[test] +fn test_stress_1000_blocks() { + let mut store = TieredStore::new(4096); + let mut rng = SimpleRng::new(0xDEADBEEF); + let n_elems = 32; + let tiers = [Tier::Tier1, Tier::Tier2, Tier::Tier3]; + + for i in 0..1000u32 { + let d: Vec = (0..n_elems).map(|_| rng.next_f32() * 2.0 - 1.0).collect(); + let tier = tiers[(rng.next_u64() % 3) as usize]; + store.put(make_key(1, i), &d, tier, i as u64).unwrap(); + } + assert_eq!(store.block_count(), 1000); + assert!(store.total_bytes() > 0); + + for t in 0..10_000u64 { + let idx = (rng.next_u64() % 1000) as u32; + store.touch(make_key(1, idx), 1000 + t); + } + + for i in 0..1000u32 { + let mut out = vec![0.0f32; n_elems]; + let n = store.get(make_key(1, i), &mut out, 20_000).unwrap(); + assert_eq!(n, n_elems); + for j in 0..n_elems { + assert!(out[j].is_finite(), "block {} elem {} not finite", i, j); + } + } + assert!(store.total_bytes() > 0); +} + +// =========================================================================== +// 9. Compressor + Store Integration +// =========================================================================== + +/// Compress frames via TemporalTensorCompressor, decode the segment, store +/// each decoded frame as a block, and verify roundtrip. +#[test] +fn test_compressor_to_store() { + let tensor_len = 128u32; + let policy = TierPolicy::default(); + let mut comp = TemporalTensorCompressor::new(policy, tensor_len, 0); + comp.set_access(100, 0); // hot -> 8-bit + + let mut rng = SimpleRng::new(0xCAFE); + let n_frames = 10usize; + + let frames: Vec> = (0..n_frames) + .map(|_| (0..tensor_len as usize).map(|_| rng.next_f32() * 2.0 - 1.0).collect()) + .collect(); + + let mut seg = Vec::new(); + for (i, frame) in frames.iter().enumerate() { + comp.push_frame(frame, (i + 1) as u32, &mut seg); + } + comp.flush(&mut seg); + assert!(!seg.is_empty(), "compressor should produce a segment"); + + let mut decoded = Vec::new(); + segment::decode(&seg, &mut decoded); + assert_eq!(decoded.len(), tensor_len as usize * n_frames); + + // Store each decoded frame as a block. + let mut store = TieredStore::new(4096); + for i in 0..n_frames { + let start = i * tensor_len as usize; + let end = start + tensor_len as usize; + store.put(make_key(50, i as u32), &decoded[start..end], Tier::Tier1, i as u64).unwrap(); + } + assert_eq!(store.block_count(), n_frames); + + // Read back and verify against the decoded data (double quantization). + for i in 0..n_frames { + let mut out = vec![0.0f32; tensor_len as usize]; + let n = store.get(make_key(50, i as u32), &mut out, n_frames as u64).unwrap(); + assert_eq!(n, tensor_len as usize); + + let start = i * tensor_len as usize; + for j in 0..tensor_len as usize { + let expected = decoded[start + j]; + let err = (expected - out[j]).abs(); + // Double quantization (compressor + store) compounds error. + let tol = if expected.abs() > 0.01 { expected.abs() * 0.04 } else { 0.05 }; + assert!(err < tol, "frame {} elem {}: exp={} got={} err={}", i, j, expected, out[j], err); + } + } +} + +// =========================================================================== +// 10. Factor Reconstruction Quality +// =========================================================================== + +/// Create a low-rank matrix, factor it, reconstruct, and verify error is low. +#[test] +fn test_factor_reconstruction_quality() { + let m = 16; + let n = 16; + + // Rank-1 matrix: data[i][j] = (i+1)*(j+1) / (m*n). + let data: Vec = (0..m * n) + .map(|idx| { + let (i, j) = (idx / n, idx % n); + (i as f32 + 1.0) * (j as f32 + 1.0) / (m * n) as f32 + }) + .collect(); + + let factors = FactorSet::from_data(&data, m, n, 1); + assert_eq!(factors.m, m); + assert_eq!(factors.n, n); + assert_eq!(factors.k, 1); + + let reconstructed = factors.reconstruct(); + assert_eq!(reconstructed.len(), m * n); + + let max_abs: f32 = data.iter().map(|v| v.abs()).fold(0.0f32, f32::max); + let mut max_err = 0.0f32; + for i in 0..m * n { + let err = (data[i] - reconstructed[i]).abs(); + if err > max_err { max_err = err; } + } + + assert!( + max_err < max_abs * 0.01, + "factor reconstruction error too high: max_err={} (max_abs={})", + max_err, max_abs + ); + + // Factor storage should be smaller than the full matrix. + assert!(factors.storage_bytes() > 0); + assert!( + factors.storage_bytes() < m * n * 4, + "factor storage {} should be < original {}", + factors.storage_bytes(), m * n * 4 + ); +} + +// =========================================================================== +// 11. Witness Logging Integration +// =========================================================================== + +/// Record access, tier-change, and eviction events; verify counters and +/// flip-rate calculation. +#[test] +fn test_witness_logging() { + let mut log = WitnessLog::new(256); + let mut store = TieredStore::new(4096); + + let key = make_key(1, 0); + store.put(key, &vec![1.0f32; 64], Tier::Tier1, 0).unwrap(); + + log.record(0, WitnessEvent::Access { key, score: 0.95, tier: Tier::Tier1 }); + log.record(100, WitnessEvent::TierChange { + key, + from_tier: Tier::Tier1, + to_tier: Tier::Tier2, + score: 0.45, + reason: TierChangeReason::ScoreDowngrade, + }); + + store.evict(key, ReconstructPolicy::None).unwrap(); + log.record(200, WitnessEvent::Eviction { key, score: 0.05, bytes_freed: 64 }); + + assert_eq!(log.len(), 3); + assert_eq!(log.count_tier_changes(), 1); + assert_eq!(log.count_evictions(), 1); + assert_eq!(log.count_checksum_failures(), 0); + + let recent = log.recent(2); + assert_eq!(recent.len(), 2); + assert_eq!(recent[0].timestamp, 100); + assert_eq!(recent[1].timestamp, 200); + + // One tier change across 1 block in the window = flip rate 1.0. + let rate = log.tier_flip_rate(300, 1); + assert!((rate - 1.0).abs() < 1e-6, "expected flip rate 1.0, got {}", rate); +} diff --git a/crates/ruvector-temporal-tensor/tests/property_tests.rs b/crates/ruvector-temporal-tensor/tests/property_tests.rs new file mode 100644 index 00000000..0ce35485 --- /dev/null +++ b/crates/ruvector-temporal-tensor/tests/property_tests.rs @@ -0,0 +1,893 @@ +//! Property-based roundtrip tests for temporal tensor compression. +//! +//! Verifies quantization roundtrip correctness across many random inputs +//! using a deterministic PRNG. No external dependencies. +//! +//! Run with: +//! ```sh +//! cargo test --release -p ruvector-temporal-tensor --test property_tests -- --nocapture +//! ``` + +use ruvector_temporal_tensor::bitpack; +use ruvector_temporal_tensor::delta; +use ruvector_temporal_tensor::f16; +use ruvector_temporal_tensor::quantizer; +use ruvector_temporal_tensor::segment; +use ruvector_temporal_tensor::tiering::{self, BlockMeta, TierConfig}; + +// --------------------------------------------------------------------------- +// Deterministic PRNG (LCG) -- no external deps +// --------------------------------------------------------------------------- + +/// Simple linear congruential generator. Constants from Knuth MMIX. +struct SimpleRng { + state: u64, +} + +impl SimpleRng { + fn new(seed: u64) -> Self { + Self { state: seed } + } + + fn next_u64(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.state + } + + fn next_f32(&mut self) -> f32 { + (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32 + } + + fn next_f32_range(&mut self, lo: f32, hi: f32) -> f32 { + lo + self.next_f32() * (hi - lo) + } + + fn next_usize_range(&mut self, lo: usize, hi: usize) -> usize { + let range = (hi - lo) as u64; + if range == 0 { + return lo; + } + lo + (self.next_u64() % range) as usize + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const GROUP_LEN: usize = 64; + +/// Generate a random f32 vector of the given length with values in [lo, hi]. +fn random_vec(rng: &mut SimpleRng, len: usize, lo: f32, hi: f32) -> Vec { + (0..len).map(|_| rng.next_f32_range(lo, hi)).collect() +} + +/// Compute group-level maximum absolute values for error bounding. +fn group_max_abs(frame: &[f32], group_len: usize) -> Vec { + frame + .chunks(group_len) + .map(|chunk| { + chunk + .iter() + .filter(|v| v.is_finite()) + .map(|v| v.abs()) + .fold(0.0f32, f32::max) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// 1. Quantize/Dequant Roundtrip Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_roundtrip_error_bounded() { + let mut rng = SimpleRng::new(0xDEAD_BEEF_CAFE_BABE); + + // Error bounds as fraction of each group's max absolute value. + // The absolute error per element is bounded by: + // scale * 1 (one quantization step) + f16 rounding (~0.1% of scale) + // where scale = group_max_abs / qmax. So the error fraction of group_max is + // approximately 1/qmax + small f16 term. + // 8-bit: qmax=127, ~0.8% + margin -> 1% + // 7-bit: qmax=63, ~1.6% + margin -> 2% + // 5-bit: qmax=15, ~6.7% + margin -> 7% + // 3-bit: qmax=3, ~33% + margin -> 35% + let bit_configs: &[(u8, f32)] = &[ + (8, 0.01), // 8-bit: < 1% of group max + (7, 0.02), // 7-bit: < 2% of group max + (5, 0.07), // 5-bit: < 7% of group max + (3, 0.35), // 3-bit: < 35% of group max + ]; + + for trial in 0..1000 { + let len = rng.next_usize_range(64, 513); // 64..512 inclusive + let frame = random_vec(&mut rng, len, -10.0, 10.0); + + for &(bits, max_err_frac) in bit_configs { + let scales = quantizer::compute_scales(&frame, GROUP_LEN, bits); + let scales_f32 = quantizer::scales_to_f32(&scales); + + let mut packed = Vec::new(); + quantizer::quantize_and_pack_f32(&frame, &scales_f32, GROUP_LEN, bits, &mut packed); + + let mut decoded = Vec::new(); + quantizer::dequantize_f32( + &packed, + &scales_f32, + GROUP_LEN, + bits, + frame.len(), + 1, + &mut decoded, + ); + + assert_eq!( + decoded.len(), + frame.len(), + "trial={trial}, bits={bits}: length mismatch" + ); + + // Compute per-group max absolute value for error bounding. + let gmax = group_max_abs(&frame, GROUP_LEN); + + for (i, (&orig, &dec)) in frame.iter().zip(decoded.iter()).enumerate() { + let abs_err = (orig - dec).abs(); + let group_idx = i / GROUP_LEN; + let group_m = if group_idx < gmax.len() { gmax[group_idx] } else { 1.0 }; + // Bound: max_err_frac * group_max + small absolute floor for near-zero groups. + let bound = max_err_frac * group_m + 1e-6; + assert!( + abs_err <= bound, + "trial={trial}, bits={bits}, i={i}: orig={orig}, dec={dec}, \ + abs_err={abs_err}, bound={bound}, group_max={group_m}" + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// 2. Bit Packing Roundtrip Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_bitpack_roundtrip() { + let mut rng = SimpleRng::new(0x1234_5678_9ABC_DEF0); + + let bit_widths: &[u32] = &[3, 5, 7, 8]; + + for _trial in 0..1000 { + let count = rng.next_usize_range(1, 513); + + for &bits in bit_widths { + let max_val = (1u32 << bits) - 1; + let codes: Vec = (0..count) + .map(|_| (rng.next_u64() as u32) % (max_val + 1)) + .collect(); + + let mut packed = Vec::new(); + bitpack::pack(&codes, bits, &mut packed); + + let mut unpacked = Vec::new(); + bitpack::unpack(&packed, bits, count, &mut unpacked); + + assert_eq!( + codes, unpacked, + "bits={bits}, count={count}: pack/unpack mismatch" + ); + } + } +} + +// --------------------------------------------------------------------------- +// 3. Segment Encode/Decode Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_segment_roundtrip() { + let mut rng = SimpleRng::new(0xFEED_FACE_DEAD_C0DE); + + let tensor_lens: &[usize] = &[32, 64, 128, 256, 512]; + let frame_counts: &[usize] = &[1, 2, 5, 10, 20]; + let bit_widths: &[u8] = &[3, 5, 7, 8]; + + for _trial in 0..200 { + let tensor_len = tensor_lens[rng.next_usize_range(0, tensor_lens.len())]; + let frame_count = frame_counts[rng.next_usize_range(0, frame_counts.len())]; + let bits = bit_widths[rng.next_usize_range(0, bit_widths.len())]; + + // Generate the first frame and compute scales from it (shared across frames). + let first_frame = random_vec(&mut rng, tensor_len, -5.0, 5.0); + let scales = quantizer::compute_scales(&first_frame, GROUP_LEN, bits); + let scales_f32 = quantizer::scales_to_f32(&scales); + + // Quantize all frames with the same scales. + let mut packed = Vec::new(); + quantizer::quantize_and_pack_f32( + &first_frame, + &scales_f32, + GROUP_LEN, + bits, + &mut packed, + ); + for _ in 1..frame_count { + // Subsequent frames use values within the first frame's range to fit scales. + let frame = random_vec(&mut rng, tensor_len, -4.0, 4.0); + quantizer::quantize_and_pack_f32( + &frame, + &scales_f32, + GROUP_LEN, + bits, + &mut packed, + ); + } + + // Encode into segment format. + let mut seg = Vec::new(); + segment::encode( + bits, + GROUP_LEN as u32, + tensor_len as u32, + frame_count as u32, + &scales, + &packed, + &mut seg, + ); + + // Decode the segment. + let mut decoded = Vec::new(); + segment::decode(&seg, &mut decoded); + + assert_eq!( + decoded.len(), + tensor_len * frame_count, + "trial={_trial}, bits={bits}, tensor_len={tensor_len}, frames={frame_count}: \ + decoded length mismatch" + ); + + // Parse the header and verify metadata. + let header = segment::parse_header(&seg).expect("header should parse"); + assert_eq!(header.bits, bits); + assert_eq!(header.tensor_len, tensor_len as u32); + assert_eq!(header.frame_count, frame_count as u32); + assert_eq!(header.group_len, GROUP_LEN as u32); + } +} + +// --------------------------------------------------------------------------- +// 4. f16 Roundtrip Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_f16_roundtrip() { + let mut rng = SimpleRng::new(0xAAAA_BBBB_CCCC_DDDD); + + for _trial in 0..10_000 { + // Generate value in scale-relevant range [1e-4, 1e4]. + let v = rng.next_f32_range(1e-4, 1e4); + // Randomly negate half the values. + let v = if rng.next_u64() & 1 == 0 { v } else { -v }; + + let h = f16::f32_to_f16_bits(v); + let back = f16::f16_bits_to_f32(h); + + // f16 has ~0.1% relative error for normal values in this range. + let rel_err = ((back - v) / v).abs(); + assert!( + rel_err < 0.002, + "trial={_trial}: v={v}, back={back}, rel_err={rel_err}" + ); + } +} + +// --------------------------------------------------------------------------- +// 5. Delta Compute/Apply Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_delta_apply_recovers_new() { + let mut rng = SimpleRng::new(0x0123_4567_89AB_CDEF); + + for trial in 0..500 { + let len = rng.next_usize_range(8, 257); + let old = random_vec(&mut rng, len, -5.0, 5.0); + + // Create "new" as old with a small number of perturbations. + let mut new = old.clone(); + let num_changes = rng.next_usize_range(1, (len / 4).max(2)); + for _ in 0..num_changes { + let idx = rng.next_usize_range(0, len); + new[idx] += rng.next_f32_range(-1.0, 1.0); + } + + let threshold = 0.001; + let max_change_frac = 0.8; + let result = delta::compute_delta( + &old, + &new, + trial as u128, + 0, + 0, + threshold, + max_change_frac, + ); + + match result { + Some(d) => { + // Apply delta to old, verify it approximates new. + let mut reconstructed = old.clone(); + delta::apply_delta(&mut reconstructed, &d); + + for i in 0..len { + let err = (reconstructed[i] - new[i]).abs(); + // Two sources of error: + // 1. Entries below threshold are not captured in the delta, + // so the reconstruction error for those is up to `threshold`. + // 2. Captured entries have i16 quantization error of at most + // delta_scale / 2 (half a quantization step). + let tolerance = threshold + d.delta_scale * 1.5 + 1e-6; + assert!( + err <= tolerance, + "trial={trial}, i={i}: recon={}, new={}, err={err}, tol={tolerance}", + reconstructed[i], + new[i] + ); + } + } + None => { + // Delta was too large (>= max_change_fraction). + // Verify that indeed many values changed. + let changed = old + .iter() + .zip(new.iter()) + .filter(|(&o, &n)| (o - n).abs() >= threshold) + .count(); + let fraction = changed as f32 / len as f32; + assert!( + fraction >= max_change_frac, + "trial={trial}: delta was None but change fraction={fraction} < {max_change_frac}" + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// 6. Compression Ratio Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_compression_ratio_matches_theory() { + let mut rng = SimpleRng::new(0xCAFE_D00D_BEEF_FEED); + + let expected: &[(u8, f32)] = &[ + (8, 3.5), + (7, 4.0), + (5, 5.5), + (3, 8.5), + ]; + + for &(bits, min_ratio) in expected { + // Use a 512-element tensor with group_len=64 for consistent measurement. + let frame = random_vec(&mut rng, 512, -1.0, 1.0); + let scales = quantizer::compute_scales(&frame, GROUP_LEN, bits); + let mut packed = Vec::new(); + quantizer::quantize_and_pack(&frame, &scales, GROUP_LEN, bits, &mut packed); + + let raw_bytes = frame.len() * 4; // f32 = 4 bytes + let compressed = packed.len() + scales.len() * 2; // packed data + f16 scales + let ratio = raw_bytes as f32 / compressed as f32; + + assert!( + ratio >= min_ratio, + "bits={bits}: ratio={ratio:.2}x < expected={min_ratio}x \ + (raw={raw_bytes}, compressed={compressed})" + ); + } +} + +// --------------------------------------------------------------------------- +// 7. Score Monotonicity Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_score_monotonic_with_access() { + let mut rng = SimpleRng::new(0x7777_8888_9999_AAAA); + let config = TierConfig::default(); + + for _trial in 0..100 { + let start_tick = rng.next_u64() % 1000; + let mut meta = BlockMeta::new(start_tick); + + // Score before any touch. + let score_before = tiering::compute_score(&config, start_tick, &meta); + + // Touch the block. + tiering::touch(&config, start_tick + 1, &mut meta); + let score_after_touch = tiering::compute_score(&config, start_tick + 1, &meta); + + // Touching should increase (or at minimum maintain) the score. + assert!( + score_after_touch >= score_before - 1e-6, + "trial={_trial}: score decreased after touch: \ + before={score_before}, after={score_after_touch}" + ); + + // Now let time pass without access -- score should decrease. + let score_at_touch = tiering::compute_score(&config, start_tick + 1, &meta); + let score_later = tiering::compute_score(&config, start_tick + 1000, &meta); + + assert!( + score_later <= score_at_touch + 1e-6, + "trial={_trial}: score increased without access: \ + at_touch={score_at_touch}, later={score_later}" + ); + } +} + +// --------------------------------------------------------------------------- +// 8. Zero Vector Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_zero_vector_roundtrip() { + let bit_widths: &[u8] = &[3, 5, 7, 8]; + + for &len in &[64, 128, 256, 512] { + let frame = vec![0.0f32; len]; + + for &bits in bit_widths { + let scales = quantizer::compute_scales(&frame, GROUP_LEN, bits); + let scales_f32 = quantizer::scales_to_f32(&scales); + + // All scales should be zero for a zero vector. + for (i, &s) in scales_f32.iter().enumerate() { + assert_eq!( + s, 0.0, + "len={len}, bits={bits}, group={i}: scale should be 0.0, got {s}" + ); + } + + let mut packed = Vec::new(); + quantizer::quantize_and_pack_f32( + &frame, + &scales_f32, + GROUP_LEN, + bits, + &mut packed, + ); + + let mut decoded = Vec::new(); + quantizer::dequantize_f32( + &packed, + &scales_f32, + GROUP_LEN, + bits, + len, + 1, + &mut decoded, + ); + + assert_eq!(decoded.len(), len); + for (i, &v) in decoded.iter().enumerate() { + assert_eq!( + v, 0.0, + "len={len}, bits={bits}, i={i}: expected 0.0, got {v}" + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// 9. Single-Value (Uniform) Vector Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_uniform_vector_roundtrip() { + let mut rng = SimpleRng::new(0xBBBB_CCCC_DDDD_EEEE); + let bit_widths: &[u8] = &[3, 5, 7, 8]; + + for _trial in 0..200 { + let len = rng.next_usize_range(64, 513); + let value = rng.next_f32_range(-10.0, 10.0); + let frame = vec![value; len]; + + for &bits in bit_widths { + let qmax = bitpack::qmax_from_bits(bits); + if qmax == 0 { + continue; + } + + let scales = quantizer::compute_scales(&frame, GROUP_LEN, bits); + let scales_f32 = quantizer::scales_to_f32(&scales); + + let mut packed = Vec::new(); + quantizer::quantize_and_pack_f32( + &frame, + &scales_f32, + GROUP_LEN, + bits, + &mut packed, + ); + + let mut decoded = Vec::new(); + quantizer::dequantize_f32( + &packed, + &scales_f32, + GROUP_LEN, + bits, + len, + 1, + &mut decoded, + ); + + assert_eq!(decoded.len(), len); + + // For a uniform vector, the quantization step is value.abs() / qmax. + // Max error should be at most half a step (rounding) plus f16 scale error. + let step = if value.abs() > 0.0 { + value.abs() / qmax as f32 + } else { + 0.0 + }; + // Allow step/2 plus a small f16 rounding margin. + let max_err = step * 0.5 + value.abs() * 0.002 + 1e-6; + + for (i, &dec) in decoded.iter().enumerate() { + let err = (dec - value).abs(); + assert!( + err <= max_err, + "trial={_trial}, bits={bits}, i={i}: value={value}, dec={dec}, \ + err={err}, max_err={max_err}, step={step}" + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// 10. Extreme Value Property +// --------------------------------------------------------------------------- + +#[test] +fn prop_extreme_values_dont_panic() { + let bit_widths: &[u8] = &[3, 5, 7, 8]; + + // Frames where scales stay within f16 representable range -- decoded values + // must be finite. + let finite_frames: Vec> = vec![ + // Very small positive values + vec![f32::MIN_POSITIVE; 128], + // Contains infinities and NaN (quantizer maps non-finite to 0) + { + let mut v = vec![1.0f32; 128]; + v[0] = f32::INFINITY; + v[1] = f32::NEG_INFINITY; + v[2] = f32::NAN; + v[3] = -0.0; + v + }, + // All subnormal + vec![1e-40f32; 128], + // Alternating zero and large (within f16 scale range) + (0..128) + .map(|i| if i % 2 == 0 { 0.0 } else { 1e4 }) + .collect(), + ]; + + // Frames with magnitudes that overflow f16 scales -- we only assert + // no panics and correct output length. The decoded values may be NaN/Inf + // because scale overflows to f16 infinity. + let overflow_frames: Vec> = vec![ + // All f32::MAX + vec![f32::MAX; 128], + // All f32::MIN (most negative finite) + vec![f32::MIN; 128], + // Mixed signs of large magnitude + (0..128) + .map(|i| if i % 2 == 0 { f32::MAX } else { f32::MIN }) + .collect(), + // Mix of tiny and huge + (0..128) + .map(|i| { + if i % 3 == 0 { + f32::MIN_POSITIVE + } else if i % 3 == 1 { + 1e30 + } else { + -1e30 + } + }) + .collect(), + ]; + + // Test finite-output frames: no panics, correct length, all decoded finite. + for (frame_idx, frame) in finite_frames.iter().enumerate() { + for &bits in bit_widths { + let scales = quantizer::compute_scales(frame, GROUP_LEN, bits); + let scales_f32 = quantizer::scales_to_f32(&scales); + + let mut packed = Vec::new(); + quantizer::quantize_and_pack_f32( + frame, + &scales_f32, + GROUP_LEN, + bits, + &mut packed, + ); + + let mut decoded = Vec::new(); + quantizer::dequantize_f32( + &packed, + &scales_f32, + GROUP_LEN, + bits, + frame.len(), + 1, + &mut decoded, + ); + + assert_eq!( + decoded.len(), + frame.len(), + "finite frame_idx={frame_idx}, bits={bits}: length mismatch" + ); + + for (i, &d) in decoded.iter().enumerate() { + assert!( + d.is_finite(), + "finite frame_idx={frame_idx}, bits={bits}, i={i}: \ + decoded value is not finite: {d}" + ); + } + } + } + + // Test overflow frames: no panics, correct length (decoded may contain NaN/Inf). + for (frame_idx, frame) in overflow_frames.iter().enumerate() { + for &bits in bit_widths { + let scales = quantizer::compute_scales(frame, GROUP_LEN, bits); + let scales_f32 = quantizer::scales_to_f32(&scales); + + let mut packed = Vec::new(); + quantizer::quantize_and_pack_f32( + frame, + &scales_f32, + GROUP_LEN, + bits, + &mut packed, + ); + + let mut decoded = Vec::new(); + quantizer::dequantize_f32( + &packed, + &scales_f32, + GROUP_LEN, + bits, + frame.len(), + 1, + &mut decoded, + ); + + assert_eq!( + decoded.len(), + frame.len(), + "overflow frame_idx={frame_idx}, bits={bits}: length mismatch" + ); + } + } + + // Bitpack roundtrip with boundary codes -- must not panic and must be exact. + for &bits in bit_widths { + let qmax = bitpack::qmax_from_bits(bits) as u32; + if qmax > 0 { + let max_code = qmax * 2; + let codes: Vec = (0..128).map(|i| i as u32 % (max_code + 1)).collect(); + let mut bp = Vec::new(); + bitpack::pack(&codes, bits as u32, &mut bp); + let mut unpacked = Vec::new(); + bitpack::unpack(&bp, bits as u32, codes.len(), &mut unpacked); + assert_eq!(codes, unpacked); + } + } +} + +// --------------------------------------------------------------------------- +// 11. Segment Compression Ratio is Positive +// --------------------------------------------------------------------------- + +#[test] +fn prop_segment_compression_ratio_positive() { + let mut rng = SimpleRng::new(0x1111_2222_3333_4444); + + for _trial in 0..100 { + let tensor_len = 128; + let bits = [3u8, 5, 7, 8][rng.next_usize_range(0, 4)]; + let frame = random_vec(&mut rng, tensor_len, -1.0, 1.0); + + let scales = quantizer::compute_scales(&frame, GROUP_LEN, bits); + let mut packed = Vec::new(); + quantizer::quantize_and_pack(&frame, &scales, GROUP_LEN, bits, &mut packed); + + let mut seg = Vec::new(); + segment::encode( + bits, + GROUP_LEN as u32, + tensor_len as u32, + 1, + &scales, + &packed, + &mut seg, + ); + + let ratio = segment::compression_ratio(&seg); + assert!( + ratio > 1.0, + "trial={_trial}, bits={bits}: compression ratio {ratio} should be > 1.0" + ); + } +} + +// --------------------------------------------------------------------------- +// 12. Single-Frame Decode Matches Full Decode +// --------------------------------------------------------------------------- + +#[test] +fn prop_single_frame_decode_consistency() { + let mut rng = SimpleRng::new(0x5555_6666_7777_8888); + + for _trial in 0..100 { + let tensor_len = 64; + let frame_count = rng.next_usize_range(1, 6); + let bits = [3u8, 5, 7, 8][rng.next_usize_range(0, 4)]; + + let first_frame = random_vec(&mut rng, tensor_len, -3.0, 3.0); + let scales = quantizer::compute_scales(&first_frame, GROUP_LEN, bits); + let scales_f32 = quantizer::scales_to_f32(&scales); + + let mut packed = Vec::new(); + quantizer::quantize_and_pack_f32( + &first_frame, + &scales_f32, + GROUP_LEN, + bits, + &mut packed, + ); + for _ in 1..frame_count { + let frame = random_vec(&mut rng, tensor_len, -2.5, 2.5); + quantizer::quantize_and_pack_f32( + &frame, + &scales_f32, + GROUP_LEN, + bits, + &mut packed, + ); + } + + let mut seg = Vec::new(); + segment::encode( + bits, + GROUP_LEN as u32, + tensor_len as u32, + frame_count as u32, + &scales, + &packed, + &mut seg, + ); + + // Full decode. + let mut all_decoded = Vec::new(); + segment::decode(&seg, &mut all_decoded); + assert_eq!(all_decoded.len(), tensor_len * frame_count); + + // Single-frame decode should match the corresponding slice. + for f in 0..frame_count { + let single = segment::decode_single_frame(&seg, f); + assert!( + single.is_some(), + "trial={_trial}, frame={f}: single-frame decode returned None" + ); + let single = single.unwrap(); + let expected = &all_decoded[f * tensor_len..(f + 1) * tensor_len]; + assert_eq!( + single.len(), + expected.len(), + "trial={_trial}, frame={f}: length mismatch" + ); + for (i, (&s, &e)) in single.iter().zip(expected.iter()).enumerate() { + assert!( + (s - e).abs() < 1e-6, + "trial={_trial}, frame={f}, i={i}: single={s}, full={e}" + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// 13. Delta Encode/Decode Binary Roundtrip +// --------------------------------------------------------------------------- + +#[test] +fn prop_delta_encode_decode_binary() { + let mut rng = SimpleRng::new(0x9999_0000_1111_2222); + + for trial in 0..500 { + let nnz = rng.next_usize_range(0, 100); + let entries: Vec = (0..nnz) + .map(|_| delta::SparseEntry { + index: (rng.next_u64() % 65536) as u16, + value: (rng.next_u64() % 65536) as i16, + }) + .collect(); + let scale = rng.next_f32_range(1e-6, 100.0); + + let record = delta::DeltaRecord { + header: delta::DeltaHeader { + tensor_id: rng.next_u64() as u128 | ((rng.next_u64() as u128) << 64), + block_index: rng.next_u64() as u32, + base_epoch: rng.next_u64(), + nnz: nnz as u16, + }, + delta_scale: scale, + entries, + }; + + let bytes = delta::encode_delta(&record); + let decoded = delta::decode_delta(&bytes) + .unwrap_or_else(|e| panic!("trial={trial}: decode failed: {e:?}")); + + assert_eq!(decoded.header.tensor_id, record.header.tensor_id); + assert_eq!(decoded.header.block_index, record.header.block_index); + assert_eq!(decoded.header.base_epoch, record.header.base_epoch); + assert_eq!(decoded.header.nnz, record.header.nnz); + assert!( + (decoded.delta_scale - record.delta_scale).abs() < 1e-10, + "trial={trial}: scale mismatch" + ); + assert_eq!(decoded.entries.len(), record.entries.len()); + for (i, (a, b)) in decoded + .entries + .iter() + .zip(record.entries.iter()) + .enumerate() + { + assert_eq!(a.index, b.index, "trial={trial}, entry={i}: index mismatch"); + assert_eq!(a.value, b.value, "trial={trial}, entry={i}: value mismatch"); + } + } +} + +// --------------------------------------------------------------------------- +// 14. Quantization is Deterministic +// --------------------------------------------------------------------------- + +#[test] +fn prop_quantization_deterministic() { + let mut rng = SimpleRng::new(0xABCD_EF01_2345_6789); + + for _trial in 0..200 { + let len = rng.next_usize_range(64, 257); + let frame = random_vec(&mut rng, len, -5.0, 5.0); + let bits = [3u8, 5, 7, 8][rng.next_usize_range(0, 4)]; + + let scales = quantizer::compute_scales(&frame, GROUP_LEN, bits); + let scales_f32 = quantizer::scales_to_f32(&scales); + + let mut packed1 = Vec::new(); + quantizer::quantize_and_pack_f32(&frame, &scales_f32, GROUP_LEN, bits, &mut packed1); + + let mut packed2 = Vec::new(); + quantizer::quantize_and_pack_f32(&frame, &scales_f32, GROUP_LEN, bits, &mut packed2); + + assert_eq!( + packed1, packed2, + "trial={_trial}, bits={bits}: quantization is not deterministic" + ); + } +}