max / audiofiles
1 file changed,
+99 insertions,
-0 deletions
| @@ -1441,4 +1441,103 @@ mod tests { | |||
| 1441 | 1441 | let result = apply_delete(conn, "samples", "nonexistent_hash", None); | |
| 1442 | 1442 | assert!(result.is_ok()); | |
| 1443 | 1443 | } | |
| 1444 | + | ||
| 1445 | + | // ── Phase 5: classifier syncs across a user's own devices ── | |
| 1446 | + | ||
| 1447 | + | /// Drain everything a device would push: each unsuppressed changelog row as a | |
| 1448 | + | /// `ChangeEntry`, in insertion order (apply_remote_changes re-orders FK-safely). | |
| 1449 | + | fn drain_changelog(conn: &Connection) -> Vec<ChangeEntry> { | |
| 1450 | + | let mut stmt = conn | |
| 1451 | + | .prepare("SELECT table_name, op, row_id, data FROM sync_changelog ORDER BY id") | |
| 1452 | + | .unwrap(); | |
| 1453 | + | let rows: Vec<(String, String, String, Option<String>)> = stmt | |
| 1454 | + | .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) | |
| 1455 | + | .unwrap() | |
| 1456 | + | .collect::<std::result::Result<Vec<_>, _>>() | |
| 1457 | + | .unwrap(); | |
| 1458 | + | rows.into_iter() | |
| 1459 | + | .map(|(table, op, row_id, data)| ChangeEntry { | |
| 1460 | + | table, | |
| 1461 | + | op: match op.as_str() { | |
| 1462 | + | "INSERT" => ChangeOp::Insert, | |
| 1463 | + | "UPDATE" => ChangeOp::Update, | |
| 1464 | + | _ => ChangeOp::Delete, | |
| 1465 | + | }, | |
| 1466 | + | row_id, | |
| 1467 | + | timestamp: chrono::Utc::now(), | |
| 1468 | + | data: data.and_then(|d| serde_json::from_str(&d).ok()), | |
| 1469 | + | }) | |
| 1470 | + | .collect() | |
| 1471 | + | } | |
| 1472 | + | ||
| 1473 | + | /// Build a classified library on device A, sync it to a fresh device B, and confirm | |
| 1474 | + | /// the whole classifier rebuilds from synced data alone — no audio, no re-analysis. | |
| 1475 | + | #[test] | |
| 1476 | + | fn classifier_rebuilds_on_fresh_device_from_sync() { | |
| 1477 | + | use audiofiles_core::analysis::{classify::FEATURE_VERSION, exemplar}; | |
| 1478 | + | use audiofiles_core::rules::{self, MatchMode, NewRule, RuleAction, RuleCondition, RuleField, RuleOp}; | |
| 1479 | + | ||
| 1480 | + | // --- Device A: a sample with a feature vector, a rule, and a policy. --- | |
| 1481 | + | let a = setup_test_db(); | |
| 1482 | + | clear_changelog(a.conn()); | |
| 1483 | + | insert_sample(a.conn(), "k1", "808 kick.wav", "wav"); | |
| 1484 | + | let vec_json = serde_json::to_string(&vec![0.1f64; 35]).unwrap(); | |
| 1485 | + | a.conn() | |
| 1486 | + | .execute( | |
| 1487 | + | "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES ('k1', ?1, ?2, 0)", | |
| 1488 | + | rusqlite::params![FEATURE_VERSION, vec_json], | |
| 1489 | + | ) | |
| 1490 | + | .unwrap(); | |
| 1491 | + | rules::create_rule( | |
| 1492 | + | &a, | |
| 1493 | + | NewRule { | |
| 1494 | + | name: "kicks".into(), | |
| 1495 | + | enabled: true, | |
| 1496 | + | priority: None, | |
| 1497 | + | match_mode: MatchMode::All, | |
| 1498 | + | conditions: vec![RuleCondition { | |
| 1499 | + | field: RuleField::Name, | |
| 1500 | + | op: RuleOp::Contains, | |
| 1501 | + | value: "kick".into(), | |
| 1502 | + | }], | |
| 1503 | + | actions: vec![RuleAction::AddTag("instrument.drum.kick".into())], | |
| 1504 | + | }, | |
| 1505 | + | ) | |
| 1506 | + | .unwrap(); | |
| 1507 | + | exemplar::set_policy(&a, "instrument.drum.kick", 0.4, 0.8).unwrap(); | |
| 1508 | + | // Apply the rule on A so the tag + 'rule' provenance exist to sync. | |
| 1509 | + | rules::apply_all_rules(&a).unwrap(); | |
| 1510 | + | ||
| 1511 | + | let changes = drain_changelog(a.conn()); | |
| 1512 | + | ||
| 1513 | + | // --- Device B: fresh DB, apply the synced changes (no audio anywhere). --- | |
| 1514 | + | let b = setup_test_db(); | |
| 1515 | + | clear_changelog(b.conn()); | |
| 1516 | + | apply_remote_changes(b.conn(), &changes).unwrap(); | |
| 1517 | + | ||
| 1518 | + | // 1. k-NN index rebuilds from the synced feature vector — no re-analysis. | |
| 1519 | + | let index = exemplar::build_index(&b).unwrap(); | |
| 1520 | + | assert_eq!(index.len(), 1, "synced exemplar should rebuild the k-NN index"); | |
| 1521 | + | ||
| 1522 | + | // 2. Rule synced. | |
| 1523 | + | let synced_rules = rules::list_rules(&b).unwrap(); | |
| 1524 | + | assert_eq!(synced_rules.len(), 1); | |
| 1525 | + | assert_eq!(synced_rules[0].name, "kicks"); | |
| 1526 | + | ||
| 1527 | + | // 3. Policy synced. | |
| 1528 | + | let policy = exemplar::get_policy(&b, "instrument.drum.kick").unwrap(); | |
| 1529 | + | assert!((policy.auto_threshold - 0.8).abs() < 1e-9); | |
| 1530 | + | assert!((policy.review_threshold - 0.4).abs() < 1e-9); | |
| 1531 | + | ||
| 1532 | + | // 4. Tag + provenance synced (rule-sourced, manual-sticky semantics intact). | |
| 1533 | + | assert!(audiofiles_core::tags::get_sample_tags(&b, "k1") | |
| 1534 | + | .unwrap() | |
| 1535 | + | .contains(&"instrument.drum.kick".to_string())); | |
| 1536 | + | let prov = rules::sample_tag_provenance(&b, "k1").unwrap(); | |
| 1537 | + | assert!(prov.iter().any(|(t, s, _)| t == "instrument.drum.kick" && s == "rule")); | |
| 1538 | + | ||
| 1539 | + | // 5. Re-running rules on B (from synced metadata) is consistent — no audio needed. | |
| 1540 | + | let changed = rules::apply_all_rules(&b).unwrap(); | |
| 1541 | + | assert_eq!(changed, 0, "synced state already reconciled; re-apply is a no-op"); | |
| 1542 | + | } | |
| 1444 | 1543 | } |