| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 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 |
|
| 22 |
pub const DEFAULT_K: usize = 15; |
| 23 |
|
| 24 |
pub const DEFAULT_REVIEW_THRESHOLD: f64 = 0.5; |
| 25 |
|
| 26 |
pub const DEFAULT_AUTO_THRESHOLD: f64 = 0.85; |
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 99 |
|
| 100 |
struct Exemplar { |
| 101 |
hash: String, |
| 102 |
|
| 103 |
vector: Vec<f64>, |
| 104 |
tags: Vec<String>, |
| 105 |
|
| 106 |
|
| 107 |
weight: f64, |
| 108 |
} |
| 109 |
|
| 110 |
|
| 111 |
pub struct ExemplarIndex { |
| 112 |
means: Vec<f64>, |
| 113 |
stds: Vec<f64>, |
| 114 |
exemplars: Vec<Exemplar>, |
| 115 |
pub feature_version: u32, |
| 116 |
} |
| 117 |
|
| 118 |
|
| 119 |
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] |
| 120 |
pub struct NeighborContribution { |
| 121 |
pub hash: String, |
| 122 |
pub weight: f64, |
| 123 |
} |
| 124 |
|
| 125 |
|
| 126 |
#[derive(Debug, Clone)] |
| 127 |
pub struct TagScore { |
| 128 |
pub tag: String, |
| 129 |
pub score: f64, |
| 130 |
pub neighbors: Vec<NeighborContribution>, |
| 131 |
} |
| 132 |
|
| 133 |
|
| 134 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 135 |
pub enum MlAction { |
| 136 |
AutoApply, |
| 137 |
Review, |
| 138 |
} |
| 139 |
|
| 140 |
|
| 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 |
|
| 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 |
|
| 157 |
#[instrument(skip_all)] |
| 158 |
pub fn build_index(db: &Database) -> Result<ExemplarIndex> { |
| 159 |
let conn = db.conn(); |
| 160 |
|
| 161 |
|
| 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 |
|
| 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 |
|
| 201 |
|
| 202 |
|
| 203 |
|
| 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 |
|
| 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 |
|
| 238 |
|
| 239 |
|
| 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 |
|
| 284 |
pub(crate) fn means(&self) -> &[f64] { |
| 285 |
&self.means |
| 286 |
} |
| 287 |
|
| 288 |
|
| 289 |
pub(crate) fn stds(&self) -> &[f64] { |
| 290 |
&self.stds |
| 291 |
} |
| 292 |
|
| 293 |
|
| 294 |
|
| 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 |
|
| 302 |
|
| 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 |
|
| 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 |
|
| 321 |
|
| 322 |
dists.sort_by(|a, b| a.1.total_cmp(&b.1)); |
| 323 |
dists.truncate(k); |
| 324 |
|
| 325 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 392 |
|
| 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 |
|
| 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 |
|
| 449 |
|
| 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 |
|
| 468 |
|
| 469 |
|
| 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 |
|
| 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 |
|
| 554 |
assert!(!is_usable_vector(&[0.0; NUM_FEATURES - 1])); |
| 555 |
|
| 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 |
|
| 567 |
|
| 568 |
|
| 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 |
|
| 586 |
|
| 587 |
let mut q = vec_at(0.02).to_vec(); |
| 588 |
q[2] = f64::NAN; |
| 589 |
let _ = index.score(&q, DEFAULT_K, None); |
| 590 |
} |
| 591 |
|
| 592 |
#[test] |
| 593 |
fn auto_applies_dominant_neighbor_tag() { |
| 594 |
let db = Database::open_in_memory().unwrap(); |
| 595 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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), &[]); |
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|