Skip to main content

max / audiofiles

25.0 KB · 722 lines History Blame Raw
1 //! Exemplar k-NN tag suggestion (Layer B of the hybrid classifier).
2 //!
3 //! The user's labeled library *is* the model: every sample carrying a non-ML tag is an
4 //! exemplar. A query sample is scored by its nearest labeled neighbors in standardized
5 //! feature space; each candidate tag gets a soft, similarity-weighted multi-label score
6 //! in [0, 1]. Per-tag dual thresholds (`tag_policy`) split scores into auto-apply /
7 //! review / ignore.
8 //!
9 //! ML-applied tags (`source = 'ml'`) are excluded from the exemplar labels so the layer
10 //! never trains on its own output. Suggestions carry their driving neighbors for
11 //! explainability ("looks like these kicks you have"). See [`crate::rules`] for provenance.
12
13 use std::collections::HashMap;
14
15 use crate::db::Database;
16 use crate::error::{CoreError, Result};
17 use tracing::instrument;
18
19 use super::classify::{FEATURE_VERSION, NUM_FEATURES};
20
21 /// Default neighbor count.
22 pub const DEFAULT_K: usize = 15;
23 /// Default score at/above which a tag is queued for review.
24 pub const DEFAULT_REVIEW_THRESHOLD: f64 = 0.5;
25 /// Default score at/above which a tag is auto-applied.
26 pub const DEFAULT_AUTO_THRESHOLD: f64 = 0.85;
27
28 // ── Per-tag policy ──
29
30 /// Confidence thresholds for a tag. `auto >= review`; setting them equal collapses to
31 /// binary behaviour (auto-apply above, ignore below, no review band).
32 #[derive(Debug, Clone, Copy, PartialEq)]
33 pub struct TagPolicy {
34 pub review_threshold: f64,
35 pub auto_threshold: f64,
36 }
37
38 impl Default for TagPolicy {
39 fn default() -> Self {
40 Self {
41 review_threshold: DEFAULT_REVIEW_THRESHOLD,
42 auto_threshold: DEFAULT_AUTO_THRESHOLD,
43 }
44 }
45 }
46
47 /// Fetch a tag's policy, or the default if none is configured.
48 #[instrument(skip_all)]
49 pub fn get_policy(db: &Database, tag: &str) -> Result<TagPolicy> {
50 let policy = db
51 .conn()
52 .query_row(
53 "SELECT review_threshold, auto_threshold FROM tag_policy WHERE tag = ?1",
54 [tag],
55 |row| {
56 Ok(TagPolicy {
57 review_threshold: row.get(0)?,
58 auto_threshold: row.get(1)?,
59 })
60 },
61 )
62 .ok()
63 .unwrap_or_default();
64 Ok(policy)
65 }
66
67 /// Set a tag's thresholds. `auto` is clamped to be >= `review`, both into [0, 1].
68 #[instrument(skip_all)]
69 pub fn set_policy(db: &Database, tag: &str, review: f64, auto: f64) -> Result<()> {
70 let review = review.clamp(0.0, 1.0);
71 let auto = auto.clamp(review, 1.0);
72 db.conn().execute(
73 "INSERT INTO tag_policy (tag, review_threshold, auto_threshold) VALUES (?1, ?2, ?3)
74 ON CONFLICT(tag) DO UPDATE SET review_threshold = ?2, auto_threshold = ?3",
75 rusqlite::params![tag, review, auto],
76 )?;
77 Ok(())
78 }
79
80 /// All configured per-tag policies.
81 #[instrument(skip_all)]
82 pub fn list_policies(db: &Database) -> Result<Vec<(String, TagPolicy)>> {
83 let mut stmt = db
84 .conn()
85 .prepare("SELECT tag, review_threshold, auto_threshold FROM tag_policy ORDER BY tag")?;
86 let rows = stmt.query_map([], |row| {
87 Ok((
88 row.get::<_, String>(0)?,
89 TagPolicy {
90 review_threshold: row.get(1)?,
91 auto_threshold: row.get(2)?,
92 },
93 ))
94 })?;
95 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
96 }
97
98 // ── Index + scoring ──
99
100 struct Exemplar {
101 hash: String,
102 /// Standardized feature vector.
103 vector: Vec<f64>,
104 tags: Vec<String>,
105 /// Layer weight multiplier on this exemplar's k-NN contribution. `1.0` for the user's
106 /// own (`local`) data; imported `.afcl` layers sit below it (see [`super::afcl`]).
107 weight: f64,
108 }
109
110 /// In-memory k-NN index over the labeled library. Rebuild after tag changes.
111 pub struct ExemplarIndex {
112 means: Vec<f64>,
113 stds: Vec<f64>,
114 exemplars: Vec<Exemplar>,
115 pub feature_version: u32,
116 }
117
118 /// A neighbor that contributed to a tag's score.
119 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
120 pub struct NeighborContribution {
121 pub hash: String,
122 pub weight: f64,
123 }
124
125 /// A candidate tag with its similarity-weighted score and the neighbors behind it.
126 #[derive(Debug, Clone)]
127 pub struct TagScore {
128 pub tag: String,
129 pub score: f64,
130 pub neighbors: Vec<NeighborContribution>,
131 }
132
133 /// Whether a scored tag should be auto-applied or queued for review.
134 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
135 pub enum MlAction {
136 AutoApply,
137 Review,
138 }
139
140 /// A suggestion with its disposition under the per-tag policy.
141 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142 pub struct MlSuggestion {
143 pub tag: String,
144 pub score: f64,
145 pub action: MlAction,
146 pub neighbors: Vec<NeighborContribution>,
147 }
148
149 /// Auto-applied vs review-queued suggestions for a sample.
150 #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
151 pub struct MlOutcome {
152 pub auto_applied: Vec<MlSuggestion>,
153 pub pending_review: Vec<MlSuggestion>,
154 }
155
156 /// Build the index from every sample carrying at least one non-ML tag.
157 #[instrument(skip_all)]
158 pub fn build_index(db: &Database) -> Result<ExemplarIndex> {
159 let conn = db.conn();
160
161 // Non-ML tags per sample (the trusted labels).
162 let mut tags_by_hash: HashMap<String, Vec<String>> = HashMap::new();
163 {
164 let mut stmt = conn.prepare(
165 "SELECT t.sample_hash, t.tag FROM tags t
166 WHERE NOT EXISTS (
167 SELECT 1 FROM tag_provenance p
168 WHERE p.sample_hash = t.sample_hash AND p.tag = t.tag AND p.source = 'ml')",
169 )?;
170 let rows = stmt.query_map([], |row| {
171 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
172 })?;
173 for r in rows {
174 let (hash, tag) = r?;
175 tags_by_hash.entry(hash).or_default().push(tag);
176 }
177 }
178
179 // Raw vectors for those samples (the user's own `local` exemplars; weight 1.0).
180 let mut raw: Vec<(String, Vec<f64>, Vec<String>, f64)> = Vec::new();
181 {
182 let mut stmt =
183 conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?;
184 let rows = stmt.query_map([FEATURE_VERSION], |row| {
185 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
186 })?;
187 for r in rows {
188 let (hash, json) = r?;
189 let Some(tags) = tags_by_hash.remove(&hash) else {
190 continue;
191 };
192 let v: Vec<f64> =
193 serde_json::from_str(&json).map_err(|e| CoreError::Serialization(e.to_string()))?;
194 if is_usable_vector(&v) {
195 raw.push((hash, v, tags, 1.0));
196 }
197 }
198 }
199
200 // Imported `.afcl` layer exemplars (enabled layers), weighted below `local`.
201 // A single non-finite component would poison the standardization means for the
202 // whole set, so a malformed vector (crafted `.afcl`, a legacy row, or one
203 // synced from another device) is dropped here as well as at `.afcl` import.
204 for imp in super::afcl::enabled_imported_exemplars(db)? {
205 if is_usable_vector(&imp.vector) {
206 raw.push((imp.id, imp.vector, imp.tags, imp.weight));
207 }
208 }
209
210 // Standardization params over the full exemplar set (local + imported).
211 let raw_vecs: Vec<(String, Vec<f64>)> = raw
212 .iter()
213 .map(|(h, v, _, _)| (h.clone(), v.clone()))
214 .collect();
215 let (means, stds) = standardization_params(&raw_vecs);
216 let exemplars = raw
217 .into_iter()
218 .map(|(hash, v, tags, weight)| {
219 let vector = standardize_vec(&v, &means, &stds);
220 Exemplar {
221 hash,
222 vector,
223 tags,
224 weight,
225 }
226 })
227 .collect();
228
229 Ok(ExemplarIndex {
230 means,
231 stds,
232 exemplars,
233 feature_version: FEATURE_VERSION,
234 })
235 }
236
237 /// A feature vector is usable in the k-NN index only if it has the expected
238 /// dimension and every component is finite. Non-finite components would poison
239 /// the standardization means and reach the distance-sort comparators.
240 fn is_usable_vector(v: &[f64]) -> bool {
241 v.len() == NUM_FEATURES && v.iter().all(|x| x.is_finite())
242 }
243
244 fn standardization_params(raw: &[(String, Vec<f64>)]) -> (Vec<f64>, Vec<f64>) {
245 if raw.is_empty() {
246 return (vec![0.0; NUM_FEATURES], vec![1.0; NUM_FEATURES]);
247 }
248 let n = raw.len() as f64;
249 let dims = raw[0].1.len();
250 let mut means = vec![0.0; dims];
251 let mut stds = vec![1.0; dims];
252 for d in 0..dims {
253 let mean = raw.iter().map(|(_, v)| v[d]).sum::<f64>() / n;
254 let var = raw.iter().map(|(_, v)| (v[d] - mean).powi(2)).sum::<f64>() / n;
255 means[d] = mean;
256 let s = var.sqrt();
257 stds[d] = if s > f64::EPSILON { s } else { 1.0 };
258 }
259 (means, stds)
260 }
261
262 pub(crate) fn standardize_vec(v: &[f64], means: &[f64], stds: &[f64]) -> Vec<f64> {
263 v.iter()
264 .zip(means)
265 .zip(stds)
266 .map(|((x, m), s)| (x - m) / s)
267 .collect()
268 }
269
270 fn sq_dist(a: &[f64], b: &[f64]) -> f64 {
271 a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum()
272 }
273
274 impl ExemplarIndex {
275 pub fn len(&self) -> usize {
276 self.exemplars.len()
277 }
278
279 pub fn is_empty(&self) -> bool {
280 self.exemplars.is_empty()
281 }
282
283 /// Standardization mean per feature dimension (over the exemplar set).
284 pub(crate) fn means(&self) -> &[f64] {
285 &self.means
286 }
287
288 /// Standardization std-dev per feature dimension (over the exemplar set).
289 pub(crate) fn stds(&self) -> &[f64] {
290 &self.stds
291 }
292
293 /// Iterate `(standardized vector, tags)` over every exemplar, the training set
294 /// for the distilled head ([`super::trained_head`]).
295 pub(crate) fn rows(&self) -> impl Iterator<Item = (&[f64], &[String])> {
296 self.exemplars
297 .iter()
298 .map(|e| (e.vector.as_slice(), e.tags.as_slice()))
299 }
300
301 /// Score candidate tags for a raw (un-standardized) query vector. `exclude_hash`
302 /// drops the query's own exemplar (when scoring an already-labeled sample).
303 pub fn score(&self, raw_query: &[f64], k: usize, exclude_hash: Option<&str>) -> Vec<TagScore> {
304 if self.exemplars.is_empty() || raw_query.len() != NUM_FEATURES || k == 0 {
305 return Vec::new();
306 }
307 let q = standardize_vec(raw_query, &self.means, &self.stds);
308
309 // Distances to all eligible exemplars.
310 let mut dists: Vec<(usize, f64)> = self
311 .exemplars
312 .iter()
313 .enumerate()
314 .filter(|(_, e)| exclude_hash != Some(e.hash.as_str()))
315 .map(|(i, e)| (i, sq_dist(&q, &e.vector)))
316 .collect();
317 if dists.is_empty() {
318 return Vec::new();
319 }
320 // total_cmp never panics on NaN (unlike partial_cmp().unwrap()); the
321 // build_index finiteness filter should keep NaN out, this is the backstop.
322 dists.sort_by(|a, b| a.1.total_cmp(&b.1));
323 dists.truncate(k);
324
325 // Adaptive Gaussian bandwidth = mean neighbor distance.
326 let mean_d = (dists.iter().map(|(_, d2)| d2.sqrt()).sum::<f64>() / dists.len() as f64)
327 .max(f64::EPSILON);
328 let denom = 2.0 * mean_d * mean_d;
329
330 let mut total_weight = 0.0;
331 let weighted: Vec<(usize, f64)> = dists
332 .iter()
333 .map(|&(i, d2)| {
334 // Gaussian kernel scaled by the exemplar's layer weight (imported < local).
335 let w = (-d2 / denom).exp() * self.exemplars[i].weight;
336 total_weight += w;
337 (i, w)
338 })
339 .collect();
340 if total_weight <= f64::EPSILON {
341 return Vec::new();
342 }
343
344 // Accumulate per-tag weight + contributing neighbors.
345 let mut acc: HashMap<&str, (f64, Vec<NeighborContribution>)> = HashMap::new();
346 for &(i, w) in &weighted {
347 let e = &self.exemplars[i];
348 for tag in &e.tags {
349 let slot = acc.entry(tag.as_str()).or_insert((0.0, Vec::new()));
350 slot.0 += w;
351 slot.1.push(NeighborContribution {
352 hash: e.hash.clone(),
353 weight: w,
354 });
355 }
356 }
357
358 let mut scores: Vec<TagScore> = acc
359 .into_iter()
360 .map(|(tag, (sum_w, neighbors))| TagScore {
361 tag: tag.to_string(),
362 score: sum_w / total_weight,
363 neighbors,
364 })
365 .collect();
366 scores.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.tag.cmp(&b.tag)));
367 scores
368 }
369 }
370
371 /// Load a sample's raw feature vector (current version), if present.
372 pub(crate) fn load_query_vector(db: &Database, hash: &str) -> Result<Option<Vec<f64>>> {
373 let json: Option<String> = db
374 .conn()
375 .query_row(
376 "SELECT vector FROM sample_features WHERE hash = ?1 AND feat_version = ?2",
377 rusqlite::params![hash, FEATURE_VERSION],
378 |row| row.get(0),
379 )
380 .ok();
381 match json {
382 Some(j) => {
383 let v: Vec<f64> =
384 serde_json::from_str(&j).map_err(|e| CoreError::Serialization(e.to_string()))?;
385 Ok(Some(v))
386 }
387 None => Ok(None),
388 }
389 }
390
391 /// Split scored tags into auto-apply / review under each tag's policy, dropping tags the
392 /// sample already has and anything below its review threshold.
393 pub(crate) fn apply_policy(
394 db: &Database,
395 scores: Vec<TagScore>,
396 existing: &std::collections::HashSet<String>,
397 ) -> Result<MlOutcome> {
398 let mut outcome = MlOutcome::default();
399 for s in scores {
400 if existing.contains(&s.tag) {
401 continue;
402 }
403 let policy = get_policy(db, &s.tag)?;
404 if s.score >= policy.auto_threshold {
405 outcome.auto_applied.push(MlSuggestion {
406 tag: s.tag,
407 score: s.score,
408 action: MlAction::AutoApply,
409 neighbors: s.neighbors,
410 });
411 } else if s.score >= policy.review_threshold {
412 outcome.pending_review.push(MlSuggestion {
413 tag: s.tag,
414 score: s.score,
415 action: MlAction::Review,
416 neighbors: s.neighbors,
417 });
418 }
419 }
420 Ok(outcome)
421 }
422
423 pub(crate) fn sample_tag_set(
424 db: &Database,
425 hash: &str,
426 ) -> Result<std::collections::HashSet<String>> {
427 Ok(crate::tags::get_sample_tags(db, hash)?
428 .into_iter()
429 .collect())
430 }
431
432 /// Score a stored sample against the index without writing anything.
433 #[instrument(skip_all)]
434 pub fn preview_sample(
435 db: &Database,
436 hash: &str,
437 index: &ExemplarIndex,
438 k: usize,
439 ) -> Result<MlOutcome> {
440 let Some(raw) = load_query_vector(db, hash)? else {
441 return Ok(MlOutcome::default());
442 };
443 let scores = index.score(&raw, k, Some(hash));
444 let existing = sample_tag_set(db, hash)?;
445 apply_policy(db, scores, &existing)
446 }
447
448 /// Score a stored sample and **auto-apply** the above-threshold tags (`source = 'ml'`),
449 /// returning what was applied and what's pending review.
450 #[instrument(skip_all)]
451 pub fn apply_ml_suggestions(
452 db: &Database,
453 hash: &str,
454 index: &ExemplarIndex,
455 k: usize,
456 ) -> Result<MlOutcome> {
457 let outcome = preview_sample(db, hash, index, k)?;
458 db.transaction_core(|tx| {
459 for s in &outcome.auto_applied {
460 crate::rules::apply_tag_sourced(db, tx, hash, &s.tag, "ml")?;
461 }
462 Ok(())
463 })?;
464 Ok(outcome)
465 }
466
467 /// Build the index once and auto-apply above-threshold tags to every analyzed,
468 /// non-deleted sample. Returns the total number of tags applied. Heavy, intended
469 /// for an explicit "suggest across library" action.
470 #[instrument(skip_all)]
471 pub fn auto_apply_library(db: &Database, k: usize) -> Result<usize> {
472 let index = build_index(db)?;
473 if index.is_empty() {
474 return Ok(0);
475 }
476 let hashes: Vec<String> = {
477 let mut stmt = db.conn().prepare(
478 "SELECT s.hash FROM live_samples s
479 JOIN sample_features f ON f.hash = s.hash AND f.feat_version = ?1",
480 )?;
481 stmt.query_map([FEATURE_VERSION], |row| row.get(0))?
482 .collect::<std::result::Result<Vec<_>, _>>()?
483 };
484 let mut applied = 0;
485 for hash in hashes {
486 applied += apply_ml_suggestions(db, &hash, &index, k)?
487 .auto_applied
488 .len();
489 }
490 Ok(applied)
491 }
492
493 #[cfg(test)]
494 mod tests {
495 use super::*;
496
497 fn insert(db: &Database, hash: &str, vec: &[f64; NUM_FEATURES], tags: &[&str]) {
498 db.conn()
499 .execute(
500 "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
501 VALUES (?1, ?1, 'wav', 1, 0, 0)",
502 [hash],
503 )
504 .unwrap();
505 let json = serde_json::to_string(&vec.to_vec()).unwrap();
506 db.conn()
507 .execute(
508 "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)",
509 rusqlite::params![hash, FEATURE_VERSION, json],
510 )
511 .unwrap();
512 for t in tags {
513 crate::tags::add_tag(db, hash, t).unwrap();
514 }
515 }
516
517 fn vec_at(base: f64) -> [f64; NUM_FEATURES] {
518 [base; NUM_FEATURES]
519 }
520
521 #[test]
522 #[allow(
523 clippy::float_cmp,
524 reason = "exact equality on deterministic test values"
525 )]
526 fn policy_defaults_and_clamping() {
527 let db = Database::open_in_memory().unwrap();
528 assert_eq!(get_policy(&db, "x").unwrap(), TagPolicy::default());
529 // auto clamped to >= review.
530 set_policy(&db, "x", 0.7, 0.3).unwrap();
531 let p = get_policy(&db, "x").unwrap();
532 assert_eq!(p.review_threshold, 0.7);
533 assert_eq!(p.auto_threshold, 0.7);
534 }
535
536 #[test]
537 fn empty_index_yields_nothing() {
538 let db = Database::open_in_memory().unwrap();
539 let index = build_index(&db).unwrap();
540 assert!(index.is_empty());
541 insert(&db, "q", &vec_at(0.0), &[]);
542 assert!(
543 apply_ml_suggestions(&db, "q", &index, DEFAULT_K)
544 .unwrap()
545 .auto_applied
546 .is_empty()
547 );
548 }
549
550 #[test]
551 fn is_usable_vector_rejects_non_finite_and_wrong_len() {
552 assert!(is_usable_vector(&vec_at(0.5)));
553 // Wrong dimension.
554 assert!(!is_usable_vector(&[0.0; NUM_FEATURES - 1]));
555 // Any non-finite component disqualifies it (would poison the means).
556 let mut nan = vec_at(0.1).to_vec();
557 nan[3] = f64::NAN;
558 assert!(!is_usable_vector(&nan));
559 let mut inf = vec_at(0.1).to_vec();
560 inf[7] = f64::INFINITY;
561 assert!(!is_usable_vector(&inf));
562 }
563
564 #[test]
565 fn standardization_means_stay_finite_when_bad_vectors_are_pre_filtered() {
566 // build_index gates every vector through is_usable_vector, so the params
567 // only ever see finite input. Prove the params themselves stay finite and
568 // that a NaN slipping into the *scorer* still cannot panic the sort.
569 let raw = vec![
570 ("a".to_string(), vec_at(0.0).to_vec()),
571 ("b".to_string(), vec_at(1.0).to_vec()),
572 ];
573 let (means, stds) = standardization_params(&raw);
574 assert!(means.iter().all(|m| m.is_finite()));
575 assert!(stds.iter().all(|s| s.is_finite() && *s > 0.0));
576 }
577
578 #[test]
579 fn score_does_not_panic_on_nan_query() {
580 let db = Database::open_in_memory().unwrap();
581 insert(&db, "k1", &vec_at(0.0), &["instrument.drum.kick"]);
582 insert(&db, "k2", &vec_at(0.1), &["instrument.drum.kick"]);
583 let index = build_index(&db).unwrap();
584
585 // A NaN in the query reaches the distance sort; total_cmp must keep it
586 // from panicking (returns some ordering rather than unwrapping None).
587 let mut q = vec_at(0.02).to_vec();
588 q[2] = f64::NAN;
589 let _ = index.score(&q, DEFAULT_K, None); // must not panic
590 }
591
592 #[test]
593 fn auto_applies_dominant_neighbor_tag() {
594 let db = Database::open_in_memory().unwrap();
595 // A tight cluster of kicks, plus one far-away snare.
596 insert(&db, "k1", &vec_at(0.0), &["instrument.drum.kick"]);
597 insert(&db, "k2", &vec_at(0.05), &["instrument.drum.kick"]);
598 insert(&db, "k3", &vec_at(-0.05), &["instrument.drum.kick"]);
599 insert(&db, "s1", &vec_at(100.0), &["instrument.drum.snare"]);
600 // Unlabeled query right next to the kicks.
601 insert(&db, "q", &vec_at(0.02), &[]);
602
603 let index = build_index(&db).unwrap();
604 let outcome = apply_ml_suggestions(&db, "q", &index, DEFAULT_K).unwrap();
605 let applied: Vec<&str> = outcome
606 .auto_applied
607 .iter()
608 .map(|s| s.tag.as_str())
609 .collect();
610 assert!(applied.contains(&"instrument.drum.kick"), "got {applied:?}");
611 // The applied tag is provenance 'ml'.
612 let prov = crate::rules::sample_tag_provenance(&db, "q").unwrap();
613 assert!(
614 prov.iter()
615 .any(|(t, s, _)| t == "instrument.drum.kick" && s == "ml")
616 );
617 // Neighbors are surfaced for explainability.
618 let kick = outcome
619 .auto_applied
620 .iter()
621 .find(|s| s.tag == "instrument.drum.kick")
622 .unwrap();
623 assert!(!kick.neighbors.is_empty());
624 }
625
626 #[test]
627 fn ml_tags_excluded_from_exemplars() {
628 let db = Database::open_in_memory().unwrap();
629 // One real exemplar + one whose only tag is ML-sourced (must not count as label).
630 insert(&db, "k1", &vec_at(0.0), &["instrument.drum.kick"]);
631 insert(&db, "m1", &vec_at(0.0), &[]);
632 db.transaction_core(|tx| {
633 crate::rules::apply_tag_sourced(&db, tx, "m1", "bogus.ml", "ml").map(|_| ())
634 })
635 .unwrap();
636
637 let index = build_index(&db).unwrap();
638 // Only k1 is a labeled exemplar (m1's lone tag is ML).
639 assert_eq!(index.len(), 1);
640 }
641
642 #[test]
643 fn auto_apply_library_tags_unlabeled() {
644 let db = Database::open_in_memory().unwrap();
645 insert(&db, "k1", &vec_at(0.0), &["instrument.drum.kick"]);
646 insert(&db, "k2", &vec_at(0.05), &["instrument.drum.kick"]);
647 insert(&db, "k3", &vec_at(-0.05), &["instrument.drum.kick"]);
648 insert(&db, "q", &vec_at(0.02), &[]); // unlabeled, near the kicks
649
650 let applied = auto_apply_library(&db, DEFAULT_K).unwrap();
651 assert!(applied >= 1, "expected at least one auto-applied tag");
652 assert!(
653 crate::tags::get_sample_tags(&db, "q")
654 .unwrap()
655 .contains(&"instrument.drum.kick".to_string())
656 );
657 // Exemplars keep their own single tag (not re-suggested to themselves).
658 assert_eq!(crate::tags::get_sample_tags(&db, "k1").unwrap().len(), 1);
659 }
660
661 #[test]
662 fn imported_layer_exemplars_join_the_index() {
663 let db = Database::open_in_memory().unwrap();
664 // The user has no labels at all; an imported layer carries the only kicks.
665 let afcl = super::super::afcl::Afcl {
666 manifest: super::super::afcl::AfclManifest {
667 afcl_version: super::super::afcl::AFCL_VERSION,
668 feat_version: FEATURE_VERSION,
669 name: "kicks".into(),
670 description: String::new(),
671 kind: "imported".into(),
672 created_at: 0,
673 license_note: String::new(),
674 exemplar_count: 2,
675 rule_count: 0,
676 policy_count: 0,
677 },
678 exemplars: vec![
679 super::super::afcl::AfclExemplar {
680 vector: vec![0.0; NUM_FEATURES],
681 tags: vec!["instrument.drum.kick".into()],
682 },
683 super::super::afcl::AfclExemplar {
684 vector: vec![0.05; NUM_FEATURES],
685 tags: vec!["instrument.drum.kick".into()],
686 },
687 ],
688 rules: vec![],
689 policy: vec![],
690 };
691 super::super::afcl::import(&db, &afcl, None).unwrap();
692
693 // A query near the imported kicks gets the kick tag from the imported layer alone.
694 insert(&db, "q", &vec_at(0.02), &[]);
695 let index = build_index(&db).unwrap();
696 assert_eq!(index.len(), 2, "imported exemplars are in the index");
697 let scores = index.score(&vec_at(0.02), DEFAULT_K, Some("q"));
698 assert!(
699 scores
700 .iter()
701 .any(|s| s.tag == "instrument.drum.kick" && s.score > 0.5)
702 );
703 }
704
705 #[test]
706 fn review_band_between_thresholds() {
707 let db = Database::open_in_memory().unwrap();
708 // Two equidistant neighbors with different tags -> each ~0.5 score.
709 insert(&db, "a", &vec_at(0.0), &["x.a"]);
710 insert(&db, "b", &vec_at(10.0), &["x.b"]);
711 insert(&db, "q", &vec_at(5.0), &[]);
712 // Lower review threshold so the ~0.5 tags surface for review, never auto.
713 set_policy(&db, "x.a", 0.2, 0.99).unwrap();
714 set_policy(&db, "x.b", 0.2, 0.99).unwrap();
715
716 let index = build_index(&db).unwrap();
717 let outcome = preview_sample(&db, "q", &index, DEFAULT_K).unwrap();
718 assert!(outcome.auto_applied.is_empty());
719 assert!(!outcome.pending_review.is_empty());
720 }
721 }
722