use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::debug;
#[non_exhaustive]
#[derive(Debug)]
pub enum RvfError {
Io(std::io::Error),
Serde(serde_json::Error),
NotFound(String),
}
impl std::fmt::Display for RvfError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RvfError::Io(e) => write!(f, "rvf I/O error: {e}"),
RvfError::Serde(e) => write!(f, "rvf serde error: {e}"),
RvfError::NotFound(id) => write!(f, "rvf entry not found: {id}"),
}
}
}
impl std::error::Error for RvfError {}
impl From<std::io::Error> for RvfError {
fn from(e: std::io::Error) -> Self {
RvfError::Io(e)
}
}
impl From<serde_json::Error> for RvfError {
fn from(e: serde_json::Error) -> Self {
RvfError::Serde(e)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RvfEntry {
pub id: String,
pub embedding: Vec<f32>,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct RvfQueryResult {
pub id: String,
pub score: f32,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoreState {
entries: Vec<RvfEntry>,
}
pub struct RvfStore {
entries: Vec<RvfEntry>,
path: Option<PathBuf>,
}
impl RvfStore {
pub fn create(path: Option<&Path>) -> Self {
debug!(path = ?path, "creating new RvfStore");
Self {
entries: Vec::new(),
path: path.map(|p| p.to_path_buf()),
}
}
pub fn open(path: &Path) -> Result<Self, RvfError> {
if path.exists() {
debug!(path = %path.display(), "opening existing RvfStore");
let data = std::fs::read_to_string(path)?;
let state: StoreState = serde_json::from_str(&data)?;
Ok(Self {
entries: state.entries,
path: Some(path.to_path_buf()),
})
} else {
debug!(path = %path.display(), "no existing store, creating new");
Ok(Self::create(Some(path)))
}
}
pub fn ingest(&mut self, id: String, embedding: Vec<f32>, metadata: serde_json::Value) {
self.entries.retain(|e| e.id != id);
self.entries.push(RvfEntry {
id,
embedding,
metadata,
});
}
pub fn query(&self, query_embedding: &[f32], top_k: usize) -> Vec<RvfQueryResult> {
if self.entries.is_empty() || top_k == 0 {
return Vec::new();
}
let mut scored: Vec<RvfQueryResult> = self
.entries
.iter()
.map(|entry| {
let score = cosine_similarity(query_embedding, &entry.embedding);
RvfQueryResult {
id: entry.id.clone(),
score,
metadata: entry.metadata.clone(),
}
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(top_k);
scored
}
pub fn delete(&mut self, id: &str) -> bool {
let before = self.entries.len();
self.entries.retain(|e| e.id != id);
self.entries.len() < before
}
pub fn compact(&self) -> Result<(), RvfError> {
let Some(ref path) = self.path else {
return Ok(()); };
debug!(path = %path.display(), entries = self.entries.len(), "compacting RvfStore");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let state = StoreState {
entries: self.entries.clone(),
};
let json = serde_json::to_string_pretty(&state)?;
std::fs::write(path, json)?;
Ok(())
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
pub fn get(&self, id: &str) -> Option<&RvfEntry> {
self.entries.iter().find(|e| e.id == id)
}
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
dot / (norm_a * norm_b)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_path(label: &str) -> PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("clawft_rvf_test_{label}_{pid}_{n}.json"))
}
#[test]
fn create_empty_store() {
let store = RvfStore::create(None);
assert!(store.is_empty());
assert_eq!(store.len(), 0);
}
#[test]
fn ingest_and_query() {
let mut store = RvfStore::create(None);
store.ingest(
"doc1".into(),
vec![1.0, 0.0, 0.0],
serde_json::json!({"text": "hello"}),
);
store.ingest(
"doc2".into(),
vec![0.0, 1.0, 0.0],
serde_json::json!({"text": "world"}),
);
let results = store.query(&[1.0, 0.0, 0.0], 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "doc1");
assert!((results[0].score - 1.0).abs() < 0.01);
}
#[test]
fn query_ordering() {
let mut store = RvfStore::create(None);
store.ingest("a".into(), vec![1.0, 0.0], serde_json::json!({}));
store.ingest("b".into(), vec![0.7, 0.7], serde_json::json!({}));
store.ingest("c".into(), vec![0.0, 1.0], serde_json::json!({}));
let results = store.query(&[1.0, 0.0], 3);
assert_eq!(results[0].id, "a");
assert_eq!(results[1].id, "b");
assert_eq!(results[2].id, "c");
}
#[test]
fn delete_entry() {
let mut store = RvfStore::create(None);
store.ingest("doc1".into(), vec![1.0], serde_json::json!({}));
store.ingest("doc2".into(), vec![0.0], serde_json::json!({}));
assert!(store.delete("doc1"));
assert_eq!(store.len(), 1);
assert!(!store.delete("doc1")); }
#[test]
fn upsert_semantics() {
let mut store = RvfStore::create(None);
store.ingest("doc1".into(), vec![1.0, 0.0], serde_json::json!({"v": 1}));
store.ingest("doc1".into(), vec![0.0, 1.0], serde_json::json!({"v": 2}));
assert_eq!(store.len(), 1);
let entry = store.get("doc1").unwrap();
assert_eq!(entry.metadata["v"], 2);
assert_eq!(entry.embedding, vec![0.0, 1.0]);
}
#[test]
fn compact_and_open_roundtrip() {
let path = temp_path("roundtrip");
{
let mut store = RvfStore::create(Some(&path));
store.ingest(
"doc1".into(),
vec![1.0, 0.0, 0.0],
serde_json::json!({"text": "hello"}),
);
store.ingest(
"doc2".into(),
vec![0.0, 1.0, 0.0],
serde_json::json!({"text": "world"}),
);
store.compact().unwrap();
}
let store = RvfStore::open(&path).unwrap();
assert_eq!(store.len(), 2);
let results = store.query(&[1.0, 0.0, 0.0], 1);
assert_eq!(results[0].id, "doc1");
let _ = std::fs::remove_file(&path);
}
#[test]
fn open_nonexistent_creates_empty() {
let path = temp_path("nonexist");
let _ = std::fs::remove_file(&path);
let store = RvfStore::open(&path).unwrap();
assert!(store.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn query_empty_store() {
let store = RvfStore::create(None);
let results = store.query(&[1.0, 0.0], 5);
assert!(results.is_empty());
}
#[test]
fn query_top_k_zero() {
let mut store = RvfStore::create(None);
store.ingest("x".into(), vec![1.0], serde_json::json!({}));
let results = store.query(&[1.0], 0);
assert!(results.is_empty());
}
#[test]
fn get_entry_by_id() {
let mut store = RvfStore::create(None);
store.ingest(
"doc1".into(),
vec![1.0, 2.0],
serde_json::json!({"key": "value"}),
);
let entry = store.get("doc1");
assert!(entry.is_some());
assert_eq!(entry.unwrap().id, "doc1");
assert!(store.get("nonexistent").is_none());
}
#[test]
fn cosine_similarity_identical() {
let score = cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]);
assert!((score - 1.0).abs() < 0.01);
}
#[test]
fn cosine_similarity_orthogonal() {
let score = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
assert!(score.abs() < 0.01);
}
#[test]
fn cosine_similarity_opposite() {
let score = cosine_similarity(&[1.0, 0.0], &[-1.0, 0.0]);
assert!((score + 1.0).abs() < 0.01);
}
#[test]
fn cosine_similarity_different_lengths() {
let score = cosine_similarity(&[1.0, 0.0], &[1.0]);
assert!((score - 0.0).abs() < f32::EPSILON);
}
#[test]
fn rvf_error_display() {
let err = RvfError::NotFound("xyz".into());
assert!(format!("{err}").contains("xyz"));
let err = RvfError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
assert!(format!("{err}").contains("test"));
}
#[test]
fn store_path_accessor() {
let path = temp_path("path_acc");
let store = RvfStore::create(Some(&path));
assert_eq!(store.path(), Some(path.as_path()));
let store2 = RvfStore::create(None);
assert!(store2.path().is_none());
}
#[test]
fn compact_without_path_is_noop() {
let store = RvfStore::create(None);
assert!(store.compact().is_ok());
}
}