mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-05-29 19:33:34 +00:00
perf: Optimize quantizer + store; add comprehensive tests
- Eliminate round() call in quantize hot path (1.8x speedup) - Add 3-bit dequant fast path (8-values-from-3-bytes, 2.4x speedup) - Wire WitnessLog into TieredStore (put/get/evict audit trail) - Add TieredStore.metrics() for aggregate store statistics - Add TieredStore.witness_log() accessors - Update store.get() to accept `now` tick for access tracking - 14 property-based tests (roundtrip, bitpack, segment, delta, f16, score monotonicity, extreme values, compression ratio, determinism) - 11 end-to-end integration tests (lifecycle, delta chain, quality sweep, persistence, eviction, checksum, multi-tensor, stress, compressor-to-store, factor reconstruction, witness logging) Benchmarks (4096-element tensors, release mode): 8-bit quantize: 10,745 ns (1.52 GB/s) 8-bit dequant: 992 ns (16.52 GB/s) 3-bit dequant: 2,998 ns (5.46 GB/s) Zipf P95 read: 41 ns Tier flip rate: 0.074/block/min (threshold: 0.1) All 204 tests pass. https://claude.ai/code/session_01Ksy165BL5nGpVoWaAfTE7t
This commit is contained in:
parent
7f01c3e2e4
commit
01a6f91a89
4 changed files with 2109 additions and 15 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<BlockKey>,
|
||||
tier2_keys: Vec<BlockKey>,
|
||||
tier3_keys: Vec<BlockKey>,
|
||||
|
||||
/// 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<usize, StoreError> {
|
||||
pub fn get(&mut self, key: BlockKey, out: &mut [f32], now: u64) -> Result<usize, StoreError> {
|
||||
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<BlockKey> = 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<f32> {
|
||||
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<f32> = (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<f32> = (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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
605
crates/ruvector-temporal-tensor/tests/integration.rs
Normal file
605
crates/ruvector-temporal-tensor/tests/integration.rs
Normal file
|
|
@ -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<Vec<f32>> = (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<tiering::BlockMeta> =
|
||||
(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<f32> = (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<f32> = (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<f32>> = Vec::new();
|
||||
let mut block_tiers: Vec<Tier> = Vec::new();
|
||||
|
||||
for i in 0..50u32 {
|
||||
let d: Vec<f32> = (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<f32> = (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<f32> = (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<Vec<f32>>> = Vec::new();
|
||||
|
||||
for &tid in &tensor_ids {
|
||||
let mut tensor_blocks = Vec::new();
|
||||
for blk in 0..5u32 {
|
||||
let d: Vec<f32> = (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<f32> = (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<Vec<f32>> = (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<f32> = (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);
|
||||
}
|
||||
893
crates/ruvector-temporal-tensor/tests/property_tests.rs
Normal file
893
crates/ruvector-temporal-tensor/tests/property_tests.rs
Normal file
|
|
@ -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<f32> {
|
||||
(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<f32> {
|
||||
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<u32> = (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<f32>> = 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<f32>> = 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<u32> = (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<delta::SparseEntry> = (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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue