//! Tests for [`super`]. use super::*; use crate::analysis::{self, AnalysisResult}; use crate::test_helpers::insert_fake_sample; /// `(anchor, neighbours)` from a list of `(hash, distance)` pairs. fn hood(anchor: &str, rows: &[(&str, f64)]) -> (String, Vec) { ( anchor.to_owned(), rows.iter() .map(|(hash, distance)| SimilarResult { hash: (*hash).to_owned(), distance: *distance, }) .collect(), ) } fn ranked(hits: &[BasketHit]) -> Vec<&str> { hits.iter().map(|hit| hit.hash.as_str()).collect() } /// The contract the basket rests on: one anchor merged is the one-anchor /// query, same rows in the same order. If this ever fails, a basket of one /// has become a second kind of search rather than a special case of this one. #[test] fn a_basket_of_one_is_the_single_anchor_query() { let rows = [("b", 0.1), ("c", 0.4), ("d", 0.2)]; let hits = merge_neighbourhoods(&[hood("a", &rows)], 10); let mut expected: Vec<(&str, f64)> = rows.to_vec(); expected.sort_by(|a, b| a.1.total_cmp(&b.1)); assert_eq!( ranked(&hits), expected.iter().map(|(h, _)| *h).collect::>() ); for hit in &hits { assert_eq!(hit.matched, vec!["a".to_owned()]); } } /// "Near all of these" is the worst distance, not the sum and not the best. /// `near_both` is further from the first anchor than `near_one` is, and still /// wins, because `near_one` is not near the second anchor at all. #[test] fn the_ranking_minimises_the_worst_distance_to_any_anchor() { let hits = merge_neighbourhoods( &[ hood( "a", &[("near_one", 0.05), ("near_both", 0.30), ("far", 0.90)], ), hood("b", &[("near_both", 0.20), ("far", 0.80)]), ], 10, ); assert_eq!(ranked(&hits), vec!["near_both", "near_one", "far"]); let near_both = &hits[0]; assert!( (near_both.score - 0.30).abs() < f64::EPSILON, "{near_both:?}" ); assert_eq!(near_both.matched, vec!["a".to_owned(), "b".to_owned()]); } /// A sample missing from an anchor's neighbourhood is not at a known /// distance from it. The last row that anchor did return stands in, so the /// score is a lower bound: `near_one` scores 0.80 -- b's worst -- rather than /// its own 0.05. #[test] fn a_sample_missing_from_a_neighbourhood_is_scored_from_that_anchors_last_row() { let hits = merge_neighbourhoods( &[ hood("a", &[("near_one", 0.05)]), hood("b", &[("other", 0.80)]), ], 10, ); let near_one = hits.iter().find(|hit| hit.hash == "near_one").unwrap(); assert!((near_one.score - 0.80).abs() < f64::EPSILON, "{near_one:?}"); assert_eq!(near_one.matched, vec!["a".to_owned()]); } /// The accounting is the decided part, and it reads out in basket order /// however the anchors happened to answer. #[test] fn each_hit_names_the_anchors_it_answered_to_in_basket_order() { let hits = merge_neighbourhoods( &[ hood("a", &[("x", 0.1)]), hood("b", &[]), hood("c", &[("x", 0.2)]), ], 10, ); let x = hits.iter().find(|hit| hit.hash == "x").unwrap(); assert_eq!(x.matched, vec!["a".to_owned(), "c".to_owned()]); } /// Every anchor, not merely the one being looked up. A per-anchor query drops /// itself, so without this a basket of two hands back its own members. #[test] fn no_anchor_comes_back_as_its_own_result() { let hits = merge_neighbourhoods( &[ hood("a", &[("b", 0.01), ("x", 0.5)]), hood("b", &[("a", 0.01), ("x", 0.6)]), ], 10, ); assert_eq!(ranked(&hits), vec!["x"]); } /// An anchor that came back cold says nothing about anything, so it must not /// make every other anchor's answers unrankable. #[test] fn an_anchor_with_no_neighbours_penalises_nothing() { let hits = merge_neighbourhoods(&[hood("a", &[("x", 0.2)]), hood("b", &[])], 10); let x = hits.iter().find(|hit| hit.hash == "x").unwrap(); assert!((x.score - 0.2).abs() < f64::EPSILON, "{x:?}"); } /// Two queries over an unchanged library give the same list in the same /// order, including where scores tie. A ranking that shuffles is one the /// reader cannot keep their place in. #[test] fn the_ranking_is_stable_where_scores_tie() { let tied = [ hood("a", &[("q", 0.5), ("p", 0.5), ("r", 0.5)]), hood("b", &[("r", 0.5), ("q", 0.5), ("p", 0.5)]), ]; assert_eq!( ranked(&merge_neighbourhoods(&tied, 10)), vec!["p", "q", "r"] ); assert_eq!( merge_neighbourhoods(&tied, 10), merge_neighbourhoods(&tied, 10) ); } #[test] fn an_empty_basket_answers_nothing() { assert!(merge_neighbourhoods(&[], 10).is_empty()); } #[test] fn the_limit_takes_the_nearest_rather_than_the_first_found() { let hits = merge_neighbourhoods( &[hood("a", &[("far", 0.9), ("near", 0.1), ("mid", 0.5)])], 2, ); assert_eq!(ranked(&hits), vec!["near", "mid"]); } /// `feature_distance` is documented as a true weighted-Euclidean metric, the /// VP-tree's triangle-inequality prune depends on it (a pseudometric could /// drop a genuine nearest neighbour). Prove the property rather than assert it /// in prose: over randomized vectors (incl. missing/imputed dims and the /// non-finite values the ingest guard maps to imputed), the triangle /// inequality d(a,c) <= d(a,b) + d(b,c) must hold. #[test] fn feature_distance_satisfies_triangle_inequality() { // Deterministic LCG (no rand dep; reproducible). Numerical Recipes constants. let mut state: u64 = 0x9E37_79B9_7F4A_7C15; let mut next = || { state = state .wrapping_mul(6_364_136_223_846_793_005) .wrapping_add(1_442_695_040_888_963_407); (state >> 33) as f64 / (1u64 << 31) as f64 // [0, 2) }; // A dim is None ~1/4 of the time, NaN ~1/16 (exercise the finite guard), // else a value in roughly the normalized [0,1] range (with some spill). let mut gen_vec = || { let mut d = || -> Option { let r = next(); if r < 0.5 { None } else if r < 0.625 { Some(f64::NAN) } else { Some(next() / 2.0) // ~[0,1) } }; FeatureVector { bpm: d(), duration: d(), lufs: d(), spectral_centroid: d(), spectral_flatness: d(), spectral_rolloff: d(), zero_crossing_rate: d(), onset_strength: d(), spectral_bandwidth: d(), centroid_variance: d(), crest_factor: d(), attack_time: d(), } }; let weights = FeatureWeights::default(); for _ in 0..5000 { let a = gen_vec(); let b = gen_vec(); let c = gen_vec(); let dab = feature_distance(&a, &b, &weights); let dbc = feature_distance(&b, &c, &weights); let dac = feature_distance(&a, &c, &weights); // All distances finite (the non-finite guard holds). assert!(dab.is_finite() && dbc.is_finite() && dac.is_finite()); // Triangle inequality with a small float-rounding tolerance. assert!( dac <= dab + dbc + 1e-9, "triangle inequality violated: d(a,c)={dac} > d(a,b)+d(b,c)={}", dab + dbc ); } } fn insert_with_features(db: &Database, hash: &str, bpm: f64, duration: f64) { insert_fake_sample(db, hash); let result = AnalysisResult { hash: hash.to_string(), duration, sample_rate: 44100, channels: 1, peak_db: None, rms_db: None, lufs: Some(-14.0), bpm: Some(bpm), musical_key: None, is_loop: None, spectral_centroid: Some(1000.0), spectral_flatness: Some(0.5), spectral_rolloff: Some(5000.0), zero_crossing_rate: Some(0.1), onset_strength: Some(20.0), fingerprint: None, spectral_bandwidth: Some(2000.0), centroid_variance: Some(50000.0), crest_factor: Some(3.0), attack_time: Some(0.01), feature_vector: None, feature_version: None, }; analysis::save_analysis_batch(db, std::slice::from_ref(&result)).unwrap(); } #[test] fn normalize_values() { let fv = FeatureVector { bpm: Some(120.0), duration: Some(2.0), ..Default::default() }; let ranges = NormRanges { bpm: (100.0, 200.0), duration: (1.0, 3.0), ..Default::default() }; let normed = normalize(&fv, &ranges); assert!((normed.bpm.unwrap() - 0.2).abs() < 1e-10); assert!((normed.duration.unwrap() - 0.5).abs() < 1e-10); } #[test] fn distance_zero_for_identical() { let fv = FeatureVector { bpm: Some(0.5), duration: Some(0.5), lufs: Some(0.5), spectral_centroid: Some(0.5), spectral_flatness: Some(0.5), spectral_rolloff: Some(0.5), zero_crossing_rate: Some(0.5), onset_strength: Some(0.5), spectral_bandwidth: Some(0.5), centroid_variance: Some(0.5), crest_factor: Some(0.5), attack_time: Some(0.5), }; let d = feature_distance(&fv, &fv, &FeatureWeights::default()); assert!((d - 0.0).abs() < f64::EPSILON); } #[test] fn distance_symmetric() { let a = FeatureVector { bpm: Some(0.0), duration: Some(1.0), ..Default::default() }; let b = FeatureVector { bpm: Some(1.0), duration: Some(0.0), ..Default::default() }; let w = FeatureWeights::default(); let d1 = feature_distance(&a, &b, &w); let d2 = feature_distance(&b, &a, &w); assert!((d1 - d2).abs() < f64::EPSILON); } #[test] fn distance_satisfies_triangle_inequality_with_missing_dims() { // The VP-tree index prunes on the triangle inequality, so the distance // must satisfy d(a,c) <= d(a,b) + d(b,c) even when vectors have // different sets of missing dimensions (the case the old per-pair // denominator broke). let w = FeatureWeights::default(); let a = FeatureVector { bpm: Some(0.1), duration: Some(0.9), ..Default::default() }; let b = FeatureVector { bpm: Some(0.8), lufs: Some(0.2), ..Default::default() }; let c = FeatureVector { duration: Some(0.1), spectral_centroid: Some(0.7), ..Default::default() }; let ab = feature_distance(&a, &b, &w); let bc = feature_distance(&b, &c, &w); let ac = feature_distance(&a, &c, &w); assert!( ac <= ab + bc + 1e-9, "triangle inequality violated: d(a,c)={ac} > d(a,b)+d(b,c)={}", ab + bc ); } #[test] fn ranking_correctness() { let db = Database::open_in_memory().unwrap(); insert_with_features(&db, "ref", 120.0, 1.0); insert_with_features(&db, "close", 122.0, 1.1); insert_with_features(&db, "far", 200.0, 10.0); let results = find_similar(&db, "ref", 10).unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0].hash, "close"); assert_eq!(results[1].hash, "far"); assert!(results[0].distance < results[1].distance); } #[test] fn limit_respected() { let db = Database::open_in_memory().unwrap(); insert_with_features(&db, "ref", 120.0, 1.0); insert_with_features(&db, "a", 121.0, 1.0); insert_with_features(&db, "b", 122.0, 1.0); insert_with_features(&db, "c", 123.0, 1.0); let results = find_similar(&db, "ref", 2).unwrap(); assert_eq!(results.len(), 2); } #[test] fn missing_hash_errors() { let db = Database::open_in_memory().unwrap(); let result = find_similar(&db, "nonexistent", 10); assert!(result.is_err()); } // --- SimilarityIndex tests --- #[test] fn index_build_empty() { let db = Database::open_in_memory().unwrap(); let idx = SimilarityIndex::build(&db).unwrap(); assert!(idx.is_empty()); assert_eq!(idx.len(), 0); } #[test] fn index_ranking_matches_linear() { let db = Database::open_in_memory().unwrap(); insert_with_features(&db, "ref", 120.0, 1.0); insert_with_features(&db, "close", 122.0, 1.1); insert_with_features(&db, "far", 200.0, 10.0); let linear = find_similar(&db, "ref", 10).unwrap(); let idx = SimilarityIndex::build(&db).unwrap(); let ref_features = load_features(&db, "ref").unwrap(); let indexed = idx.find_similar("ref", &ref_features, 10); // Same ranking order. assert_eq!(linear.len(), indexed.len()); for (l, i) in linear.iter().zip(indexed.iter()) { assert_eq!(l.hash, i.hash, "Ranking order differs"); } } #[test] fn index_limit_respected() { let db = Database::open_in_memory().unwrap(); insert_with_features(&db, "ref", 120.0, 1.0); insert_with_features(&db, "a", 121.0, 1.0); insert_with_features(&db, "b", 122.0, 1.0); insert_with_features(&db, "c", 123.0, 1.0); let idx = SimilarityIndex::build(&db).unwrap(); let ref_features = load_features(&db, "ref").unwrap(); let results = idx.find_similar("ref", &ref_features, 2); assert_eq!(results.len(), 2); } #[test] fn index_excludes_self() { let db = Database::open_in_memory().unwrap(); insert_with_features(&db, "only", 120.0, 1.0); let idx = SimilarityIndex::build(&db).unwrap(); let features = load_features(&db, "only").unwrap(); let results = idx.find_similar("only", &features, 10); assert!(results.is_empty()); } // --- NeighbourGraph tests --- /// A five-sample fixture whose pairwise distances are all distinct, so a /// ranking disagreement is a real disagreement and never a tie broken two /// ways. Only bpm and duration vary; the other ten dimensions are constant /// across `insert_with_features`, which is what makes the arithmetic here /// checkable by hand. fn fixture_library() -> Database { let db = Database::open_in_memory().unwrap(); insert_with_features(&db, "a", 100.0, 1.0); insert_with_features(&db, "b", 120.0, 2.0); insert_with_features(&db, "c", 150.0, 4.0); insert_with_features(&db, "d", 180.0, 7.0); insert_with_features(&db, "e", 200.0, 11.0); db } /// Every stored edge, in a comparable order. fn stored_edges(db: &Database) -> Vec<(String, String, f64, i64)> { let mut stmt = db .conn() .prepare( "SELECT hash, neighbour_hash, distance, rank FROM sample_neighbours ORDER BY hash, rank", ) .unwrap(); stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) .unwrap() .collect::>() .unwrap() } /// The graph's ranking must be the brute-force oracle's ranking, for every /// sample. This is the whole correctness claim: the table is a cache of an /// answer that already has a right value, so it is checked against the /// definition rather than against the index that produced it. #[test] fn graph_agrees_with_the_brute_force_oracle() { let db = fixture_library(); let index = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::rebuild(&db, &index).unwrap(); for hash in ["a", "b", "c", "d", "e"] { let oracle = find_similar(&db, hash, GRAPH_K).unwrap(); let stored = NeighbourGraph::neighbours(&db, hash, GRAPH_K) .unwrap() .unwrap_or_else(|| panic!("graph declined to answer for {hash}")); let oracle_hashes: Vec<&str> = oracle.iter().map(|r| r.hash.as_str()).collect(); let stored_hashes: Vec<&str> = stored.iter().map(|r| r.hash.as_str()).collect(); assert_eq!(stored_hashes, oracle_hashes, "ranking differs for {hash}"); for (s, o) in stored.iter().zip(oracle.iter()) { assert!( (s.distance - o.distance).abs() < 1e-12, "distance differs for {hash} -> {}: {} vs {}", s.hash, s.distance, o.distance ); } } } /// Patching one sample in must land on exactly the graph a rebuild would /// have produced. If it does not, the incremental path is a second /// implementation of the same idea rather than a cheaper route to it. #[test] fn incremental_insert_matches_a_full_rebuild() { let db = fixture_library(); let before = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::rebuild(&db, &before).unwrap(); // Interior on both varying dimensions, so the normalization ranges do // not move and `refresh` is free to take the incremental path. insert_with_features(&db, "f", 145.0, 3.5); let after = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::mark_dirty(&db, &["f".to_string()]).unwrap(); NeighbourGraph::refresh(&db, &after).unwrap(); let incremental = stored_edges(&db); NeighbourGraph::rebuild(&db, &after).unwrap(); assert_eq!(incremental, stored_edges(&db)); } /// Out-edges cascade with the sample row; back-edges have no foreign key to /// cascade along, so they are the ones that rot. Nothing may point at a hash /// that is gone, and the sources left short must be queued to refill. #[test] fn delete_leaves_no_edge_pointing_at_a_gone_sample() { let db = fixture_library(); let index = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::rebuild(&db, &index).unwrap(); db.conn() .execute("DELETE FROM samples WHERE hash = 'c'", []) .unwrap(); let dangling: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM sample_neighbours WHERE neighbour_hash NOT IN (SELECT hash FROM samples)", [], |r| r.get(0), ) .unwrap(); assert_eq!(dangling, 0, "back-edges to the deleted sample survived"); let own: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM sample_neighbours WHERE hash = 'c'", [], |r| r.get(0), ) .unwrap(); assert_eq!(own, 0, "out-edges of the deleted sample survived"); let queued: i64 = db .conn() .query_row("SELECT COUNT(*) FROM neighbour_graph_dirty", [], |r| { r.get(0) }) .unwrap(); assert_eq!(queued, 4, "every source that lost an edge should be queued"); } /// An insert outside the stored ranges invalidates every distance in the /// table, not just the new sample's, because they were all normalized /// against ranges that no longer hold. `refresh` has to notice and rebuild, /// and the proof is that the graph still matches the oracle afterwards. #[test] fn a_widening_insert_rebuilds_rather_than_patches() { let db = fixture_library(); let before = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::rebuild(&db, &before).unwrap(); // Well outside both ranges the graph was built under. Outside on both // axes rather than one, because widening a single axis rescales it // alone and can leave two samples exactly equidistant from a third, // where a ranking disagreement would be a coin toss rather than a bug. insert_with_features(&db, "g", 400.0, 20.0); let after = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::mark_dirty(&db, &["g".to_string()]).unwrap(); NeighbourGraph::refresh(&db, &after).unwrap(); for hash in ["a", "b", "c", "d", "e", "g"] { let oracle = find_similar(&db, hash, GRAPH_K).unwrap(); let stored = NeighbourGraph::neighbours(&db, hash, GRAPH_K) .unwrap() .unwrap_or_else(|| panic!("graph declined to answer for {hash}")); let oracle_hashes: Vec<&str> = oracle.iter().map(|r| r.hash.as_str()).collect(); let stored_hashes: Vec<&str> = stored.iter().map(|r| r.hash.as_str()).collect(); assert_eq!( stored_hashes, oracle_hashes, "stale ranking survived a widening insert for {hash}" ); } } /// The graph declines rather than answering wrongly: before it is built, once /// it is marked stale, while a sample is queued, and past the k it stores. /// Each of these sends the caller to the VP-tree, which is why a cold or /// behind graph costs speed and never correctness. #[test] fn graph_declines_when_it_cannot_answer() { let db = fixture_library(); assert!(NeighbourGraph::is_stale(&db).unwrap(), "never built"); assert!(NeighbourGraph::neighbours(&db, "a", 4).unwrap().is_none()); let index = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::rebuild(&db, &index).unwrap(); assert!(!NeighbourGraph::is_stale(&db).unwrap()); assert!(NeighbourGraph::neighbours(&db, "a", 4).unwrap().is_some()); // Past the stored k there is no edge to answer with. assert!( NeighbourGraph::neighbours(&db, "a", GRAPH_K + 1) .unwrap() .is_none() ); // Queued for update: the stored edges are known to be behind. NeighbourGraph::mark_dirty(&db, &["a".to_string()]).unwrap(); assert!(NeighbourGraph::neighbours(&db, "a", 4).unwrap().is_none()); assert!( NeighbourGraph::neighbours(&db, "b", 4).unwrap().is_some(), "one queued sample should not silence the rest of the graph" ); // Marked stale wholesale: nothing answers. NeighbourGraph::mark_stale(&db).unwrap(); assert!(NeighbourGraph::is_stale(&db).unwrap()); assert!(NeighbourGraph::neighbours(&db, "b", 4).unwrap().is_none()); } /// A sample unknown to the graph must not be answered with an empty list. /// The short-row-set path trusts the row count only when the library really /// is that small, and an unanalysed hash is the case that separates the two. #[test] fn an_unknown_sample_is_a_miss_not_an_empty_answer() { let db = fixture_library(); let index = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::rebuild(&db, &index).unwrap(); assert!( NeighbourGraph::neighbours(&db, "nonexistent", 4) .unwrap() .is_none() ); } /// A one-sample library has no neighbours, and that is an answer rather than /// a miss: the graph would otherwise never satisfy a library smaller than /// the limit being asked for. #[test] fn a_complete_short_answer_is_still_an_answer() { let db = Database::open_in_memory().unwrap(); insert_with_features(&db, "only", 120.0, 1.0); let index = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::rebuild(&db, &index).unwrap(); let rows = NeighbourGraph::neighbours(&db, "only", GRAPH_K) .unwrap() .expect("the whole library is covered"); assert!(rows.is_empty()); } /// `remove` is the explicit form of what the delete trigger does, for a /// caller unlinking a sample whose row stays. #[test] fn remove_unlinks_in_both_directions() { let db = fixture_library(); let index = SimilarityIndex::build(&db).unwrap(); NeighbourGraph::rebuild(&db, &index).unwrap(); NeighbourGraph::remove(&db, "c").unwrap(); let touching: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM sample_neighbours WHERE hash = 'c' OR neighbour_hash = 'c'", [], |r| r.get(0), ) .unwrap(); assert_eq!(touching, 0); let queued: bool = db .conn() .query_row( "SELECT EXISTS(SELECT 1 FROM neighbour_graph_dirty WHERE hash = 'a')", [], |r| r.get(0), ) .unwrap(); assert!(queued, "sources left short should be queued to refill"); } #[test] fn index_sorted_by_distance() { let db = Database::open_in_memory().unwrap(); insert_with_features(&db, "ref", 120.0, 1.0); insert_with_features(&db, "a", 125.0, 2.0); insert_with_features(&db, "b", 130.0, 3.0); insert_with_features(&db, "c", 140.0, 5.0); insert_with_features(&db, "d", 200.0, 10.0); let idx = SimilarityIndex::build(&db).unwrap(); let ref_features = load_features(&db, "ref").unwrap(); let results = idx.find_similar("ref", &ref_features, 10); for w in results.windows(2) { assert!( w[0].distance <= w[1].distance, "Results not sorted: {} > {}", w[0].distance, w[1].distance ); } }