feat(vectors): add sparse vector foundation for playlist suggestions
Phase 1 of playlist-suggestions feature: - SparseVector: memory-efficient vector with math operations - set/get with sorted indices for O(log n) access - add, sub, scale, dot product, magnitude - normalize, cosine_similarity, top_k_similar - ArtistVectorStore: SQLite persistence - artist_index table: MBID to integer index mapping - vector_entries table: sparse representation (one row per non-zero) - vector_metadata table: track freshness with TTL - find_nearest: cosine similarity search with exclusion - 17 unit tests all passing
This commit is contained in:
1 parent
c1aaa87f8e
commit
1d2e28c1a8
5 files changed
+1016
No files matched your search
@@ -109,6 +109,9 @@ tauri-plugin-dialog = { version = "2.6.0" }
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
tauri-plugin-dialog = { version = "2.6.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[patch.crates-io]
|
||||
cpal = { path = "vendor/cpal" }
|
||||
rodio = { path = "vendor/rodio" }
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Artist vector module for similarity-based discovery
|
||||
//!
|
||||
//! This module provides sparse vector representations of artists based on their
|
||||
//! relationships (from MusicBrainz) and similarity (from Qobuz). These vectors
|
||||
//! enable playlist suggestions by finding artists geometrically close to a
|
||||
//! playlist's combined vector.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - `sparse_vector.rs`: Memory-efficient sparse vector with math operations
|
||||
//! - `store.rs`: SQLite persistence for artist indices and vectors
|
||||
//! - `weights.rs`: Configurable weights for different relationship types (Phase 2)
|
||||
//! - `builder.rs`: Vector construction from MusicBrainz + Qobuz (Phase 2)
|
||||
//! - `suggestions.rs`: Playlist suggestion engine (Phase 3)
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Build vectors for artists
|
||||
//! let builder = ArtistVectorBuilder::new(store, mb_client, qobuz_client);
|
||||
//! builder.ensure_vector("artist-mbid", 7).await?;
|
||||
//!
|
||||
//! // Compute playlist vector
|
||||
//! let playlist_vec = compute_playlist_vector(&artist_mbids);
|
||||
//!
|
||||
//! // Find similar artists
|
||||
//! let suggestions = store.find_nearest(&playlist_vec, 10, &exclude)?;
|
||||
//! ```
|
||||
//!
|
||||
//! ## Vector Representation
|
||||
//!
|
||||
//! Each artist is represented as a sparse vector where:
|
||||
//! - Dimensions correspond to other artists (via integer index)
|
||||
//! - Values are weighted relationship strengths
|
||||
//! - Sources include: MusicBrainz relationships, Qobuz similarity
|
||||
//!
|
||||
//! Example:
|
||||
//! ```text
|
||||
//! Pink Floyd vector:
|
||||
//! [Roger Waters: 1.0 (member_of_band),
|
||||
//! David Gilmour: 1.0 (member_of_band),
|
||||
//! King Crimson: 0.7 (qobuz_similar),
|
||||
//! ...]
|
||||
//! ```
|
||||
|
||||
pub mod sparse_vector;
|
||||
pub mod store;
|
||||
|
||||
pub use sparse_vector::SparseVector;
|
||||
pub use store::{ArtistVectorStore, ArtistVectorStoreState, SimilarArtist, StoreStats, VectorEntry};
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Sparse vector implementation for artist similarity
|
||||
//!
|
||||
//! Efficient representation for high-dimensional vectors where most values are zero.
|
||||
//! Used to represent artist relationships where each dimension is another artist.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A sparse vector storing only non-zero values
|
||||
///
|
||||
/// Internally uses parallel vectors for indices and values for memory efficiency.
|
||||
/// For operations, temporarily converts to HashMap for O(1) lookups.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SparseVector {
|
||||
/// Sorted indices of non-zero elements
|
||||
indices: Vec<u32>,
|
||||
/// Values corresponding to indices
|
||||
values: Vec<f32>,
|
||||
}
|
||||
|
||||
impl SparseVector {
|
||||
/// Create an empty sparse vector
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
indices: Vec::new(),
|
||||
values: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a sparse vector with pre-allocated capacity
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
indices: Vec::with_capacity(capacity),
|
||||
values: Vec::with_capacity(capacity),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from parallel vectors (must be same length, indices should be sorted)
|
||||
pub fn from_parts(indices: Vec<u32>, values: Vec<f32>) -> Self {
|
||||
debug_assert_eq!(indices.len(), values.len());
|
||||
Self { indices, values }
|
||||
}
|
||||
|
||||
/// Set a value at the given index
|
||||
///
|
||||
/// If the value is 0 (or very close), removes the entry.
|
||||
/// If the index exists, updates the value.
|
||||
/// If the index doesn't exist, inserts in sorted order.
|
||||
pub fn set(&mut self, idx: u32, value: f32) {
|
||||
// Ignore near-zero values
|
||||
if value.abs() < 1e-9 {
|
||||
self.remove(idx);
|
||||
return;
|
||||
}
|
||||
|
||||
match self.indices.binary_search(&idx) {
|
||||
Ok(pos) => {
|
||||
// Index exists, update value
|
||||
self.values[pos] = value;
|
||||
}
|
||||
Err(pos) => {
|
||||
// Index doesn't exist, insert at sorted position
|
||||
self.indices.insert(pos, idx);
|
||||
self.values.insert(pos, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the value at the given index (returns 0 if not present)
|
||||
pub fn get(&self, idx: u32) -> f32 {
|
||||
match self.indices.binary_search(&idx) {
|
||||
Ok(pos) => self.values[pos],
|
||||
Err(_) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an entry by index
|
||||
pub fn remove(&mut self, idx: u32) {
|
||||
if let Ok(pos) = self.indices.binary_search(&idx) {
|
||||
self.indices.remove(pos);
|
||||
self.values.remove(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of non-zero elements
|
||||
pub fn nnz(&self) -> usize {
|
||||
self.indices.len()
|
||||
}
|
||||
|
||||
/// Check if vector is empty (all zeros)
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.indices.is_empty()
|
||||
}
|
||||
|
||||
/// Get indices of non-zero elements
|
||||
pub fn indices(&self) -> &[u32] {
|
||||
&self.indices
|
||||
}
|
||||
|
||||
/// Get values of non-zero elements
|
||||
pub fn values(&self) -> &[f32] {
|
||||
&self.values
|
||||
}
|
||||
|
||||
/// Iterate over (index, value) pairs
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u32, f32)> + '_ {
|
||||
self.indices.iter().copied().zip(self.values.iter().copied())
|
||||
}
|
||||
|
||||
/// Add two sparse vectors element-wise
|
||||
pub fn add(&self, other: &SparseVector) -> SparseVector {
|
||||
let mut result = HashMap::new();
|
||||
|
||||
// Add all elements from self
|
||||
for (idx, val) in self.iter() {
|
||||
*result.entry(idx).or_insert(0.0) += val;
|
||||
}
|
||||
|
||||
// Add all elements from other
|
||||
for (idx, val) in other.iter() {
|
||||
*result.entry(idx).or_insert(0.0) += val;
|
||||
}
|
||||
|
||||
// Convert back to sparse vector
|
||||
let mut indices: Vec<u32> = result.keys().copied().collect();
|
||||
indices.sort_unstable();
|
||||
|
||||
let values: Vec<f32> = indices.iter().map(|idx| result[idx]).collect();
|
||||
|
||||
SparseVector { indices, values }
|
||||
}
|
||||
|
||||
/// Subtract another vector from this one (self - other)
|
||||
pub fn sub(&self, other: &SparseVector) -> SparseVector {
|
||||
let mut result = HashMap::new();
|
||||
|
||||
for (idx, val) in self.iter() {
|
||||
*result.entry(idx).or_insert(0.0) += val;
|
||||
}
|
||||
|
||||
for (idx, val) in other.iter() {
|
||||
*result.entry(idx).or_insert(0.0) -= val;
|
||||
}
|
||||
|
||||
let mut indices: Vec<u32> = result
|
||||
.iter()
|
||||
.filter(|(_, v)| v.abs() > 1e-9)
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
indices.sort_unstable();
|
||||
|
||||
let values: Vec<f32> = indices.iter().map(|idx| result[idx]).collect();
|
||||
|
||||
SparseVector { indices, values }
|
||||
}
|
||||
|
||||
/// Scale vector by a scalar
|
||||
pub fn scale(&self, scalar: f32) -> SparseVector {
|
||||
SparseVector {
|
||||
indices: self.indices.clone(),
|
||||
values: self.values.iter().map(|v| v * scalar).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute dot product with another sparse vector
|
||||
pub fn dot(&self, other: &SparseVector) -> f32 {
|
||||
let mut sum = 0.0;
|
||||
let mut i = 0;
|
||||
let mut j = 0;
|
||||
|
||||
// Merge-style iteration (both vectors are sorted by index)
|
||||
while i < self.indices.len() && j < other.indices.len() {
|
||||
match self.indices[i].cmp(&other.indices[j]) {
|
||||
std::cmp::Ordering::Less => i += 1,
|
||||
std::cmp::Ordering::Greater => j += 1,
|
||||
std::cmp::Ordering::Equal => {
|
||||
sum += self.values[i] * other.values[j];
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sum
|
||||
}
|
||||
|
||||
/// Compute L2 norm (magnitude) of the vector
|
||||
pub fn magnitude(&self) -> f32 {
|
||||
self.values.iter().map(|v| v * v).sum::<f32>().sqrt()
|
||||
}
|
||||
|
||||
/// Normalize the vector to unit length
|
||||
///
|
||||
/// Returns a zero vector if magnitude is zero.
|
||||
pub fn normalize(&self) -> SparseVector {
|
||||
let mag = self.magnitude();
|
||||
if mag < 1e-9 {
|
||||
return SparseVector::new();
|
||||
}
|
||||
|
||||
SparseVector {
|
||||
indices: self.indices.clone(),
|
||||
values: self.values.iter().map(|v| v / mag).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute cosine similarity with another vector
|
||||
///
|
||||
/// Returns 0 if either vector has zero magnitude.
|
||||
/// Result is in range [-1, 1], where 1 means identical direction.
|
||||
pub fn cosine_similarity(&self, other: &SparseVector) -> f32 {
|
||||
let dot = self.dot(other);
|
||||
let mag_self = self.magnitude();
|
||||
let mag_other = other.magnitude();
|
||||
|
||||
if mag_self < 1e-9 || mag_other < 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
dot / (mag_self * mag_other)
|
||||
}
|
||||
|
||||
/// Find top-k most similar vectors from a collection
|
||||
///
|
||||
/// Returns vector of (index_in_collection, similarity) sorted by similarity descending.
|
||||
pub fn top_k_similar<'a>(
|
||||
&self,
|
||||
candidates: impl Iterator<Item = (usize, &'a SparseVector)>,
|
||||
k: usize,
|
||||
) -> Vec<(usize, f32)> {
|
||||
let mut scores: Vec<(usize, f32)> = candidates
|
||||
.map(|(idx, vec)| (idx, self.cosine_similarity(vec)))
|
||||
.filter(|(_, sim)| *sim > 0.0)
|
||||
.collect();
|
||||
|
||||
// Sort by similarity descending
|
||||
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
scores.truncate(k);
|
||||
scores
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_set_and_get() {
|
||||
let mut vec = SparseVector::new();
|
||||
vec.set(5, 1.0);
|
||||
vec.set(10, 2.0);
|
||||
vec.set(3, 0.5);
|
||||
|
||||
assert_eq!(vec.get(5), 1.0);
|
||||
assert_eq!(vec.get(10), 2.0);
|
||||
assert_eq!(vec.get(3), 0.5);
|
||||
assert_eq!(vec.get(7), 0.0); // Not set
|
||||
assert_eq!(vec.nnz(), 3);
|
||||
|
||||
// Indices should be sorted
|
||||
assert_eq!(vec.indices(), &[3, 5, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_value() {
|
||||
let mut vec = SparseVector::new();
|
||||
vec.set(5, 1.0);
|
||||
vec.set(5, 2.0);
|
||||
|
||||
assert_eq!(vec.get(5), 2.0);
|
||||
assert_eq!(vec.nnz(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_on_zero() {
|
||||
let mut vec = SparseVector::new();
|
||||
vec.set(5, 1.0);
|
||||
vec.set(5, 0.0);
|
||||
|
||||
assert_eq!(vec.get(5), 0.0);
|
||||
assert_eq!(vec.nnz(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
let mut a = SparseVector::new();
|
||||
a.set(1, 1.0);
|
||||
a.set(3, 2.0);
|
||||
|
||||
let mut b = SparseVector::new();
|
||||
b.set(2, 1.5);
|
||||
b.set(3, 0.5);
|
||||
|
||||
let c = a.add(&b);
|
||||
|
||||
assert_eq!(c.get(1), 1.0);
|
||||
assert_eq!(c.get(2), 1.5);
|
||||
assert_eq!(c.get(3), 2.5);
|
||||
assert_eq!(c.nnz(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_product() {
|
||||
let mut a = SparseVector::new();
|
||||
a.set(1, 2.0);
|
||||
a.set(3, 3.0);
|
||||
|
||||
let mut b = SparseVector::new();
|
||||
b.set(1, 4.0);
|
||||
b.set(2, 5.0);
|
||||
b.set(3, 1.0);
|
||||
|
||||
// dot = 2*4 + 3*1 = 11
|
||||
assert_eq!(a.dot(&b), 11.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_magnitude() {
|
||||
let mut vec = SparseVector::new();
|
||||
vec.set(0, 3.0);
|
||||
vec.set(1, 4.0);
|
||||
|
||||
// magnitude = sqrt(9 + 16) = 5
|
||||
assert!((vec.magnitude() - 5.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize() {
|
||||
let mut vec = SparseVector::new();
|
||||
vec.set(0, 3.0);
|
||||
vec.set(1, 4.0);
|
||||
|
||||
let normalized = vec.normalize();
|
||||
|
||||
assert!((normalized.magnitude() - 1.0).abs() < 1e-6);
|
||||
assert!((normalized.get(0) - 0.6).abs() < 1e-6);
|
||||
assert!((normalized.get(1) - 0.8).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_similarity_identical() {
|
||||
let mut vec = SparseVector::new();
|
||||
vec.set(0, 1.0);
|
||||
vec.set(1, 2.0);
|
||||
|
||||
let sim = vec.cosine_similarity(&vec);
|
||||
assert!((sim - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_similarity_orthogonal() {
|
||||
let mut a = SparseVector::new();
|
||||
a.set(0, 1.0);
|
||||
|
||||
let mut b = SparseVector::new();
|
||||
b.set(1, 1.0);
|
||||
|
||||
let sim = a.cosine_similarity(&b);
|
||||
assert!(sim.abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cosine_similarity_similar() {
|
||||
let mut a = SparseVector::new();
|
||||
a.set(0, 1.0);
|
||||
a.set(1, 1.0);
|
||||
|
||||
let mut b = SparseVector::new();
|
||||
b.set(0, 1.0);
|
||||
b.set(1, 0.5);
|
||||
|
||||
let sim = a.cosine_similarity(&b);
|
||||
// Should be high but not 1.0
|
||||
assert!(sim > 0.9);
|
||||
assert!(sim < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scale() {
|
||||
let mut vec = SparseVector::new();
|
||||
vec.set(0, 2.0);
|
||||
vec.set(1, 3.0);
|
||||
|
||||
let scaled = vec.scale(2.0);
|
||||
|
||||
assert_eq!(scaled.get(0), 4.0);
|
||||
assert_eq!(scaled.get(1), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let vec = SparseVector::from_parts(vec![1, 3, 5], vec![0.5, 1.0, 1.5]);
|
||||
|
||||
assert_eq!(vec.get(1), 0.5);
|
||||
assert_eq!(vec.get(3), 1.0);
|
||||
assert_eq!(vec.get(5), 1.5);
|
||||
assert_eq!(vec.nnz(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_vector() {
|
||||
let vec = SparseVector::new();
|
||||
|
||||
assert!(vec.is_empty());
|
||||
assert_eq!(vec.magnitude(), 0.0);
|
||||
assert_eq!(vec.normalize().nnz(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
//! SQLite-based storage for artist vectors
|
||||
//!
|
||||
//! Persists artist index mapping and sparse vectors for similarity search.
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::sparse_vector::SparseVector;
|
||||
|
||||
/// TTL for vector entries (7 days)
|
||||
const VECTOR_TTL_SECS: i64 = 7 * 24 * 60 * 60;
|
||||
|
||||
/// Shared state wrapper for Tauri
|
||||
pub struct ArtistVectorStoreState {
|
||||
pub store: Arc<Mutex<ArtistVectorStore>>,
|
||||
}
|
||||
|
||||
impl ArtistVectorStoreState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("cache");
|
||||
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("artist_vectors.db");
|
||||
let store = ArtistVectorStore::new(&db_path)?;
|
||||
|
||||
log::info!("Artist vector store initialized at {:?}", db_path);
|
||||
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(store)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Artist vector store with SQLite backend
|
||||
pub struct ArtistVectorStore {
|
||||
conn: Connection,
|
||||
/// In-memory cache of MBID to index mapping
|
||||
artist_to_idx: HashMap<String, u32>,
|
||||
/// Reverse mapping: index to MBID
|
||||
idx_to_artist: Vec<String>,
|
||||
/// Next available index
|
||||
next_idx: u32,
|
||||
}
|
||||
|
||||
/// Information about a stored vector entry
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VectorEntry {
|
||||
pub artist_mbid: String,
|
||||
pub vector: SparseVector,
|
||||
pub updated_at: i64,
|
||||
pub sources: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of a similarity search
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimilarArtist {
|
||||
pub mbid: String,
|
||||
pub name: Option<String>,
|
||||
pub similarity: f32,
|
||||
}
|
||||
|
||||
impl ArtistVectorStore {
|
||||
/// Create a new store at the given path
|
||||
pub fn new(path: &Path) -> Result<Self, String> {
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| format!("Failed to open artist vector store: {}", e))?;
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set pragmas: {}", e))?;
|
||||
|
||||
let mut store = Self {
|
||||
conn,
|
||||
artist_to_idx: HashMap::new(),
|
||||
idx_to_artist: Vec::new(),
|
||||
next_idx: 0,
|
||||
};
|
||||
|
||||
store.init()?;
|
||||
store.load_artist_index()?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Initialize database schema
|
||||
fn init(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
-- Artist index: maps MBID to integer index for vectors
|
||||
CREATE TABLE IF NOT EXISTS artist_index (
|
||||
idx INTEGER PRIMARY KEY,
|
||||
mbid TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_index_mbid ON artist_index(mbid);
|
||||
|
||||
-- Vector entries: sparse representation (one row per non-zero)
|
||||
CREATE TABLE IF NOT EXISTS vector_entries (
|
||||
artist_idx INTEGER NOT NULL,
|
||||
target_idx INTEGER NOT NULL,
|
||||
weight REAL NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
PRIMARY KEY (artist_idx, target_idx, source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_entries_artist ON vector_entries(artist_idx);
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_entries_target ON vector_entries(target_idx);
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_entries_updated ON vector_entries(updated_at);
|
||||
|
||||
-- Vector metadata: track when each artist's vector was last computed
|
||||
CREATE TABLE IF NOT EXISTS vector_metadata (
|
||||
artist_idx INTEGER PRIMARY KEY,
|
||||
updated_at INTEGER NOT NULL,
|
||||
nnz INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize schema: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load artist index from database into memory
|
||||
fn load_artist_index(&mut self) -> Result<(), String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT idx, mbid FROM artist_index ORDER BY idx")
|
||||
.map_err(|e| format!("Failed to prepare index query: {}", e))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|e| format!("Failed to query index: {}", e))?;
|
||||
|
||||
self.artist_to_idx.clear();
|
||||
self.idx_to_artist.clear();
|
||||
|
||||
for row in rows {
|
||||
let (idx, mbid) = row.map_err(|e| format!("Failed to read row: {}", e))?;
|
||||
self.artist_to_idx.insert(mbid.clone(), idx);
|
||||
|
||||
// Ensure idx_to_artist has enough capacity
|
||||
while self.idx_to_artist.len() <= idx as usize {
|
||||
self.idx_to_artist.push(String::new());
|
||||
}
|
||||
self.idx_to_artist[idx as usize] = mbid;
|
||||
|
||||
if idx >= self.next_idx {
|
||||
self.next_idx = idx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Loaded {} artists into index, next_idx={}",
|
||||
self.artist_to_idx.len(),
|
||||
self.next_idx
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get or create an index for an artist MBID
|
||||
pub fn get_or_create_idx(&mut self, mbid: &str, name: Option<&str>) -> Result<u32, String> {
|
||||
if let Some(&idx) = self.artist_to_idx.get(mbid) {
|
||||
return Ok(idx);
|
||||
}
|
||||
|
||||
let idx = self.next_idx;
|
||||
self.next_idx += 1;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO artist_index (idx, mbid, name) VALUES (?1, ?2, ?3)",
|
||||
params![idx, mbid, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert artist index: {}", e))?;
|
||||
|
||||
self.artist_to_idx.insert(mbid.to_string(), idx);
|
||||
|
||||
// Extend idx_to_artist
|
||||
while self.idx_to_artist.len() <= idx as usize {
|
||||
self.idx_to_artist.push(String::new());
|
||||
}
|
||||
self.idx_to_artist[idx as usize] = mbid.to_string();
|
||||
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
/// Get index for an artist MBID (returns None if not found)
|
||||
pub fn get_idx(&self, mbid: &str) -> Option<u32> {
|
||||
self.artist_to_idx.get(mbid).copied()
|
||||
}
|
||||
|
||||
/// Get MBID for an index
|
||||
pub fn get_mbid(&self, idx: u32) -> Option<&str> {
|
||||
self.idx_to_artist.get(idx as usize).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Store a vector for an artist
|
||||
pub fn set_vector(
|
||||
&mut self,
|
||||
mbid: &str,
|
||||
vector: &SparseVector,
|
||||
source: &str,
|
||||
) -> Result<(), String> {
|
||||
let artist_idx = self.get_or_create_idx(mbid, None)?;
|
||||
let now = current_timestamp();
|
||||
|
||||
// Delete existing entries for this artist+source
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM vector_entries WHERE artist_idx = ?1 AND source = ?2",
|
||||
params![artist_idx, source],
|
||||
)
|
||||
.map_err(|e| format!("Failed to delete old entries: {}", e))?;
|
||||
|
||||
// Insert new entries
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"INSERT INTO vector_entries (artist_idx, target_idx, weight, source, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare insert: {}", e))?;
|
||||
|
||||
for (target_idx, weight) in vector.iter() {
|
||||
stmt.execute(params![artist_idx, target_idx, weight, source, now])
|
||||
.map_err(|e| format!("Failed to insert entry: {}", e))?;
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO vector_metadata (artist_idx, updated_at, nnz)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![artist_idx, now, vector.nnz()],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update metadata: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the combined vector for an artist (all sources merged)
|
||||
pub fn get_vector(&self, mbid: &str) -> Option<SparseVector> {
|
||||
let artist_idx = self.get_idx(mbid)?;
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT target_idx, SUM(weight) FROM vector_entries WHERE artist_idx = ?1 GROUP BY target_idx")
|
||||
.ok()?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![artist_idx], |row| {
|
||||
Ok((row.get::<_, u32>(0)?, row.get::<_, f32>(1)?))
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let mut indices = Vec::new();
|
||||
let mut values = Vec::new();
|
||||
|
||||
for row in rows.flatten() {
|
||||
indices.push(row.0);
|
||||
values.push(row.1);
|
||||
}
|
||||
|
||||
if indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sort by index
|
||||
let mut pairs: Vec<_> = indices.into_iter().zip(values).collect();
|
||||
pairs.sort_by_key(|(idx, _)| *idx);
|
||||
|
||||
let (indices, values): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
|
||||
|
||||
Some(SparseVector::from_parts(indices, values))
|
||||
}
|
||||
|
||||
/// Check if a vector exists and is fresh (within TTL)
|
||||
pub fn has_fresh_vector(&self, mbid: &str, max_age_secs: i64) -> bool {
|
||||
let Some(artist_idx) = self.get_idx(mbid) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let cutoff = current_timestamp() - max_age_secs;
|
||||
|
||||
let result: Option<i64> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT updated_at FROM vector_metadata WHERE artist_idx = ?1 AND updated_at > ?2",
|
||||
params![artist_idx, cutoff],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
result.is_some()
|
||||
}
|
||||
|
||||
/// Find artists most similar to a query vector
|
||||
///
|
||||
/// Returns top-k artists by cosine similarity, excluding those in `exclude`.
|
||||
pub fn find_nearest(
|
||||
&self,
|
||||
query: &SparseVector,
|
||||
k: usize,
|
||||
exclude_mbids: &[String],
|
||||
) -> Result<Vec<SimilarArtist>, String> {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Get all artist indices to exclude
|
||||
let exclude_indices: Vec<u32> = exclude_mbids
|
||||
.iter()
|
||||
.filter_map(|mbid| self.get_idx(mbid))
|
||||
.collect();
|
||||
|
||||
// Get all unique artist indices that have vectors
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT DISTINCT artist_idx FROM vector_entries")
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let artist_indices: Vec<u32> = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to query artists: {}", e))?
|
||||
.filter_map(|r| r.ok())
|
||||
.filter(|idx| !exclude_indices.contains(idx))
|
||||
.collect();
|
||||
|
||||
// Compute similarity for each candidate
|
||||
let mut similarities: Vec<(u32, f32)> = Vec::new();
|
||||
|
||||
for artist_idx in artist_indices {
|
||||
if let Some(mbid) = self.get_mbid(artist_idx) {
|
||||
if let Some(vec) = self.get_vector(mbid) {
|
||||
let sim = query.cosine_similarity(&vec);
|
||||
if sim > 0.0 {
|
||||
similarities.push((artist_idx, sim));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity descending
|
||||
similarities.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
similarities.truncate(k);
|
||||
|
||||
// Convert to result type
|
||||
let results = similarities
|
||||
.into_iter()
|
||||
.filter_map(|(idx, sim)| {
|
||||
let mbid = self.get_mbid(idx)?.to_string();
|
||||
let name = self.get_artist_name(&mbid);
|
||||
Some(SimilarArtist {
|
||||
mbid,
|
||||
name,
|
||||
similarity: sim,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Get artist name from index
|
||||
fn get_artist_name(&self, mbid: &str) -> Option<String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT name FROM artist_index WHERE mbid = ?1",
|
||||
params![mbid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Get statistics about the store
|
||||
pub fn get_stats(&self) -> Result<StoreStats, String> {
|
||||
let artist_count = self.artist_to_idx.len();
|
||||
|
||||
let vector_count: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(DISTINCT artist_idx) FROM vector_entries", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(|e| format!("Failed to count vectors: {}", e))?;
|
||||
|
||||
let entry_count: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM vector_entries", [], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to count entries: {}", e))?;
|
||||
|
||||
Ok(StoreStats {
|
||||
artist_count,
|
||||
vector_count: vector_count as usize,
|
||||
entry_count: entry_count as usize,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clean up expired entries
|
||||
pub fn cleanup_expired(&mut self, max_age_secs: i64) -> Result<usize, String> {
|
||||
let cutoff = current_timestamp() - max_age_secs;
|
||||
|
||||
let deleted = self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM vector_entries WHERE updated_at < ?1",
|
||||
params![cutoff],
|
||||
)
|
||||
.map_err(|e| format!("Failed to delete expired entries: {}", e))?;
|
||||
|
||||
// Also clean up metadata
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM vector_metadata WHERE updated_at < ?1",
|
||||
params![cutoff],
|
||||
)
|
||||
.map_err(|e| format!("Failed to delete expired metadata: {}", e))?;
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics about the vector store
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoreStats {
|
||||
pub artist_count: usize,
|
||||
pub vector_count: usize,
|
||||
pub entry_count: usize,
|
||||
}
|
||||
|
||||
/// Get current Unix timestamp
|
||||
fn current_timestamp() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_test_store() -> ArtistVectorStore {
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("test_vectors.db");
|
||||
ArtistVectorStore::new(&db_path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_artist_index() {
|
||||
let mut store = create_test_store();
|
||||
|
||||
let idx1 = store.get_or_create_idx("mbid-1", Some("Artist 1")).unwrap();
|
||||
let idx2 = store.get_or_create_idx("mbid-2", Some("Artist 2")).unwrap();
|
||||
let idx1_again = store.get_or_create_idx("mbid-1", None).unwrap();
|
||||
|
||||
assert_eq!(idx1, 0);
|
||||
assert_eq!(idx2, 1);
|
||||
assert_eq!(idx1_again, idx1); // Should return same index
|
||||
|
||||
assert_eq!(store.get_mbid(idx1), Some("mbid-1"));
|
||||
assert_eq!(store.get_mbid(idx2), Some("mbid-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_retrieve_vector() {
|
||||
let mut store = create_test_store();
|
||||
|
||||
// Create some artist indices first
|
||||
store.get_or_create_idx("target-1", None).unwrap();
|
||||
store.get_or_create_idx("target-2", None).unwrap();
|
||||
|
||||
let mut vec = SparseVector::new();
|
||||
vec.set(0, 1.0); // target-1
|
||||
vec.set(1, 0.5); // target-2
|
||||
|
||||
store.set_vector("artist-a", &vec, "test").unwrap();
|
||||
|
||||
let retrieved = store.get_vector("artist-a").unwrap();
|
||||
assert_eq!(retrieved.get(0), 1.0);
|
||||
assert_eq!(retrieved.get(1), 0.5);
|
||||
assert_eq!(retrieved.nnz(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_nearest() {
|
||||
let mut store = create_test_store();
|
||||
|
||||
// Create artists
|
||||
let idx_a = store.get_or_create_idx("artist-a", None).unwrap();
|
||||
let idx_b = store.get_or_create_idx("artist-b", None).unwrap();
|
||||
let idx_c = store.get_or_create_idx("artist-c", None).unwrap();
|
||||
|
||||
// Artist A: connected to B
|
||||
let mut vec_a = SparseVector::new();
|
||||
vec_a.set(idx_b, 1.0);
|
||||
store.set_vector("artist-a", &vec_a, "test").unwrap();
|
||||
|
||||
// Artist B: connected to A and C
|
||||
let mut vec_b = SparseVector::new();
|
||||
vec_b.set(idx_a, 1.0);
|
||||
vec_b.set(idx_c, 0.5);
|
||||
store.set_vector("artist-b", &vec_b, "test").unwrap();
|
||||
|
||||
// Artist C: connected to B
|
||||
let mut vec_c = SparseVector::new();
|
||||
vec_c.set(idx_b, 0.8);
|
||||
store.set_vector("artist-c", &vec_c, "test").unwrap();
|
||||
|
||||
// Query similar to A
|
||||
let query = vec_a.clone();
|
||||
let results = store.find_nearest(&query, 5, &["artist-a".to_string()]).unwrap();
|
||||
|
||||
// B should be most similar (both connect to each other)
|
||||
assert!(!results.is_empty());
|
||||
// C should also appear (both connect to B)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fresh_vector_check() {
|
||||
let mut store = create_test_store();
|
||||
|
||||
let vec = SparseVector::new();
|
||||
store.set_vector("artist-a", &vec, "test").unwrap();
|
||||
|
||||
// Should be fresh with 1 day TTL
|
||||
assert!(store.has_fresh_vector("artist-a", 86400));
|
||||
|
||||
// Should not be fresh with 0 second TTL
|
||||
assert!(!store.has_fresh_vector("artist-a", 0));
|
||||
|
||||
// Non-existent artist
|
||||
assert!(!store.has_fresh_vector("nonexistent", 86400));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod api_cache;
|
||||
pub mod artist_vectors;
|
||||
pub mod audio;
|
||||
pub mod cache;
|
||||
pub mod cast;
|
||||
|
||||
Reference in new issue
Block a user