mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
249 lines
7.4 KiB
Rust
249 lines
7.4 KiB
Rust
//! Progressive layer model (Layer A / B / C) for RVF indexing.
|
|
//!
|
|
//! Each layer is independently useful and stores a different granularity
|
|
//! of the HNSW graph, enabling progressive availability.
|
|
|
|
extern crate alloc;
|
|
|
|
use alloc::collections::BTreeMap;
|
|
use alloc::vec::Vec;
|
|
|
|
use crate::hnsw::HnswLayer;
|
|
|
|
/// Which index layer a piece of data belongs to.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
#[repr(u8)]
|
|
pub enum IndexLayer {
|
|
/// Entry points + coarse routing. Always present, loaded first (< 5ms).
|
|
A = 0,
|
|
/// Partial adjacency for the hot region. Loaded second (100ms-1s).
|
|
B = 1,
|
|
/// Full adjacency for every node. Loaded last (seconds to minutes).
|
|
C = 2,
|
|
}
|
|
|
|
impl TryFrom<u8> for IndexLayer {
|
|
type Error = u8;
|
|
|
|
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
|
match value {
|
|
0 => Ok(Self::A),
|
|
1 => Ok(Self::B),
|
|
2 => Ok(Self::C),
|
|
other => Err(other),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Entry in the centroid-to-partition map.
|
|
#[derive(Clone, Debug)]
|
|
pub struct PartitionEntry {
|
|
/// Which centroid owns this partition.
|
|
pub centroid_id: u32,
|
|
/// First vector ID in this partition.
|
|
pub vector_id_start: u64,
|
|
/// Last vector ID in this partition (exclusive).
|
|
pub vector_id_end: u64,
|
|
/// Segment ID containing the vector data.
|
|
pub segment_ref: u64,
|
|
/// Block offset within the segment.
|
|
pub block_ref: u32,
|
|
}
|
|
|
|
/// Layer A: Entry Points + Coarse Routing.
|
|
///
|
|
/// Contains:
|
|
/// - HNSW entry points (node ID + layer)
|
|
/// - Top-layer adjacency lists (layers >= threshold)
|
|
/// - Cluster centroids for IVF-style partition routing
|
|
/// - Centroid-to-partition map
|
|
#[derive(Clone, Debug)]
|
|
pub struct LayerA {
|
|
/// Entry points: `(node_id, max_layer)`.
|
|
pub entry_points: Vec<(u64, u32)>,
|
|
/// Top-layer adjacency: HNSW layers at the highest levels.
|
|
/// Index 0 = the highest layer, etc.
|
|
pub top_layers: Vec<HnswLayer>,
|
|
/// The HNSW layer index where top_layers[0] starts.
|
|
pub top_layer_start: usize,
|
|
/// Cluster centroids for partition routing.
|
|
pub centroids: Vec<Vec<f32>>,
|
|
/// Map from centroid to vector ID ranges.
|
|
pub partition_map: Vec<PartitionEntry>,
|
|
}
|
|
|
|
/// Layer B: Partial Adjacency for the hot working set.
|
|
///
|
|
/// Contains neighbor lists for the most-accessed nodes (determined by
|
|
/// temperature sketch). Typically covers 10-20% of total nodes.
|
|
#[derive(Clone, Debug)]
|
|
pub struct LayerB {
|
|
/// Partial adjacency: node_id -> neighbor list.
|
|
/// Only nodes in the hot region are present.
|
|
pub partial_adjacency: BTreeMap<u64, Vec<u64>>,
|
|
/// Ranges of node IDs covered by this layer.
|
|
pub covered_ranges: Vec<(u64, u64)>,
|
|
}
|
|
|
|
impl LayerB {
|
|
/// Returns true if the given node has adjacency data in this layer.
|
|
#[inline]
|
|
pub fn has_node(&self, id: u64) -> bool {
|
|
self.partial_adjacency.contains_key(&id)
|
|
}
|
|
|
|
/// Returns neighbors for a node, or `None` if not in the hot region.
|
|
#[inline]
|
|
pub fn neighbors(&self, id: u64) -> Option<&[u64]> {
|
|
self.partial_adjacency.get(&id).map(|v| v.as_slice())
|
|
}
|
|
}
|
|
|
|
/// Layer C: Full Adjacency.
|
|
///
|
|
/// Complete neighbor lists for every node at every HNSW level.
|
|
/// This is the traditional full HNSW graph.
|
|
#[derive(Clone, Debug)]
|
|
pub struct LayerC {
|
|
/// Full adjacency at every HNSW layer. Index 0 = layer 0 (bottom).
|
|
pub full_adjacency: Vec<HnswLayer>,
|
|
}
|
|
|
|
/// Aggregated state of all loaded index layers.
|
|
#[derive(Clone, Debug)]
|
|
pub struct IndexState {
|
|
pub layer_a: Option<LayerA>,
|
|
pub layer_b: Option<LayerB>,
|
|
pub layer_c: Option<LayerC>,
|
|
/// Total number of nodes in the full graph (known from metadata).
|
|
pub total_nodes: u64,
|
|
}
|
|
|
|
/// Estimate recall@10 based on which layers are currently loaded.
|
|
///
|
|
/// These are approximate lower-bound estimates based on the spec:
|
|
/// - A only: 0.65-0.75
|
|
/// - A + B: 0.85-0.92
|
|
/// - A + B + C: 0.95-0.99
|
|
pub fn available_recall(state: &IndexState) -> f32 {
|
|
match (&state.layer_a, &state.layer_b, &state.layer_c) {
|
|
(None, _, _) => 0.0,
|
|
(Some(_), None, None) => 0.70,
|
|
(Some(_), Some(b), None) => {
|
|
// Recall scales with coverage of partial adjacency.
|
|
let covered_nodes: u64 = b
|
|
.covered_ranges
|
|
.iter()
|
|
.map(|(start, end)| end.saturating_sub(*start))
|
|
.sum();
|
|
let coverage = if state.total_nodes > 0 {
|
|
covered_nodes as f32 / state.total_nodes as f32
|
|
} else {
|
|
0.0
|
|
};
|
|
// Scale between 0.70 (no B coverage) and 0.92 (full B coverage).
|
|
0.70 + coverage * 0.22
|
|
}
|
|
(Some(_), _, Some(_)) => 0.97,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn index_layer_round_trip() {
|
|
assert_eq!(IndexLayer::try_from(0), Ok(IndexLayer::A));
|
|
assert_eq!(IndexLayer::try_from(1), Ok(IndexLayer::B));
|
|
assert_eq!(IndexLayer::try_from(2), Ok(IndexLayer::C));
|
|
assert_eq!(IndexLayer::try_from(3), Err(3));
|
|
}
|
|
|
|
#[test]
|
|
fn recall_no_layers() {
|
|
let state = IndexState {
|
|
layer_a: None,
|
|
layer_b: None,
|
|
layer_c: None,
|
|
total_nodes: 1000,
|
|
};
|
|
assert!((available_recall(&state) - 0.0).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn recall_a_only() {
|
|
let state = IndexState {
|
|
layer_a: Some(LayerA {
|
|
entry_points: vec![(0, 5)],
|
|
top_layers: vec![],
|
|
top_layer_start: 5,
|
|
centroids: vec![],
|
|
partition_map: vec![],
|
|
}),
|
|
layer_b: None,
|
|
layer_c: None,
|
|
total_nodes: 1000,
|
|
};
|
|
assert!((available_recall(&state) - 0.70).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn recall_a_plus_b() {
|
|
let state = IndexState {
|
|
layer_a: Some(LayerA {
|
|
entry_points: vec![(0, 5)],
|
|
top_layers: vec![],
|
|
top_layer_start: 5,
|
|
centroids: vec![],
|
|
partition_map: vec![],
|
|
}),
|
|
layer_b: Some(LayerB {
|
|
partial_adjacency: BTreeMap::new(),
|
|
covered_ranges: vec![(0, 500)],
|
|
}),
|
|
layer_c: None,
|
|
total_nodes: 1000,
|
|
};
|
|
let recall = available_recall(&state);
|
|
assert!(recall > 0.70);
|
|
assert!(recall < 0.93);
|
|
}
|
|
|
|
#[test]
|
|
fn recall_full() {
|
|
let state = IndexState {
|
|
layer_a: Some(LayerA {
|
|
entry_points: vec![(0, 5)],
|
|
top_layers: vec![],
|
|
top_layer_start: 5,
|
|
centroids: vec![],
|
|
partition_map: vec![],
|
|
}),
|
|
layer_b: Some(LayerB {
|
|
partial_adjacency: BTreeMap::new(),
|
|
covered_ranges: vec![(0, 1000)],
|
|
}),
|
|
layer_c: Some(LayerC {
|
|
full_adjacency: vec![],
|
|
}),
|
|
total_nodes: 1000,
|
|
};
|
|
assert!(available_recall(&state) >= 0.95);
|
|
}
|
|
|
|
#[test]
|
|
fn layer_b_has_node() {
|
|
let mut adj = BTreeMap::new();
|
|
adj.insert(42, vec![1, 2, 3]);
|
|
let b = LayerB {
|
|
partial_adjacency: adj,
|
|
covered_ranges: vec![(0, 100)],
|
|
};
|
|
assert!(b.has_node(42));
|
|
assert!(!b.has_node(99));
|
|
assert_eq!(b.neighbors(42), Some([1u64, 2, 3].as_slice()));
|
|
assert_eq!(b.neighbors(99), None);
|
|
}
|
|
}
|