Skip to main content

max / audiofiles

Verify classifier syncs across own devices (classifier Phase 5) Cross-crate test: build a classified library on "device A" (sample + feature vector + rule + policy + rule-applied tag/provenance), drain exactly what its sync triggers would push, apply that to a fresh "device B", and confirm the whole classifier rebuilds from synced data alone: - k-NN exemplar index rebuilds from the synced feature vector (no re-analysis, no audio files present) - tag_rules, tag_policy, tag_provenance, and tags all round-trip - manual-sticky 'rule' provenance survives, and re-applying rules on B from synced metadata is a consistent no-op No production changes — the sync wiring landed in Phases 0-3; this proves it end to end. Sync suite 48 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 00:34 UTC
Commit: ebfd5ead4a7ddecd73e54ca014816342807b059e
Parent: ec0f6d3
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 }