| 1 |
1 |
|
//! Similarity search: find samples with similar audio features using weighted Euclidean distance.
|
| 2 |
2 |
|
|
| 3 |
3 |
|
use crate::db::Database;
|
| 4 |
|
- |
use crate::error::{CoreError, Result};
|
|
4 |
+ |
use crate::error::{CoreError, Result, unix_now};
|
| 5 |
5 |
|
use crate::vp_tree::VpTree;
|
|
6 |
+ |
use rusqlite::{Connection, params};
|
|
7 |
+ |
use serde::{Deserialize, Serialize};
|
| 6 |
8 |
|
use tracing::instrument;
|
| 7 |
9 |
|
|
| 8 |
10 |
|
/// Audio feature vector for similarity comparison.
|
| 68 |
70 |
|
}
|
| 69 |
71 |
|
|
| 70 |
72 |
|
/// Normalization ranges for each feature dimension, learned from the dataset.
|
| 71 |
|
- |
#[derive(Debug, Clone, Default)]
|
|
73 |
+ |
///
|
|
74 |
+ |
/// Round-trips through `neighbour_graph_meta.ranges` so a stored distance can be
|
|
75 |
+ |
/// checked against the ranges it was computed under; `PartialEq` is what
|
|
76 |
+ |
/// [`NeighbourGraph::refresh`] uses to notice that they have drifted.
|
|
77 |
+ |
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
| 72 |
78 |
|
struct NormRanges {
|
| 73 |
79 |
|
bpm: (f64, f64),
|
| 74 |
80 |
|
duration: (f64, f64),
|
| 493 |
499 |
|
})
|
| 494 |
500 |
|
.collect()
|
| 495 |
501 |
|
}
|
|
502 |
+ |
|
|
503 |
+ |
/// Hash of the `i`th indexed sample, for `i` in `0..len()`.
|
|
504 |
+ |
pub fn hash_at(&self, i: usize) -> &str {
|
|
505 |
+ |
&self.tree.get(i).hash
|
|
506 |
+ |
}
|
|
507 |
+ |
|
|
508 |
+ |
/// Neighbours of the `i`th indexed sample, queried from its own stored
|
|
509 |
+ |
/// entry rather than from raw features.
|
|
510 |
+ |
///
|
|
511 |
+ |
/// Equivalent to [`find_similar`](Self::find_similar) with that sample's
|
|
512 |
+ |
/// features, minus the redundant re-normalization; the graph builder walks
|
|
513 |
+ |
/// every sample in the index, so that saving is the whole build.
|
|
514 |
+ |
/// Self-exclusion is by tree index rather than by hash, so a distinct sample
|
|
515 |
+ |
/// that happens to sit at distance zero is still a neighbour.
|
|
516 |
+ |
pub fn neighbours_of(&self, i: usize, limit: usize) -> Vec<SimilarResult> {
|
|
517 |
+ |
// Request limit+1: the query point is itself in the tree.
|
|
518 |
+ |
let candidates = self
|
|
519 |
+ |
.tree
|
|
520 |
+ |
.find_nearest(self.tree.get(i), limit + 1, entry_distance);
|
|
521 |
+ |
candidates
|
|
522 |
+ |
.into_iter()
|
|
523 |
+ |
.filter(|c| c.index != i)
|
|
524 |
+ |
.take(limit)
|
|
525 |
+ |
.map(|c| SimilarResult {
|
|
526 |
+ |
hash: self.tree.get(c.index).hash.clone(),
|
|
527 |
+ |
distance: c.distance,
|
|
528 |
+ |
})
|
|
529 |
+ |
.collect()
|
|
530 |
+ |
}
|
|
531 |
+ |
|
|
532 |
+ |
/// The normalization ranges this index was built under.
|
|
533 |
+ |
fn ranges(&self) -> &NormRanges {
|
|
534 |
+ |
&self.ranges
|
|
535 |
+ |
}
|
| 496 |
536 |
|
}
|
| 497 |
537 |
|
|
| 498 |
538 |
|
/// Compute min/max ranges across all samples (no reference bias).
|
| 531 |
571 |
|
ranges
|
| 532 |
572 |
|
}
|
| 533 |
573 |
|
|
|
574 |
+ |
// --- Persisted neighbour graph ---
|
|
575 |
+ |
|
|
576 |
+ |
/// Out-edges stored per sample in the persisted neighbour graph.
|
|
577 |
+ |
///
|
|
578 |
+ |
/// The read path answers any query up to this limit from `sample_neighbours`;
|
|
579 |
+ |
/// past it the VP-tree is the only source. This is the similarity path's own
|
|
580 |
+ |
/// constant and is unrelated to `analysis::exemplar::DEFAULT_K`, which belongs
|
|
581 |
+ |
/// to the classifier: the two share no call site and never did.
|
|
582 |
+ |
pub const GRAPH_K: usize = 50;
|
|
583 |
+ |
|
|
584 |
+ |
/// Fraction of the library that has to be pending before [`NeighbourGraph::refresh`]
|
|
585 |
+ |
/// stops updating sample by sample and rebuilds the whole graph instead.
|
|
586 |
+ |
///
|
|
587 |
+ |
/// A cost trade, not a semantic one: both paths land on the same edges. One
|
|
588 |
+ |
/// incremental update is a tree query plus a few dozen small writes, and a full
|
|
589 |
+ |
/// rebuild is one tree query per sample with no per-source bookkeeping at all,
|
|
590 |
+ |
/// so past roughly a quarter of the library the incremental path stops paying
|
|
591 |
+ |
/// for itself. A first import takes the rebuild path, which is what it should do.
|
|
592 |
+ |
const REBUILD_WHEN_DIRTY_FRACTION: usize = 4;
|
|
593 |
+ |
|
|
594 |
+ |
/// What the graph was built with, from `neighbour_graph_meta`.
|
|
595 |
+ |
struct GraphMeta {
|
|
596 |
+ |
k: usize,
|
|
597 |
+ |
ranges: NormRanges,
|
|
598 |
+ |
stale: bool,
|
|
599 |
+ |
}
|
|
600 |
+ |
|
|
601 |
+ |
/// The stored k-nearest-neighbour graph: `sample_neighbours` plus the two small
|
|
602 |
+ |
/// tables that say how far behind it is.
|
|
603 |
+ |
///
|
|
604 |
+ |
/// The point is not caching a query, it is having the neighbourhood as a thing
|
|
605 |
+ |
/// that exists. A VP-tree answers "what is near this" and forgets; a stored
|
|
606 |
+ |
/// graph can be walked, so chains, regions and "what is near nothing" become
|
|
607 |
+ |
/// reachable without a new index each time.
|
|
608 |
+ |
///
|
|
609 |
+ |
/// Distances are exactly what [`SimilarityIndex::find_similar`] returns:
|
|
610 |
+ |
/// weighted Euclidean on the min-max-normalized vector, no second scale layered
|
|
611 |
+ |
/// on top. Nothing scores a sample as a share of its neighbourhood any more, so
|
|
612 |
+ |
/// there is no denominator left for a standardisation to serve, and keeping the
|
|
613 |
+ |
/// raw number is what makes "how far apart are these two really" answerable.
|
|
614 |
+ |
///
|
|
615 |
+ |
/// The graph is derived and additive. Every read path falls back to the VP-tree
|
|
616 |
+ |
/// when it cannot answer, so a graph that is cold, stale or half-built costs
|
|
617 |
+ |
/// speed and never correctness.
|
|
618 |
+ |
pub struct NeighbourGraph;
|
|
619 |
+ |
|
|
620 |
+ |
impl NeighbourGraph {
|
|
621 |
+ |
/// Read the single meta row. `None` means the graph was never built.
|
|
622 |
+ |
fn meta(db: &Database) -> Result<Option<GraphMeta>> {
|
|
623 |
+ |
let row = db.conn().query_row(
|
|
624 |
+ |
"SELECT k, ranges, stale FROM neighbour_graph_meta WHERE id = 1",
|
|
625 |
+ |
[],
|
|
626 |
+ |
|r| {
|
|
627 |
+ |
Ok((
|
|
628 |
+ |
r.get::<_, i64>(0)?,
|
|
629 |
+ |
r.get::<_, String>(1)?,
|
|
630 |
+ |
r.get::<_, i64>(2)?,
|
|
631 |
+ |
))
|
|
632 |
+ |
},
|
|
633 |
+ |
);
|
|
634 |
+ |
match row {
|
|
635 |
+ |
Ok((k, ranges, stale)) => Ok(Some(GraphMeta {
|
|
636 |
+ |
k: usize::try_from(k).unwrap_or(0),
|
|
637 |
+ |
ranges: serde_json::from_str(&ranges)
|
|
638 |
+ |
.map_err(|e| CoreError::Serialization(e.to_string()))?,
|
|
639 |
+ |
stale: stale != 0,
|
|
640 |
+ |
})),
|
|
641 |
+ |
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
642 |
+ |
Err(e) => Err(e.into()),
|
|
643 |
+ |
}
|
|
644 |
+ |
}
|
|
645 |
+ |
|
|
646 |
+ |
/// Whether the graph cannot be trusted as it stands: never built, explicitly
|
|
647 |
+ |
/// marked, or built for a smaller k than the code now asks for.
|
|
648 |
+ |
pub fn is_stale(db: &Database) -> Result<bool> {
|
|
649 |
+ |
Ok(match Self::meta(db)? {
|
|
650 |
+ |
None => true,
|
|
651 |
+ |
Some(m) => m.stale || m.k < GRAPH_K,
|
|
652 |
+ |
})
|
|
653 |
+ |
}
|
|
654 |
+ |
|
|
655 |
+ |
/// Distrust the whole graph, for a caller that knows the library changed but
|
|
656 |
+ |
/// not which samples. The next [`refresh`](Self::refresh) rebuilds it.
|
|
657 |
+ |
pub fn mark_stale(db: &Database) -> Result<()> {
|
|
658 |
+ |
db.conn()
|
|
659 |
+ |
.execute("UPDATE neighbour_graph_meta SET stale = 1 WHERE id = 1", [])?;
|
|
660 |
+ |
Ok(())
|
|
661 |
+ |
}
|
|
662 |
+ |
|
|
663 |
+ |
/// Queue samples whose out-edges need recomputing. Unknown hashes are
|
|
664 |
+ |
/// dropped rather than rejected, so a caller can pass a batch without first
|
|
665 |
+ |
/// checking which of it survived.
|
|
666 |
+ |
pub fn mark_dirty(db: &Database, hashes: &[String]) -> Result<()> {
|
|
667 |
+ |
if hashes.is_empty() {
|
|
668 |
+ |
return Ok(());
|
|
669 |
+ |
}
|
|
670 |
+ |
db.transaction_core(|_tx| {
|
|
671 |
+ |
let mut stmt = db.conn().prepare(
|
|
672 |
+ |
"INSERT OR IGNORE INTO neighbour_graph_dirty (hash)
|
|
673 |
+ |
SELECT hash FROM samples WHERE hash = ?1",
|
|
674 |
+ |
)?;
|
|
675 |
+ |
for hash in hashes {
|
|
676 |
+ |
stmt.execute([hash])?;
|
|
677 |
+ |
}
|
|
678 |
+ |
Ok(())
|
|
679 |
+ |
})
|
|
680 |
+ |
}
|
|
681 |
+ |
|
|
682 |
+ |
/// Stored neighbours of `hash`, or `None` when the graph cannot answer and
|
|
683 |
+ |
/// the caller must fall back to the VP-tree.
|
|
684 |
+ |
///
|
|
685 |
+ |
/// It declines for a limit past `GRAPH_K`, for a graph that is stale or has
|
|
686 |
+ |
/// this sample queued for update, and for a row set shorter than asked that
|
|
687 |
+ |
/// is not simply the whole library. That last case is what the count query
|
|
688 |
+ |
/// is for: in a library of three samples, two neighbours IS the complete
|
|
689 |
+ |
/// answer to a request for fifty, and treating it as a miss would mean the
|
|
690 |
+ |
/// graph never answers a small library at all.
|
|
691 |
+ |
#[instrument(skip_all, fields(hash = %hash))]
|
|
692 |
+ |
pub fn neighbours(
|
|
693 |
+ |
db: &Database,
|
|
694 |
+ |
hash: &str,
|
|
695 |
+ |
limit: usize,
|
|
696 |
+ |
) -> Result<Option<Vec<SimilarResult>>> {
|
|
697 |
+ |
if limit == 0 {
|
|
698 |
+ |
return Ok(Some(Vec::new()));
|
|
699 |
+ |
}
|
|
700 |
+ |
if limit > GRAPH_K {
|
|
701 |
+ |
return Ok(None);
|
|
702 |
+ |
}
|
|
703 |
+ |
match Self::meta(db)? {
|
|
704 |
+ |
Some(m) if !m.stale && m.k >= limit => {}
|
|
705 |
+ |
_ => return Ok(None),
|
|
706 |
+ |
}
|
|
707 |
+ |
let queued: bool = db.conn().query_row(
|
|
708 |
+ |
"SELECT EXISTS(SELECT 1 FROM neighbour_graph_dirty WHERE hash = ?1)",
|
|
709 |
+ |
[hash],
|
|
710 |
+ |
|r| r.get(0),
|
|
711 |
+ |
)?;
|
|
712 |
+ |
if queued {
|
|
713 |
+ |
return Ok(None);
|
|
714 |
+ |
}
|
|
715 |
+ |
|
|
716 |
+ |
let mut stmt = db.conn().prepare(
|
|
717 |
+ |
"SELECT neighbour_hash, distance FROM sample_neighbours
|
|
718 |
+ |
WHERE hash = ?1 ORDER BY rank LIMIT ?2",
|
|
719 |
+ |
)?;
|
|
720 |
+ |
let rows: Vec<SimilarResult> = stmt
|
|
721 |
+ |
.query_map(params![hash, limit as i64], |r| {
|
|
722 |
+ |
Ok(SimilarResult {
|
|
723 |
+ |
hash: r.get(0)?,
|
|
724 |
+ |
distance: r.get(1)?,
|
|
725 |
+ |
})
|
|
726 |
+ |
})?
|
|
727 |
+ |
.collect::<std::result::Result<_, _>>()?;
|
|
728 |
+ |
if rows.len() >= limit {
|
|
729 |
+ |
return Ok(Some(rows));
|
|
730 |
+ |
}
|
|
731 |
+ |
|
|
732 |
+ |
// Short of the limit. Only the whole-library case makes that an answer,
|
|
733 |
+ |
// and only for a sample the graph could have covered in the first place.
|
|
734 |
+ |
let (analysed, known): (i64, bool) = db.conn().query_row(
|
|
735 |
+ |
"SELECT (SELECT COUNT(*) FROM audio_analysis),
|
|
736 |
+ |
EXISTS(SELECT 1 FROM audio_analysis WHERE hash = ?1)",
|
|
737 |
+ |
[hash],
|
|
738 |
+ |
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
739 |
+ |
)?;
|
|
740 |
+ |
if known && rows.len() as i64 + 1 >= analysed {
|
|
741 |
+ |
Ok(Some(rows))
|
|
742 |
+ |
} else {
|
|
743 |
+ |
Ok(None)
|
|
744 |
+ |
}
|
|
745 |
+ |
}
|
|
746 |
+ |
|
|
747 |
+ |
/// Rebuild every edge from `index`, replacing whatever was stored.
|
|
748 |
+ |
#[instrument(skip_all)]
|
|
749 |
+ |
pub fn rebuild(db: &Database, index: &SimilarityIndex) -> Result<()> {
|
|
750 |
+ |
let ranges = serde_json::to_string(index.ranges())
|
|
751 |
+ |
.map_err(|e| CoreError::Serialization(e.to_string()))?;
|
|
752 |
+ |
let built_at = unix_now();
|
|
753 |
+ |
db.transaction_core(|_tx| {
|
|
754 |
+ |
let conn = db.conn();
|
|
755 |
+ |
conn.execute("DELETE FROM sample_neighbours", [])?;
|
|
756 |
+ |
conn.execute("DELETE FROM neighbour_graph_dirty", [])?;
|
|
757 |
+ |
{
|
|
758 |
+ |
let mut stmt = conn.prepare(
|
|
759 |
+ |
"INSERT OR REPLACE INTO sample_neighbours (hash, neighbour_hash, distance, rank)
|
|
760 |
+ |
VALUES (?1, ?2, ?3, ?4)",
|
|
761 |
+ |
)?;
|
|
762 |
+ |
for i in 0..index.len() {
|
|
763 |
+ |
let hash = index.hash_at(i);
|
|
764 |
+ |
for (rank, n) in index.neighbours_of(i, GRAPH_K).iter().enumerate() {
|
|
765 |
+ |
stmt.execute(params![hash, n.hash, n.distance, rank as i64])?;
|
|
766 |
+ |
}
|
|
767 |
+ |
}
|
|
768 |
+ |
}
|
|
769 |
+ |
conn.execute(
|
|
770 |
+ |
"INSERT OR REPLACE INTO neighbour_graph_meta (id, k, ranges, stale, built_at)
|
|
771 |
+ |
VALUES (1, ?1, ?2, 0, ?3)",
|
|
772 |
+ |
params![GRAPH_K as i64, ranges, built_at],
|
|
773 |
+ |
)?;
|
|
774 |
+ |
Ok(())
|
|
775 |
+ |
})
|
|
776 |
+ |
}
|
|
777 |
+ |
|
|
778 |
+ |
/// Recompute one sample's out-edges, and offer it as a neighbour to each
|
|
779 |
+ |
/// sample it landed near.
|
|
780 |
+ |
///
|
|
781 |
+ |
/// At most k+1 sources are touched: the sample itself, plus a back-edge into
|
|
782 |
+ |
/// each neighbour where the new distance beats that neighbour's current
|
|
783 |
+ |
/// k-th. `index` need not contain `hash` — the query goes by features, and
|
|
784 |
+ |
/// the metric is symmetric, so the back-edge distance is the same number
|
|
785 |
+ |
/// already computed for the out-edge.
|
|
786 |
+ |
#[instrument(skip_all, fields(hash = %hash))]
|
|
787 |
+ |
pub fn insert(db: &Database, index: &SimilarityIndex, hash: &str) -> Result<()> {
|
|
788 |
+ |
let features = load_features(db, hash)?;
|
|
789 |
+ |
let outs = index.find_similar(hash, &features, GRAPH_K);
|
|
790 |
+ |
db.transaction_core(|_tx| {
|
|
791 |
+ |
let conn = db.conn();
|
|
792 |
+ |
conn.execute("DELETE FROM sample_neighbours WHERE hash = ?1", [hash])?;
|
|
793 |
+ |
{
|
|
794 |
+ |
let mut stmt = conn.prepare(
|
|
795 |
+ |
"INSERT OR REPLACE INTO sample_neighbours (hash, neighbour_hash, distance, rank)
|
|
796 |
+ |
VALUES (?1, ?2, ?3, ?4)",
|
|
797 |
+ |
)?;
|
|
798 |
+ |
for (rank, n) in outs.iter().enumerate() {
|
|
799 |
+ |
stmt.execute(params![hash, n.hash, n.distance, rank as i64])?;
|
|
800 |
+ |
}
|
|
801 |
+ |
}
|
|
802 |
+ |
for n in &outs {
|
|
803 |
+ |
Self::offer_back_edge(conn, &n.hash, hash, n.distance)?;
|
|
804 |
+ |
}
|
|
805 |
+ |
conn.execute("DELETE FROM neighbour_graph_dirty WHERE hash = ?1", [hash])?;
|
|
806 |
+ |
Ok(())
|
|
807 |
+ |
})
|
|
808 |
+ |
}
|
|
809 |
+ |
|
|
810 |
+ |
/// Write `neighbour` into `source`'s edge list if it earns a place, then
|
|
811 |
+ |
/// trim the list back to k and renumber it.
|
|
812 |
+ |
fn offer_back_edge(
|
|
813 |
+ |
conn: &Connection,
|
|
814 |
+ |
source: &str,
|
|
815 |
+ |
neighbour: &str,
|
|
816 |
+ |
distance: f64,
|
|
817 |
+ |
) -> Result<()> {
|
|
818 |
+ |
let (count, worst): (i64, Option<f64>) = conn.query_row(
|
|
819 |
+ |
"SELECT COUNT(*), MAX(distance) FROM sample_neighbours WHERE hash = ?1",
|
|
820 |
+ |
[source],
|
|
821 |
+ |
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
822 |
+ |
)?;
|
|
823 |
+ |
let earns_a_place =
|
|
824 |
+ |
usize::try_from(count).unwrap_or(0) < GRAPH_K || worst.is_some_and(|w| distance < w);
|
|
825 |
+ |
if !earns_a_place {
|
|
826 |
+ |
return Ok(());
|
|
827 |
+ |
}
|
|
828 |
+ |
// Rank goes in as a placeholder: the insert and the trim below both
|
|
829 |
+ |
// reorder the list, so it is recomputed for the whole source afterwards.
|
|
830 |
+ |
conn.execute(
|
|
831 |
+ |
"INSERT OR REPLACE INTO sample_neighbours (hash, neighbour_hash, distance, rank)
|
|
832 |
+ |
VALUES (?1, ?2, ?3, 0)",
|
|
833 |
+ |
params![source, neighbour, distance],
|
|
834 |
+ |
)?;
|
|
835 |
+ |
conn.execute(
|
|
836 |
+ |
"DELETE FROM sample_neighbours WHERE hash = ?1 AND neighbour_hash NOT IN (
|
|
837 |
+ |
SELECT neighbour_hash FROM sample_neighbours WHERE hash = ?1
|
|
838 |
+ |
ORDER BY distance, neighbour_hash LIMIT ?2)",
|
|
839 |
+ |
params![source, GRAPH_K as i64],
|
|
840 |
+ |
)?;
|
|
841 |
+ |
Self::renumber(conn, source)
|
|
842 |
+ |
}
|
|
843 |
+ |
|
|
844 |
+ |
/// Recompute `rank` for one source from the stored distances. Ties break on
|
|
845 |
+ |
/// `neighbour_hash` so the order is total and the ranks are unique.
|
|
846 |
+ |
fn renumber(conn: &Connection, source: &str) -> Result<()> {
|
|
847 |
+ |
conn.execute(
|
|
848 |
+ |
"UPDATE sample_neighbours SET rank = (
|
|
849 |
+ |
SELECT COUNT(*) FROM sample_neighbours AS other
|
|
850 |
+ |
WHERE other.hash = sample_neighbours.hash
|
|
851 |
+ |
AND (other.distance < sample_neighbours.distance
|
|
852 |
+ |
OR (other.distance = sample_neighbours.distance
|
|
853 |
+ |
AND other.neighbour_hash < sample_neighbours.neighbour_hash))
|
|
854 |
+ |
) WHERE hash = ?1",
|
|
855 |
+ |
[source],
|
|
856 |
+ |
)?;
|
|
857 |
+ |
Ok(())
|
|
858 |
+ |
}
|
|
859 |
+ |
|
|
860 |
+ |
/// Drop a sample from the graph in both directions.
|
|
861 |
+ |
///
|
|
862 |
+ |
/// The `neighbour_graph_delete_back_edges` trigger does this when the sample
|
|
863 |
+ |
/// row itself goes; this is the explicit form, for a caller unlinking a
|
|
864 |
+ |
/// sample while its row stays. Sources left short are queued rather than
|
|
865 |
+ |
/// refilled here, because refilling needs a VP-tree and the refresh pass is
|
|
866 |
+ |
/// where one exists.
|
|
867 |
+ |
pub fn remove(db: &Database, hash: &str) -> Result<()> {
|
|
868 |
+ |
db.transaction_core(|_tx| {
|
|
869 |
+ |
let conn = db.conn();
|
|
870 |
+ |
conn.execute(
|
|
871 |
+ |
"INSERT OR IGNORE INTO neighbour_graph_dirty (hash)
|
|
872 |
+ |
SELECT hash FROM sample_neighbours WHERE neighbour_hash = ?1",
|
|
873 |
+ |
[hash],
|
|
874 |
+ |
)?;
|
|
875 |
+ |
conn.execute(
|
|
876 |
+ |
"DELETE FROM sample_neighbours WHERE neighbour_hash = ?1",
|
|
877 |
+ |
[hash],
|
|
878 |
+ |
)?;
|
|
879 |
+ |
conn.execute("DELETE FROM sample_neighbours WHERE hash = ?1", [hash])?;
|
|
880 |
+ |
conn.execute("DELETE FROM neighbour_graph_dirty WHERE hash = ?1", [hash])?;
|
|
881 |
+ |
Ok(())
|
|
882 |
+ |
})
|
|
883 |
+ |
}
|
|
884 |
+ |
|
|
885 |
+ |
/// Bring the graph up to date against `index`, incrementally where that is
|
|
886 |
+ |
/// cheaper and by full rebuild where it is not.
|
|
887 |
+ |
///
|
|
888 |
+ |
/// Range drift decides it first. `index` was built from current data, so its
|
|
889 |
+ |
/// ranges differing from the stored ones means every stored distance was
|
|
890 |
+ |
/// computed under a normalization that no longer holds, and no amount of
|
|
891 |
+ |
/// per-sample repair fixes that. Comparing the ranges is exact, which is why
|
|
892 |
+ |
/// there is no threshold to guess at here: whether a widening insert
|
|
893 |
+ |
/// actually reorders anyone's top-k is a number to measure before loosening
|
|
894 |
+ |
/// this, not before shipping it.
|
|
895 |
+ |
#[instrument(skip_all)]
|
|
896 |
+ |
pub fn refresh(db: &Database, index: &SimilarityIndex) -> Result<()> {
|
|
897 |
+ |
let needs_rebuild = match Self::meta(db)? {
|
|
898 |
+ |
None => true,
|
|
899 |
+ |
Some(m) => m.stale || m.k < GRAPH_K || m.ranges != *index.ranges(),
|
|
900 |
+ |
};
|
|
901 |
+ |
if needs_rebuild {
|
|
902 |
+ |
return Self::rebuild(db, index);
|
|
903 |
+ |
}
|
|
904 |
+ |
|
|
905 |
+ |
let dirty: Vec<String> = {
|
|
906 |
+ |
let mut stmt = db
|
|
907 |
+ |
.conn()
|
|
908 |
+ |
.prepare("SELECT hash FROM neighbour_graph_dirty")?;
|
|
909 |
+ |
let rows = stmt.query_map([], |r| r.get(0))?;
|
|
910 |
+ |
rows.collect::<std::result::Result<_, _>>()?
|
|
911 |
+ |
};
|
|
912 |
+ |
if dirty.is_empty() {
|
|
913 |
+ |
return Ok(());
|
|
914 |
+ |
}
|
|
915 |
+ |
if dirty.len().saturating_mul(REBUILD_WHEN_DIRTY_FRACTION) >= index.len() {
|
|
916 |
+ |
return Self::rebuild(db, index);
|
|
917 |
+ |
}
|
|
918 |
+ |
for hash in &dirty {
|
|
919 |
+ |
match Self::insert(db, index, hash) {
|
|
920 |
+ |
Ok(()) => {}
|
|
921 |
+ |
// A sample that lost its analysis row between the mark and here
|
|
922 |
+ |
// is not a failure: drop it from the queue and carry on.
|
|
923 |
+ |
Err(CoreError::SampleNotFound(_)) => {
|
|
924 |
+ |
db.conn()
|
|
925 |
+ |
.execute("DELETE FROM neighbour_graph_dirty WHERE hash = ?1", [hash])?;
|
|
926 |
+ |
}
|
|
927 |
+ |
Err(e) => return Err(e),
|
|
928 |
+ |
}
|
|
929 |
+ |
}
|
|
930 |
+ |
Ok(())
|
|
931 |
+ |
}
|
|
932 |
+ |
}
|
|
933 |
+ |
|
| 534 |
934 |
|
#[cfg(test)]
|
| 535 |
935 |
|
mod tests {
|
| 536 |
936 |
|
use super::*;
|
| 805 |
1205 |
|
assert!(results.is_empty());
|
| 806 |
1206 |
|
}
|
| 807 |
1207 |
|
|
|
1208 |
+ |
// --- NeighbourGraph tests ---
|
|
1209 |
+ |
|
|
1210 |
+ |
/// A five-sample fixture whose pairwise distances are all distinct, so a
|
|
1211 |
+ |
/// ranking disagreement is a real disagreement and never a tie broken two
|
|
1212 |
+ |
/// ways. Only bpm and duration vary; the other ten dimensions are constant
|
|
1213 |
+ |
/// across `insert_with_features`, which is what makes the arithmetic here
|
|
1214 |
+ |
/// checkable by hand.
|
|
1215 |
+ |
fn fixture_library() -> Database {
|
|
1216 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
1217 |
+ |
insert_with_features(&db, "a", 100.0, 1.0);
|
|
1218 |
+ |
insert_with_features(&db, "b", 120.0, 2.0);
|
|
1219 |
+ |
insert_with_features(&db, "c", 150.0, 4.0);
|
|
1220 |
+ |
insert_with_features(&db, "d", 180.0, 7.0);
|
|
1221 |
+ |
insert_with_features(&db, "e", 200.0, 11.0);
|
|
1222 |
+ |
db
|
|
1223 |
+ |
}
|
|
1224 |
+ |
|
|
1225 |
+ |
/// Every stored edge, in a comparable order.
|
|
1226 |
+ |
fn stored_edges(db: &Database) -> Vec<(String, String, f64, i64)> {
|
|
1227 |
+ |
let mut stmt = db
|
|
1228 |
+ |
.conn()
|
|
1229 |
+ |
.prepare(
|
|
1230 |
+ |
"SELECT hash, neighbour_hash, distance, rank FROM sample_neighbours
|
|
1231 |
+ |
ORDER BY hash, rank",
|
|
1232 |
+ |
)
|
|
1233 |
+ |
.unwrap();
|
|
1234 |
+ |
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
|
|
1235 |
+ |
.unwrap()
|
|
1236 |
+ |
.collect::<std::result::Result<_, _>>()
|
|
1237 |
+ |
.unwrap()
|
|
1238 |
+ |
}
|
|
1239 |
+ |
|
|
1240 |
+ |
/// The graph's ranking must be the brute-force oracle's ranking, for every
|
|
1241 |
+ |
/// sample. This is the whole correctness claim: the table is a cache of an
|
|
1242 |
+ |
/// answer that already has a right value, so it is checked against the
|
|
1243 |
+ |
/// definition rather than against the index that produced it.
|
|
1244 |
+ |
#[test]
|
|
1245 |
+ |
fn graph_agrees_with_the_brute_force_oracle() {
|
|
1246 |
+ |
let db = fixture_library();
|
|
1247 |
+ |
let index = SimilarityIndex::build(&db).unwrap();
|
|
1248 |
+ |
NeighbourGraph::rebuild(&db, &index).unwrap();
|
|
1249 |
+ |
|
|
1250 |
+ |
for hash in ["a", "b", "c", "d", "e"] {
|
|
1251 |
+ |
let oracle = find_similar(&db, hash, GRAPH_K).unwrap();
|
|
1252 |
+ |
let stored = NeighbourGraph::neighbours(&db, hash, GRAPH_K)
|
|
1253 |
+ |
.unwrap()
|
|
1254 |
+ |
.unwrap_or_else(|| panic!("graph declined to answer for {hash}"));
|
|
1255 |
+ |
let oracle_hashes: Vec<&str> = oracle.iter().map(|r| r.hash.as_str()).collect();
|
|
1256 |
+ |
let stored_hashes: Vec<&str> = stored.iter().map(|r| r.hash.as_str()).collect();
|
|
1257 |
+ |
assert_eq!(stored_hashes, oracle_hashes, "ranking differs for {hash}");
|
|
1258 |
+ |
for (s, o) in stored.iter().zip(oracle.iter()) {
|
|
1259 |
+ |
assert!(
|
|
1260 |
+ |
(s.distance - o.distance).abs() < 1e-12,
|
|
1261 |
+ |
"distance differs for {hash} -> {}: {} vs {}",
|
|
1262 |
+ |
s.hash,
|
|
1263 |
+ |
s.distance,
|
|
1264 |
+ |
o.distance
|
|
1265 |
+ |
);
|
|
1266 |
+ |
}
|
|
1267 |
+ |
}
|
|
1268 |
+ |
}
|
|
1269 |
+ |
|
|
1270 |
+ |
/// Patching one sample in must land on exactly the graph a rebuild would
|
|
1271 |
+ |
/// have produced. If it does not, the incremental path is a second
|
|
1272 |
+ |
/// implementation of the same idea rather than a cheaper route to it.
|
|
1273 |
+ |
#[test]
|
|
1274 |
+ |
fn incremental_insert_matches_a_full_rebuild() {
|
|
1275 |
+ |
let db = fixture_library();
|