//! Exemplar k-NN tag suggestion (Layer B of the hybrid classifier). //! //! The user's labeled library *is* the model: every sample carrying a non-ML tag is an //! exemplar. A query sample is scored by its nearest labeled neighbors in standardized //! feature space; each candidate tag gets a soft, similarity-weighted multi-label score //! in [0, 1]. Per-tag dual thresholds (`tag_policy`) split scores into auto-apply / //! review / ignore. //! //! ML-applied tags (`source = 'ml'`) are excluded from the exemplar labels so the layer //! never trains on its own output. Suggestions carry their driving neighbors for //! explainability ("looks like these kicks you have"). See [`crate::rules`] for provenance. use std::collections::HashMap; use crate::db::Database; use crate::error::{CoreError, Result}; use tracing::instrument; use super::classify::{FEATURE_VERSION, NUM_FEATURES}; /// Default neighbor count. pub const DEFAULT_K: usize = 15; /// Default score at/above which a tag is queued for review. pub const DEFAULT_REVIEW_THRESHOLD: f64 = 0.5; /// Default score at/above which a tag is auto-applied. pub const DEFAULT_AUTO_THRESHOLD: f64 = 0.85; // ── Per-tag policy ── /// Confidence thresholds for a tag. `auto >= review`; setting them equal collapses to /// binary behaviour (auto-apply above, ignore below, no review band). #[derive(Debug, Clone, Copy, PartialEq)] pub struct TagPolicy { pub review_threshold: f64, pub auto_threshold: f64, } impl Default for TagPolicy { fn default() -> Self { Self { review_threshold: DEFAULT_REVIEW_THRESHOLD, auto_threshold: DEFAULT_AUTO_THRESHOLD, } } } /// Fetch a tag's policy, or the default if none is configured. #[instrument(skip_all)] pub fn get_policy(db: &Database, tag: &str) -> Result { let policy = db .conn() .query_row( "SELECT review_threshold, auto_threshold FROM tag_policy WHERE tag = ?1", [tag], |row| { Ok(TagPolicy { review_threshold: row.get(0)?, auto_threshold: row.get(1)?, }) }, ) .ok() .unwrap_or_default(); Ok(policy) } /// Set a tag's thresholds. `auto` is clamped to be >= `review`, both into [0, 1]. #[instrument(skip_all)] pub fn set_policy(db: &Database, tag: &str, review: f64, auto: f64) -> Result<()> { let review = review.clamp(0.0, 1.0); let auto = auto.clamp(review, 1.0); db.conn().execute( "INSERT INTO tag_policy (tag, review_threshold, auto_threshold) VALUES (?1, ?2, ?3) ON CONFLICT(tag) DO UPDATE SET review_threshold = ?2, auto_threshold = ?3", rusqlite::params![tag, review, auto], )?; Ok(()) } /// All configured per-tag policies. #[instrument(skip_all)] pub fn list_policies(db: &Database) -> Result> { let mut stmt = db .conn() .prepare("SELECT tag, review_threshold, auto_threshold FROM tag_policy ORDER BY tag")?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, String>(0)?, TagPolicy { review_threshold: row.get(1)?, auto_threshold: row.get(2)?, }, )) })?; Ok(rows.collect::, _>>()?) } // ── Index + scoring ── struct Exemplar { hash: String, /// Standardized feature vector. vector: Vec, tags: Vec, /// Layer weight multiplier on this exemplar's k-NN contribution. `1.0` for the user's /// own (`local`) data; imported `.afcl` layers sit below it (see [`super::afcl`]). weight: f64, } /// In-memory k-NN index over the labeled library. Rebuild after tag changes. pub struct ExemplarIndex { means: Vec, stds: Vec, exemplars: Vec, pub feature_version: u32, } /// A neighbor that contributed to a tag's score. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct NeighborContribution { pub hash: String, pub weight: f64, } /// A candidate tag with its similarity-weighted score and the neighbors behind it. #[derive(Debug, Clone)] pub struct TagScore { pub tag: String, pub score: f64, pub neighbors: Vec, } /// Whether a scored tag should be auto-applied or queued for review. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum MlAction { AutoApply, Review, } /// A suggestion with its disposition under the per-tag policy. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MlSuggestion { pub tag: String, pub score: f64, pub action: MlAction, pub neighbors: Vec, } /// Auto-applied vs review-queued suggestions for a sample. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct MlOutcome { pub auto_applied: Vec, pub pending_review: Vec, } /// Build the index from every sample carrying at least one non-ML tag. #[instrument(skip_all)] pub fn build_index(db: &Database) -> Result { let conn = db.conn(); // Non-ML tags per sample (the trusted labels). let mut tags_by_hash: HashMap> = HashMap::new(); { let mut stmt = conn.prepare( "SELECT t.sample_hash, t.tag FROM tags t WHERE NOT EXISTS ( SELECT 1 FROM tag_provenance p WHERE p.sample_hash = t.sample_hash AND p.tag = t.tag AND p.source = 'ml')", )?; let rows = stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) })?; for r in rows { let (hash, tag) = r?; tags_by_hash.entry(hash).or_default().push(tag); } } // Raw vectors for those samples (the user's own `local` exemplars; weight 1.0). let mut raw: Vec<(String, Vec, Vec, f64)> = Vec::new(); { let mut stmt = conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?; let rows = stmt.query_map([FEATURE_VERSION], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) })?; for r in rows { let (hash, json) = r?; let Some(tags) = tags_by_hash.remove(&hash) else { continue; }; let v: Vec = serde_json::from_str(&json).map_err(|e| CoreError::Serialization(e.to_string()))?; if is_usable_vector(&v) { raw.push((hash, v, tags, 1.0)); } } } // Imported `.afcl` layer exemplars (enabled layers), weighted below `local`. // A single non-finite component would poison the standardization means for the // whole set, so a malformed vector (crafted `.afcl`, a legacy row, or one // synced from another device) is dropped here as well as at `.afcl` import. for imp in super::afcl::enabled_imported_exemplars(db)? { if is_usable_vector(&imp.vector) { raw.push((imp.id, imp.vector, imp.tags, imp.weight)); } } // Standardization params over the full exemplar set (local + imported). let raw_vecs: Vec<(String, Vec)> = raw .iter() .map(|(h, v, _, _)| (h.clone(), v.clone())) .collect(); let (means, stds) = standardization_params(&raw_vecs); let exemplars = raw .into_iter() .map(|(hash, v, tags, weight)| { let vector = standardize_vec(&v, &means, &stds); Exemplar { hash, vector, tags, weight, } }) .collect(); Ok(ExemplarIndex { means, stds, exemplars, feature_version: FEATURE_VERSION, }) } /// A feature vector is usable in the k-NN index only if it has the expected /// dimension and every component is finite. Non-finite components would poison /// the standardization means and reach the distance-sort comparators. fn is_usable_vector(v: &[f64]) -> bool { v.len() == NUM_FEATURES && v.iter().all(|x| x.is_finite()) } fn standardization_params(raw: &[(String, Vec)]) -> (Vec, Vec) { if raw.is_empty() { return (vec![0.0; NUM_FEATURES], vec![1.0; NUM_FEATURES]); } let n = raw.len() as f64; let dims = raw[0].1.len(); let mut means = vec![0.0; dims]; let mut stds = vec![1.0; dims]; for d in 0..dims { let mean = raw.iter().map(|(_, v)| v[d]).sum::() / n; let var = raw.iter().map(|(_, v)| (v[d] - mean).powi(2)).sum::() / n; means[d] = mean; let s = var.sqrt(); stds[d] = if s > f64::EPSILON { s } else { 1.0 }; } (means, stds) } pub(crate) fn standardize_vec(v: &[f64], means: &[f64], stds: &[f64]) -> Vec { v.iter() .zip(means) .zip(stds) .map(|((x, m), s)| (x - m) / s) .collect() } fn sq_dist(a: &[f64], b: &[f64]) -> f64 { a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum() } impl ExemplarIndex { pub fn len(&self) -> usize { self.exemplars.len() } pub fn is_empty(&self) -> bool { self.exemplars.is_empty() } /// Standardization mean per feature dimension (over the exemplar set). pub(crate) fn means(&self) -> &[f64] { &self.means } /// Standardization std-dev per feature dimension (over the exemplar set). pub(crate) fn stds(&self) -> &[f64] { &self.stds } /// Iterate `(standardized vector, tags)` over every exemplar, the training set /// for the distilled head ([`super::trained_head`]). pub(crate) fn rows(&self) -> impl Iterator { self.exemplars .iter() .map(|e| (e.vector.as_slice(), e.tags.as_slice())) } /// Score candidate tags for a raw (un-standardized) query vector. `exclude_hash` /// drops the query's own exemplar (when scoring an already-labeled sample). pub fn score(&self, raw_query: &[f64], k: usize, exclude_hash: Option<&str>) -> Vec { if self.exemplars.is_empty() || raw_query.len() != NUM_FEATURES || k == 0 { return Vec::new(); } let q = standardize_vec(raw_query, &self.means, &self.stds); // Distances to all eligible exemplars. let mut dists: Vec<(usize, f64)> = self .exemplars .iter() .enumerate() .filter(|(_, e)| exclude_hash != Some(e.hash.as_str())) .map(|(i, e)| (i, sq_dist(&q, &e.vector))) .collect(); if dists.is_empty() { return Vec::new(); } // total_cmp never panics on NaN (unlike partial_cmp().unwrap()); the // build_index finiteness filter should keep NaN out, this is the backstop. dists.sort_by(|a, b| a.1.total_cmp(&b.1)); dists.truncate(k); // Adaptive Gaussian bandwidth = mean neighbor distance. let mean_d = (dists.iter().map(|(_, d2)| d2.sqrt()).sum::() / dists.len() as f64) .max(f64::EPSILON); let denom = 2.0 * mean_d * mean_d; let mut total_weight = 0.0; let weighted: Vec<(usize, f64)> = dists .iter() .map(|&(i, d2)| { // Gaussian kernel scaled by the exemplar's layer weight (imported < local). let w = (-d2 / denom).exp() * self.exemplars[i].weight; total_weight += w; (i, w) }) .collect(); if total_weight <= f64::EPSILON { return Vec::new(); } // Accumulate per-tag weight + contributing neighbors. let mut acc: HashMap<&str, (f64, Vec)> = HashMap::new(); for &(i, w) in &weighted { let e = &self.exemplars[i]; for tag in &e.tags { let slot = acc.entry(tag.as_str()).or_insert((0.0, Vec::new())); slot.0 += w; slot.1.push(NeighborContribution { hash: e.hash.clone(), weight: w, }); } } let mut scores: Vec = acc .into_iter() .map(|(tag, (sum_w, neighbors))| TagScore { tag: tag.to_string(), score: sum_w / total_weight, neighbors, }) .collect(); scores.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.tag.cmp(&b.tag))); scores } } /// Load a sample's raw feature vector (current version), if present. pub(crate) fn load_query_vector(db: &Database, hash: &str) -> Result>> { let json: Option = db .conn() .query_row( "SELECT vector FROM sample_features WHERE hash = ?1 AND feat_version = ?2", rusqlite::params![hash, FEATURE_VERSION], |row| row.get(0), ) .ok(); match json { Some(j) => { let v: Vec = serde_json::from_str(&j).map_err(|e| CoreError::Serialization(e.to_string()))?; Ok(Some(v)) } None => Ok(None), } } /// Split scored tags into auto-apply / review under each tag's policy, dropping tags the /// sample already has and anything below its review threshold. pub(crate) fn apply_policy( db: &Database, scores: Vec, existing: &std::collections::HashSet, ) -> Result { let mut outcome = MlOutcome::default(); for s in scores { if existing.contains(&s.tag) { continue; } let policy = get_policy(db, &s.tag)?; if s.score >= policy.auto_threshold { outcome.auto_applied.push(MlSuggestion { tag: s.tag, score: s.score, action: MlAction::AutoApply, neighbors: s.neighbors, }); } else if s.score >= policy.review_threshold { outcome.pending_review.push(MlSuggestion { tag: s.tag, score: s.score, action: MlAction::Review, neighbors: s.neighbors, }); } } Ok(outcome) } pub(crate) fn sample_tag_set( db: &Database, hash: &str, ) -> Result> { Ok(crate::tags::get_sample_tags(db, hash)? .into_iter() .collect()) } /// Score a stored sample against the index without writing anything. #[instrument(skip_all)] pub fn preview_sample( db: &Database, hash: &str, index: &ExemplarIndex, k: usize, ) -> Result { let Some(raw) = load_query_vector(db, hash)? else { return Ok(MlOutcome::default()); }; let scores = index.score(&raw, k, Some(hash)); let existing = sample_tag_set(db, hash)?; apply_policy(db, scores, &existing) } /// Score a stored sample and **auto-apply** the above-threshold tags (`source = 'ml'`), /// returning what was applied and what's pending review. #[instrument(skip_all)] pub fn apply_ml_suggestions( db: &Database, hash: &str, index: &ExemplarIndex, k: usize, ) -> Result { let outcome = preview_sample(db, hash, index, k)?; db.transaction_core(|tx| { for s in &outcome.auto_applied { crate::rules::apply_tag_sourced(db, tx, hash, &s.tag, "ml")?; } Ok(()) })?; Ok(outcome) } /// Build the index once and auto-apply above-threshold tags to every analyzed, /// non-deleted sample. Returns the total number of tags applied. Heavy, intended /// for an explicit "suggest across library" action. #[instrument(skip_all)] pub fn auto_apply_library(db: &Database, k: usize) -> Result { let index = build_index(db)?; if index.is_empty() { return Ok(0); } let hashes: Vec = { let mut stmt = db.conn().prepare( "SELECT s.hash FROM live_samples s JOIN sample_features f ON f.hash = s.hash AND f.feat_version = ?1", )?; stmt.query_map([FEATURE_VERSION], |row| row.get(0))? .collect::, _>>()? }; let mut applied = 0; for hash in hashes { applied += apply_ml_suggestions(db, &hash, &index, k)? .auto_applied .len(); } Ok(applied) } #[cfg(test)] mod tests { use super::*; fn insert(db: &Database, hash: &str, vec: &[f64; NUM_FEATURES], tags: &[&str]) { db.conn() .execute( "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \ VALUES (?1, ?1, 'wav', 1, 0, 0)", [hash], ) .unwrap(); let json = serde_json::to_string(&vec.to_vec()).unwrap(); db.conn() .execute( "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)", rusqlite::params![hash, FEATURE_VERSION, json], ) .unwrap(); for t in tags { crate::tags::add_tag(db, hash, t).unwrap(); } } fn vec_at(base: f64) -> [f64; NUM_FEATURES] { [base; NUM_FEATURES] } #[test] #[allow( clippy::float_cmp, reason = "exact equality on deterministic test values" )] fn policy_defaults_and_clamping() { let db = Database::open_in_memory().unwrap(); assert_eq!(get_policy(&db, "x").unwrap(), TagPolicy::default()); // auto clamped to >= review. set_policy(&db, "x", 0.7, 0.3).unwrap(); let p = get_policy(&db, "x").unwrap(); assert_eq!(p.review_threshold, 0.7); assert_eq!(p.auto_threshold, 0.7); } #[test] fn empty_index_yields_nothing() { let db = Database::open_in_memory().unwrap(); let index = build_index(&db).unwrap(); assert!(index.is_empty()); insert(&db, "q", &vec_at(0.0), &[]); assert!( apply_ml_suggestions(&db, "q", &index, DEFAULT_K) .unwrap() .auto_applied .is_empty() ); } #[test] fn is_usable_vector_rejects_non_finite_and_wrong_len() { assert!(is_usable_vector(&vec_at(0.5))); // Wrong dimension. assert!(!is_usable_vector(&[0.0; NUM_FEATURES - 1])); // Any non-finite component disqualifies it (would poison the means). let mut nan = vec_at(0.1).to_vec(); nan[3] = f64::NAN; assert!(!is_usable_vector(&nan)); let mut inf = vec_at(0.1).to_vec(); inf[7] = f64::INFINITY; assert!(!is_usable_vector(&inf)); } #[test] fn standardization_means_stay_finite_when_bad_vectors_are_pre_filtered() { // build_index gates every vector through is_usable_vector, so the params // only ever see finite input. Prove the params themselves stay finite and // that a NaN slipping into the *scorer* still cannot panic the sort. let raw = vec![ ("a".to_string(), vec_at(0.0).to_vec()), ("b".to_string(), vec_at(1.0).to_vec()), ]; let (means, stds) = standardization_params(&raw); assert!(means.iter().all(|m| m.is_finite())); assert!(stds.iter().all(|s| s.is_finite() && *s > 0.0)); } #[test] fn score_does_not_panic_on_nan_query() { let db = Database::open_in_memory().unwrap(); insert(&db, "k1", &vec_at(0.0), &["instrument.drum.kick"]); insert(&db, "k2", &vec_at(0.1), &["instrument.drum.kick"]); let index = build_index(&db).unwrap(); // A NaN in the query reaches the distance sort; total_cmp must keep it // from panicking (returns some ordering rather than unwrapping None). let mut q = vec_at(0.02).to_vec(); q[2] = f64::NAN; let _ = index.score(&q, DEFAULT_K, None); // must not panic } #[test] fn auto_applies_dominant_neighbor_tag() { let db = Database::open_in_memory().unwrap(); // A tight cluster of kicks, plus one far-away snare. insert(&db, "k1", &vec_at(0.0), &["instrument.drum.kick"]); insert(&db, "k2", &vec_at(0.05), &["instrument.drum.kick"]); insert(&db, "k3", &vec_at(-0.05), &["instrument.drum.kick"]); insert(&db, "s1", &vec_at(100.0), &["instrument.drum.snare"]); // Unlabeled query right next to the kicks. insert(&db, "q", &vec_at(0.02), &[]); let index = build_index(&db).unwrap(); let outcome = apply_ml_suggestions(&db, "q", &index, DEFAULT_K).unwrap(); let applied: Vec<&str> = outcome .auto_applied .iter() .map(|s| s.tag.as_str()) .collect(); assert!(applied.contains(&"instrument.drum.kick"), "got {applied:?}"); // The applied tag is provenance 'ml'. let prov = crate::rules::sample_tag_provenance(&db, "q").unwrap(); assert!( prov.iter() .any(|(t, s, _)| t == "instrument.drum.kick" && s == "ml") ); // Neighbors are surfaced for explainability. let kick = outcome .auto_applied .iter() .find(|s| s.tag == "instrument.drum.kick") .unwrap(); assert!(!kick.neighbors.is_empty()); } #[test] fn ml_tags_excluded_from_exemplars() { let db = Database::open_in_memory().unwrap(); // One real exemplar + one whose only tag is ML-sourced (must not count as label). insert(&db, "k1", &vec_at(0.0), &["instrument.drum.kick"]); insert(&db, "m1", &vec_at(0.0), &[]); db.transaction_core(|tx| { crate::rules::apply_tag_sourced(&db, tx, "m1", "bogus.ml", "ml").map(|_| ()) }) .unwrap(); let index = build_index(&db).unwrap(); // Only k1 is a labeled exemplar (m1's lone tag is ML). assert_eq!(index.len(), 1); } #[test] fn auto_apply_library_tags_unlabeled() { let db = Database::open_in_memory().unwrap(); insert(&db, "k1", &vec_at(0.0), &["instrument.drum.kick"]); insert(&db, "k2", &vec_at(0.05), &["instrument.drum.kick"]); insert(&db, "k3", &vec_at(-0.05), &["instrument.drum.kick"]); insert(&db, "q", &vec_at(0.02), &[]); // unlabeled, near the kicks let applied = auto_apply_library(&db, DEFAULT_K).unwrap(); assert!(applied >= 1, "expected at least one auto-applied tag"); assert!( crate::tags::get_sample_tags(&db, "q") .unwrap() .contains(&"instrument.drum.kick".to_string()) ); // Exemplars keep their own single tag (not re-suggested to themselves). assert_eq!(crate::tags::get_sample_tags(&db, "k1").unwrap().len(), 1); } #[test] fn imported_layer_exemplars_join_the_index() { let db = Database::open_in_memory().unwrap(); // The user has no labels at all; an imported layer carries the only kicks. let afcl = super::super::afcl::Afcl { manifest: super::super::afcl::AfclManifest { afcl_version: super::super::afcl::AFCL_VERSION, feat_version: FEATURE_VERSION, name: "kicks".into(), description: String::new(), kind: "imported".into(), created_at: 0, license_note: String::new(), exemplar_count: 2, rule_count: 0, policy_count: 0, }, exemplars: vec![ super::super::afcl::AfclExemplar { vector: vec![0.0; NUM_FEATURES], tags: vec!["instrument.drum.kick".into()], }, super::super::afcl::AfclExemplar { vector: vec![0.05; NUM_FEATURES], tags: vec!["instrument.drum.kick".into()], }, ], rules: vec![], policy: vec![], }; super::super::afcl::import(&db, &afcl, None).unwrap(); // A query near the imported kicks gets the kick tag from the imported layer alone. insert(&db, "q", &vec_at(0.02), &[]); let index = build_index(&db).unwrap(); assert_eq!(index.len(), 2, "imported exemplars are in the index"); let scores = index.score(&vec_at(0.02), DEFAULT_K, Some("q")); assert!( scores .iter() .any(|s| s.tag == "instrument.drum.kick" && s.score > 0.5) ); } #[test] fn review_band_between_thresholds() { let db = Database::open_in_memory().unwrap(); // Two equidistant neighbors with different tags -> each ~0.5 score. insert(&db, "a", &vec_at(0.0), &["x.a"]); insert(&db, "b", &vec_at(10.0), &["x.b"]); insert(&db, "q", &vec_at(5.0), &[]); // Lower review threshold so the ~0.5 tags surface for review, never auto. set_policy(&db, "x.a", 0.2, 0.99).unwrap(); set_policy(&db, "x.b", 0.2, 0.99).unwrap(); let index = build_index(&db).unwrap(); let outcome = preview_sample(&db, "q", &index, DEFAULT_K).unwrap(); assert!(outcome.auto_applied.is_empty()); assert!(!outcome.pending_review.is_empty()); } }