max / audiofiles
6 files changed,
+708 insertions,
-12 deletions
| @@ -1131,12 +1131,14 @@ CREATE TABLE IF NOT EXISTS sample_features ( | |||
| 1131 | 1131 | ); | |
| 1132 | 1132 | CREATE INDEX IF NOT EXISTS idx_sample_features_version ON sample_features(feat_version); | |
| 1133 | 1133 | ||
| 1134 | - | -- Sync triggers (mirror audio_analysis: cleartext hash row_id, JSON payload). | |
| 1134 | + | -- Sync triggers (mirror audio_analysis: hashed row_id so the sample SHA-256 never | |
| 1135 | + | -- goes on the wire; DELETE carries the canonical key in `data`). | |
| 1135 | 1136 | CREATE TRIGGER IF NOT EXISTS sync_sample_features_insert AFTER INSERT ON sample_features | |
| 1136 | 1137 | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1137 | 1138 | BEGIN | |
| 1138 | 1139 | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1139 | - | VALUES ('sample_features', 'INSERT', NEW.hash, | |
| 1140 | + | VALUES ('sample_features', 'INSERT', | |
| 1141 | + | hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), NEW.hash), | |
| 1140 | 1142 | json_object('hash', NEW.hash, 'feat_version', NEW.feat_version, | |
| 1141 | 1143 | 'vector', NEW.vector, 'computed_at', NEW.computed_at)); | |
| 1142 | 1144 | END; | |
| @@ -1145,7 +1147,8 @@ CREATE TRIGGER IF NOT EXISTS sync_sample_features_update AFTER UPDATE ON sample_ | |||
| 1145 | 1147 | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1146 | 1148 | BEGIN | |
| 1147 | 1149 | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1148 | - | VALUES ('sample_features', 'UPDATE', NEW.hash, | |
| 1150 | + | VALUES ('sample_features', 'UPDATE', | |
| 1151 | + | hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), NEW.hash), | |
| 1149 | 1152 | json_object('hash', NEW.hash, 'feat_version', NEW.feat_version, | |
| 1150 | 1153 | 'vector', NEW.vector, 'computed_at', NEW.computed_at)); | |
| 1151 | 1154 | END; | |
| @@ -1154,7 +1157,102 @@ CREATE TRIGGER IF NOT EXISTS sync_sample_features_delete AFTER DELETE ON sample_ | |||
| 1154 | 1157 | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1155 | 1158 | BEGIN | |
| 1156 | 1159 | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1157 | - | VALUES ('sample_features', 'DELETE', OLD.hash, NULL); | |
| 1160 | + | VALUES ('sample_features', 'DELETE', | |
| 1161 | + | hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), OLD.hash), | |
| 1162 | + | json_object('hash', OLD.hash)); | |
| 1163 | + | END; | |
| 1164 | + | "#; | |
| 1165 | + | ||
| 1166 | + | const MIGRATION_021: &str = r#" | |
| 1167 | + | -- Phase 1 of the hybrid tag classifier: deterministic tag rules (Layer A). | |
| 1168 | + | -- Ordered IF/THEN rules over sample metadata + DSP features. Ships empty. | |
| 1169 | + | CREATE TABLE IF NOT EXISTS tag_rules ( | |
| 1170 | + | id TEXT PRIMARY KEY, | |
| 1171 | + | name TEXT NOT NULL, | |
| 1172 | + | enabled INTEGER NOT NULL DEFAULT 1, | |
| 1173 | + | priority INTEGER NOT NULL, | |
| 1174 | + | match_mode TEXT NOT NULL, | |
| 1175 | + | conditions TEXT NOT NULL, | |
| 1176 | + | actions TEXT NOT NULL, | |
| 1177 | + | created_at INTEGER NOT NULL | |
| 1178 | + | ); | |
| 1179 | + | CREATE INDEX IF NOT EXISTS idx_tag_rules_priority ON tag_rules(priority); | |
| 1180 | + | ||
| 1181 | + | -- Sync triggers (opaque non-sensitive id => cleartext row_id, like collections). | |
| 1182 | + | CREATE TRIGGER IF NOT EXISTS sync_tag_rules_insert AFTER INSERT ON tag_rules | |
| 1183 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1184 | + | BEGIN | |
| 1185 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1186 | + | VALUES ('tag_rules', 'INSERT', NEW.id, | |
| 1187 | + | json_object('id', NEW.id, 'name', NEW.name, 'enabled', NEW.enabled, | |
| 1188 | + | 'priority', NEW.priority, 'match_mode', NEW.match_mode, | |
| 1189 | + | 'conditions', NEW.conditions, 'actions', NEW.actions, | |
| 1190 | + | 'created_at', NEW.created_at)); | |
| 1191 | + | END; | |
| 1192 | + | ||
| 1193 | + | CREATE TRIGGER IF NOT EXISTS sync_tag_rules_update AFTER UPDATE ON tag_rules | |
| 1194 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1195 | + | BEGIN | |
| 1196 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1197 | + | VALUES ('tag_rules', 'UPDATE', NEW.id, | |
| 1198 | + | json_object('id', NEW.id, 'name', NEW.name, 'enabled', NEW.enabled, | |
| 1199 | + | 'priority', NEW.priority, 'match_mode', NEW.match_mode, | |
| 1200 | + | 'conditions', NEW.conditions, 'actions', NEW.actions, | |
| 1201 | + | 'created_at', NEW.created_at)); | |
| 1202 | + | END; | |
| 1203 | + | ||
| 1204 | + | CREATE TRIGGER IF NOT EXISTS sync_tag_rules_delete AFTER DELETE ON tag_rules | |
| 1205 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1206 | + | BEGIN | |
| 1207 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1208 | + | VALUES ('tag_rules', 'DELETE', OLD.id, json_object('id', OLD.id)); | |
| 1209 | + | END; | |
| 1210 | + | "#; | |
| 1211 | + | ||
| 1212 | + | const MIGRATION_022: &str = r#" | |
| 1213 | + | -- Phase 1: tag provenance. Records which machine source applied each tag so | |
| 1214 | + | -- manual tags stay sticky (a tag with NO row here is manual). Reconciliation | |
| 1215 | + | -- only ever touches rule-sourced tags. | |
| 1216 | + | CREATE TABLE IF NOT EXISTS tag_provenance ( | |
| 1217 | + | sample_hash TEXT NOT NULL REFERENCES samples(hash) ON DELETE CASCADE, | |
| 1218 | + | tag TEXT NOT NULL, | |
| 1219 | + | source TEXT NOT NULL, -- 'rule' | 'ml' | 'cluster' (manual = no row) | |
| 1220 | + | rule_id TEXT, -- tag_rules.id when source = 'rule' | |
| 1221 | + | PRIMARY KEY (sample_hash, tag) | |
| 1222 | + | ); | |
| 1223 | + | ||
| 1224 | + | -- Sync triggers (composite PK with sensitive sample_hash + tag => hashed row_id, | |
| 1225 | + | -- mirroring tags; UPDATE supported because reconciliation re-stamps source/rule_id). | |
| 1226 | + | CREATE TRIGGER IF NOT EXISTS sync_tag_provenance_insert AFTER INSERT ON tag_provenance | |
| 1227 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1228 | + | BEGIN | |
| 1229 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1230 | + | VALUES ('tag_provenance', 'INSERT', | |
| 1231 | + | hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), | |
| 1232 | + | NEW.sample_hash || ':' || NEW.tag), | |
| 1233 | + | json_object('sample_hash', NEW.sample_hash, 'tag', NEW.tag, | |
| 1234 | + | 'source', NEW.source, 'rule_id', NEW.rule_id)); | |
| 1235 | + | END; | |
| 1236 | + | ||
| 1237 | + | CREATE TRIGGER IF NOT EXISTS sync_tag_provenance_update AFTER UPDATE ON tag_provenance | |
| 1238 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1239 | + | BEGIN | |
| 1240 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1241 | + | VALUES ('tag_provenance', 'UPDATE', | |
| 1242 | + | hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), | |
| 1243 | + | NEW.sample_hash || ':' || NEW.tag), | |
| 1244 | + | json_object('sample_hash', NEW.sample_hash, 'tag', NEW.tag, | |
| 1245 | + | 'source', NEW.source, 'rule_id', NEW.rule_id)); | |
| 1246 | + | END; | |
| 1247 | + | ||
| 1248 | + | CREATE TRIGGER IF NOT EXISTS sync_tag_provenance_delete AFTER DELETE ON tag_provenance | |
| 1249 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 1250 | + | BEGIN | |
| 1251 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 1252 | + | VALUES ('tag_provenance', 'DELETE', | |
| 1253 | + | hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), | |
| 1254 | + | OLD.sample_hash || ':' || OLD.tag), | |
| 1255 | + | json_object('sample_hash', OLD.sample_hash, 'tag', OLD.tag)); | |
| 1158 | 1256 | END; | |
| 1159 | 1257 | "#; | |
| 1160 | 1258 | ||
| @@ -1258,6 +1356,8 @@ impl Database { | |||
| 1258 | 1356 | MIGRATION_018, | |
| 1259 | 1357 | MIGRATION_019, | |
| 1260 | 1358 | MIGRATION_020, | |
| 1359 | + | MIGRATION_021, | |
| 1360 | + | MIGRATION_022, | |
| 1261 | 1361 | ]; | |
| 1262 | 1362 | ||
| 1263 | 1363 | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| @@ -1419,6 +1519,8 @@ mod tests { | |||
| 1419 | 1519 | "samples", | |
| 1420 | 1520 | "sync_changelog", | |
| 1421 | 1521 | "sync_state", | |
| 1522 | + | "tag_provenance", | |
| 1523 | + | "tag_rules", | |
| 1422 | 1524 | "tags", | |
| 1423 | 1525 | "user_config", | |
| 1424 | 1526 | "vfs", | |
| @@ -1435,7 +1537,7 @@ mod tests { | |||
| 1435 | 1537 | .conn() | |
| 1436 | 1538 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1437 | 1539 | .unwrap(); | |
| 1438 | - | assert_eq!(version, 20); | |
| 1540 | + | assert_eq!(version, 22); | |
| 1439 | 1541 | } | |
| 1440 | 1542 | ||
| 1441 | 1543 | #[test] | |
| @@ -1446,7 +1548,7 @@ mod tests { | |||
| 1446 | 1548 | .conn() | |
| 1447 | 1549 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1448 | 1550 | .unwrap(); | |
| 1449 | - | assert_eq!(version, 20); | |
| 1551 | + | assert_eq!(version, 22); | |
| 1450 | 1552 | } | |
| 1451 | 1553 | ||
| 1452 | 1554 | /// Open a fresh file-backed DB, close, reopen. The second open re-enters | |
| @@ -1465,7 +1567,7 @@ mod tests { | |||
| 1465 | 1567 | .conn() | |
| 1466 | 1568 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1467 | 1569 | .unwrap(); | |
| 1468 | - | assert_eq!(version, 20); | |
| 1570 | + | assert_eq!(version, 22); | |
| 1469 | 1571 | } | |
| 1470 | 1572 | ||
| 1471 | 1573 | /// Simulates the worst-case recovery path: a prior partial migration left | |
| @@ -1509,7 +1611,7 @@ mod tests { | |||
| 1509 | 1611 | .conn() | |
| 1510 | 1612 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1511 | 1613 | .unwrap(); | |
| 1512 | - | assert_eq!(version, 20); | |
| 1614 | + | assert_eq!(version, 22); | |
| 1513 | 1615 | } | |
| 1514 | 1616 | ||
| 1515 | 1617 | /// M018 contract: the `sync_changelog.row_id` for sensitive tables must | |
| @@ -1731,7 +1833,7 @@ mod tests { | |||
| 1731 | 1833 | let initial_version: i32 = conn | |
| 1732 | 1834 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1733 | 1835 | .unwrap(); | |
| 1734 | - | assert_eq!(initial_version, 20); | |
| 1836 | + | assert_eq!(initial_version, 22); | |
| 1735 | 1837 | ||
| 1736 | 1838 | let batch = format!( | |
| 1737 | 1839 | "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;", | |
| @@ -1794,7 +1896,7 @@ mod tests { | |||
| 1794 | 1896 | .conn() | |
| 1795 | 1897 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 1796 | 1898 | .unwrap(); | |
| 1797 | - | assert_eq!(version, 20); | |
| 1899 | + | assert_eq!(version, 22); | |
| 1798 | 1900 | } | |
| 1799 | 1901 | ||
| 1800 | 1902 | #[test] |
| @@ -44,6 +44,7 @@ pub mod forge; | |||
| 44 | 44 | pub mod id_types; | |
| 45 | 45 | pub mod instrument; | |
| 46 | 46 | pub mod rename; | |
| 47 | + | pub mod rules; | |
| 47 | 48 | pub mod search; | |
| 48 | 49 | pub mod similarity; | |
| 49 | 50 | pub mod store; |
| @@ -0,0 +1,937 @@ | |||
| 1 | + | //! Deterministic tag rules (Layer A of the hybrid tag classifier). | |
| 2 | + | //! | |
| 3 | + | //! A rule is an ordered, user-authored `IF <conditions> THEN <actions>` over cheap | |
| 4 | + | //! sample metadata + DSP features. Rules ship empty; the user (or, later, a one-click | |
| 5 | + | //! starter pack) creates them. Evaluation is deterministic and re-runnable. | |
| 6 | + | //! | |
| 7 | + | //! Tag provenance keeps manual tags sticky: a tag applied by a rule records a | |
| 8 | + | //! `tag_provenance` row (`source = 'rule'`), and reconciliation only ever touches | |
| 9 | + | //! rule-sourced tags. A tag with no provenance row is treated as manual and is never | |
| 10 | + | //! removed by the engine. This is the contract the rest of the pipeline (k-NN, `.afcl` | |
| 11 | + | //! layers) builds on. | |
| 12 | + | ||
| 13 | + | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 14 | + | ||
| 15 | + | use serde::{Deserialize, Serialize}; | |
| 16 | + | use tracing::instrument; | |
| 17 | + | ||
| 18 | + | use crate::db::Database; | |
| 19 | + | use crate::error::{CoreError, Result}; | |
| 20 | + | use crate::tags::validate_tag; | |
| 21 | + | ||
| 22 | + | // ── Model ── | |
| 23 | + | ||
| 24 | + | /// How a rule's conditions combine. | |
| 25 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 26 | + | #[serde(rename_all = "snake_case")] | |
| 27 | + | pub enum MatchMode { | |
| 28 | + | /// All conditions must hold (AND). | |
| 29 | + | All, | |
| 30 | + | /// Any condition may hold (OR). | |
| 31 | + | Any, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// A field of a sample a condition can test. | |
| 35 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 36 | + | #[serde(rename_all = "snake_case")] | |
| 37 | + | pub enum RuleField { | |
| 38 | + | // String (single-valued) | |
| 39 | + | Name, | |
| 40 | + | SourcePath, | |
| 41 | + | MusicalKey, | |
| 42 | + | FileExtension, | |
| 43 | + | // String (multi-valued — match if any element satisfies) | |
| 44 | + | VfsPath, | |
| 45 | + | Tag, | |
| 46 | + | // Numeric | |
| 47 | + | Duration, | |
| 48 | + | Bpm, | |
| 49 | + | SpectralCentroid, | |
| 50 | + | SpectralFlatness, | |
| 51 | + | SpectralRolloff, | |
| 52 | + | Zcr, | |
| 53 | + | SpectralBandwidth, | |
| 54 | + | CentroidVariance, | |
| 55 | + | CrestFactor, | |
| 56 | + | AttackTime, | |
| 57 | + | PeakDb, | |
| 58 | + | RmsDb, | |
| 59 | + | Lufs, | |
| 60 | + | SampleRate, | |
| 61 | + | Channels, | |
| 62 | + | FileSize, | |
| 63 | + | // Boolean | |
| 64 | + | IsLoop, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// A comparison operator. Applicability depends on the field's value kind; | |
| 68 | + | /// inapplicable combinations simply never match. | |
| 69 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 70 | + | #[serde(rename_all = "snake_case")] | |
| 71 | + | pub enum RuleOp { | |
| 72 | + | // String (case-insensitive) | |
| 73 | + | Contains, | |
| 74 | + | NotContains, | |
| 75 | + | Equals, | |
| 76 | + | NotEquals, | |
| 77 | + | StartsWith, | |
| 78 | + | EndsWith, | |
| 79 | + | // Numeric | |
| 80 | + | Lt, | |
| 81 | + | Le, | |
| 82 | + | Gt, | |
| 83 | + | Ge, | |
| 84 | + | // Boolean | |
| 85 | + | IsTrue, | |
| 86 | + | IsFalse, | |
| 87 | + | // Any | |
| 88 | + | Exists, | |
| 89 | + | NotExists, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// A single condition: `<field> <op> <value>`. | |
| 93 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 94 | + | pub struct RuleCondition { | |
| 95 | + | pub field: RuleField, | |
| 96 | + | pub op: RuleOp, | |
| 97 | + | /// Comparison operand. Parsed as a number for numeric ops; ignored for | |
| 98 | + | /// boolean / existence ops. | |
| 99 | + | #[serde(default)] | |
| 100 | + | pub value: String, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// What a rule does when it matches. | |
| 104 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 105 | + | #[serde(rename_all = "snake_case", tag = "kind", content = "tag")] | |
| 106 | + | pub enum RuleAction { | |
| 107 | + | /// Add a tag (deduped against the working set). | |
| 108 | + | AddTag(String), | |
| 109 | + | /// Remove a tag from the rule-desired set (suppresses an earlier add). | |
| 110 | + | /// Never removes a manual tag. | |
| 111 | + | RemoveTag(String), | |
| 112 | + | /// Stop evaluating further rules for this sample. | |
| 113 | + | #[serde(rename = "stop")] | |
| 114 | + | Stop, | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// A complete rule. | |
| 118 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 119 | + | pub struct Rule { | |
| 120 | + | pub id: String, | |
| 121 | + | pub name: String, | |
| 122 | + | pub enabled: bool, | |
| 123 | + | /// Evaluation order; lower runs first. | |
| 124 | + | pub priority: i64, | |
| 125 | + | pub match_mode: MatchMode, | |
| 126 | + | pub conditions: Vec<RuleCondition>, | |
| 127 | + | pub actions: Vec<RuleAction>, | |
| 128 | + | pub created_at: i64, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// A rule to create (id + created_at are assigned by `create_rule`). | |
| 132 | + | #[derive(Debug, Clone)] | |
| 133 | + | pub struct NewRule { | |
| 134 | + | pub name: String, | |
| 135 | + | pub enabled: bool, | |
| 136 | + | /// Explicit order, or `None` to append after the current last rule. | |
| 137 | + | pub priority: Option<i64>, | |
| 138 | + | pub match_mode: MatchMode, | |
| 139 | + | pub conditions: Vec<RuleCondition>, | |
| 140 | + | pub actions: Vec<RuleAction>, | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // ── ID generation (no uuid dependency; unique within a process) ── | |
| 144 | + | ||
| 145 | + | static RULE_COUNTER: AtomicU64 = AtomicU64::new(0); | |
| 146 | + | ||
| 147 | + | fn new_rule_id() -> String { | |
| 148 | + | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 149 | + | let nanos = SystemTime::now() | |
| 150 | + | .duration_since(UNIX_EPOCH) | |
| 151 | + | .unwrap_or_default() | |
| 152 | + | .as_nanos(); | |
| 153 | + | let n = RULE_COUNTER.fetch_add(1, Ordering::Relaxed); | |
| 154 | + | format!("{nanos:x}{n:x}") | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | fn now_secs() -> i64 { | |
| 158 | + | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 159 | + | SystemTime::now() | |
| 160 | + | .duration_since(UNIX_EPOCH) | |
| 161 | + | .map(|d| d.as_secs() as i64) | |
| 162 | + | .unwrap_or(0) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // ── Evaluation context ── | |
| 166 | + | ||
| 167 | + | /// Snapshot of a sample's testable fields, loaded once per evaluation. | |
| 168 | + | #[derive(Debug, Clone, Default)] | |
| 169 | + | pub struct RuleContext { | |
| 170 | + | pub hash: String, | |
| 171 | + | pub name: String, | |
| 172 | + | pub source_path: Option<String>, | |
| 173 | + | pub file_extension: Option<String>, | |
| 174 | + | pub file_size: Option<f64>, | |
| 175 | + | pub vfs_paths: Vec<String>, | |
| 176 | + | pub tags: Vec<String>, | |
| 177 | + | pub musical_key: Option<String>, | |
| 178 | + | pub duration: Option<f64>, | |
| 179 | + | pub bpm: Option<f64>, | |
| 180 | + | pub is_loop: Option<bool>, | |
| 181 | + | pub spectral_centroid: Option<f64>, | |
| 182 | + | pub spectral_flatness: Option<f64>, | |
| 183 | + | pub spectral_rolloff: Option<f64>, | |
| 184 | + | pub zcr: Option<f64>, | |
| 185 | + | pub spectral_bandwidth: Option<f64>, | |
| 186 | + | pub centroid_variance: Option<f64>, | |
| 187 | + | pub crest_factor: Option<f64>, | |
| 188 | + | pub attack_time: Option<f64>, | |
| 189 | + | pub peak_db: Option<f64>, | |
| 190 | + | pub rms_db: Option<f64>, | |
| 191 | + | pub lufs: Option<f64>, | |
| 192 | + | pub sample_rate: Option<f64>, | |
| 193 | + | pub channels: Option<f64>, | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// A field's value for the current sample. | |
| 197 | + | enum FieldVal<'a> { | |
| 198 | + | Str(Option<&'a str>), | |
| 199 | + | Num(Option<f64>), | |
| 200 | + | Bool(Option<bool>), | |
| 201 | + | List(&'a [String]), | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | impl RuleContext { | |
| 205 | + | fn field(&self, field: RuleField) -> FieldVal<'_> { | |
| 206 | + | use RuleField::*; | |
| 207 | + | match field { | |
| 208 | + | Name => FieldVal::Str(Some(self.name.as_str())), | |
| 209 | + | SourcePath => FieldVal::Str(self.source_path.as_deref()), | |
| 210 | + | MusicalKey => FieldVal::Str(self.musical_key.as_deref()), | |
| 211 | + | FileExtension => FieldVal::Str(self.file_extension.as_deref()), | |
| 212 | + | VfsPath => FieldVal::List(&self.vfs_paths), | |
| 213 | + | Tag => FieldVal::List(&self.tags), | |
| 214 | + | Duration => FieldVal::Num(self.duration), | |
| 215 | + | Bpm => FieldVal::Num(self.bpm), | |
| 216 | + | SpectralCentroid => FieldVal::Num(self.spectral_centroid), | |
| 217 | + | SpectralFlatness => FieldVal::Num(self.spectral_flatness), | |
| 218 | + | SpectralRolloff => FieldVal::Num(self.spectral_rolloff), | |
| 219 | + | Zcr => FieldVal::Num(self.zcr), | |
| 220 | + | SpectralBandwidth => FieldVal::Num(self.spectral_bandwidth), | |
| 221 | + | CentroidVariance => FieldVal::Num(self.centroid_variance), | |
| 222 | + | CrestFactor => FieldVal::Num(self.crest_factor), | |
| 223 | + | AttackTime => FieldVal::Num(self.attack_time), | |
| 224 | + | PeakDb => FieldVal::Num(self.peak_db), | |
| 225 | + | RmsDb => FieldVal::Num(self.rms_db), | |
| 226 | + | Lufs => FieldVal::Num(self.lufs), | |
| 227 | + | SampleRate => FieldVal::Num(self.sample_rate), | |
| 228 | + | Channels => FieldVal::Num(self.channels), | |
| 229 | + | FileSize => FieldVal::Num(self.file_size), | |
| 230 | + | IsLoop => FieldVal::Bool(self.is_loop), | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // ── Operator evaluation ── | |
| 236 | + | ||
| 237 | + | /// Apply a string operator to a single value (case-insensitive). Returns `None` | |
| 238 | + | /// for operators that aren't string-applicable (handled by the caller). | |
| 239 | + | fn str_op(op: RuleOp, s: &str, target: &str) -> Option<bool> { | |
| 240 | + | let s = s.to_lowercase(); | |
| 241 | + | let t = target.to_lowercase(); | |
| 242 | + | Some(match op { | |
| 243 | + | RuleOp::Contains => s.contains(&t), | |
| 244 | + | RuleOp::NotContains => !s.contains(&t), | |
| 245 | + | RuleOp::Equals => s == t, | |
| 246 | + | RuleOp::NotEquals => s != t, | |
| 247 | + | RuleOp::StartsWith => s.starts_with(&t), | |
| 248 | + | RuleOp::EndsWith => s.ends_with(&t), | |
| 249 | + | _ => return None, | |
| 250 | + | }) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Whether an operator is a "negative" string op (true when nothing matches). | |
| 254 | + | fn is_negative_str_op(op: RuleOp) -> bool { | |
| 255 | + | matches!(op, RuleOp::NotContains | RuleOp::NotEquals) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | fn num_op(op: RuleOp, n: f64, target: &str) -> bool { | |
| 259 | + | let Ok(t) = target.trim().parse::<f64>() else { | |
| 260 | + | return false; | |
| 261 | + | }; | |
| 262 | + | match op { | |
| 263 | + | RuleOp::Lt => n < t, | |
| 264 | + | RuleOp::Le => n <= t, | |
| 265 | + | RuleOp::Gt => n > t, | |
| 266 | + | RuleOp::Ge => n >= t, | |
| 267 | + | RuleOp::Equals => (n - t).abs() < f64::EPSILON.max(t.abs() * 1e-9), | |
| 268 | + | RuleOp::NotEquals => (n - t).abs() >= f64::EPSILON.max(t.abs() * 1e-9), | |
| 269 | + | _ => false, | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | fn eval_condition(ctx: &RuleContext, cond: &RuleCondition) -> bool { | |
| 274 | + | match ctx.field(cond.field) { | |
| 275 | + | FieldVal::Str(opt) => match cond.op { | |
| 276 | + | RuleOp::Exists => opt.is_some(), | |
| 277 | + | RuleOp::NotExists => opt.is_none(), | |
| 278 | + | _ => match opt { | |
| 279 | + | Some(s) => str_op(cond.op, s, &cond.value).unwrap_or(false), | |
| 280 | + | // Missing string: positive ops fail, negative ops hold. | |
| 281 | + | None => is_negative_str_op(cond.op), | |
| 282 | + | }, | |
| 283 | + | }, | |
| 284 | + | FieldVal::Num(opt) => match cond.op { | |
| 285 | + | RuleOp::Exists => opt.is_some(), | |
| 286 | + | RuleOp::NotExists => opt.is_none(), | |
| 287 | + | _ => opt.is_some_and(|n| num_op(cond.op, n, &cond.value)), | |
| 288 | + | }, | |
| 289 | + | FieldVal::Bool(opt) => match cond.op { | |
| 290 | + | RuleOp::Exists => opt.is_some(), | |
| 291 | + | RuleOp::NotExists => opt.is_none(), | |
| 292 | + | RuleOp::IsTrue => opt == Some(true), | |
| 293 | + | RuleOp::IsFalse => opt == Some(false), | |
| 294 | + | _ => false, | |
| 295 | + | }, | |
| 296 | + | FieldVal::List(items) => match cond.op { | |
| 297 | + | RuleOp::Exists => !items.is_empty(), | |
| 298 | + | RuleOp::NotExists => items.is_empty(), | |
| 299 | + | _ if is_negative_str_op(cond.op) => { | |
| 300 | + | // Negative ops hold only when NO element matches the positive form. | |
| 301 | + | let positive = match cond.op { | |
| 302 | + | RuleOp::NotContains => RuleOp::Contains, | |
| 303 | + | RuleOp::NotEquals => RuleOp::Equals, | |
| 304 | + | _ => cond.op, | |
| 305 | + | }; | |
| 306 | + | !items | |
| 307 | + | .iter() | |
| 308 | + | .any(|it| str_op(positive, it, &cond.value).unwrap_or(false)) | |
| 309 | + | } | |
| 310 | + | _ => items | |
| 311 | + | .iter() | |
| 312 | + | .any(|it| str_op(cond.op, it, &cond.value).unwrap_or(false)), | |
| 313 | + | }, | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// Whether a rule's conditions hold for a sample (ignores actions). An empty | |
| 318 | + | /// condition list matches everything (the rule is unconditional). | |
| 319 | + | pub fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool { | |
| 320 | + | if rule.conditions.is_empty() { | |
| 321 | + | return true; | |
| 322 | + | } | |
| 323 | + | match rule.match_mode { | |
| 324 | + | MatchMode::All => rule.conditions.iter().all(|c| eval_condition(ctx, c)), | |
| 325 | + | MatchMode::Any => rule.conditions.iter().any(|c| eval_condition(ctx, c)), | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /// Evaluate all enabled rules in priority order, producing the rule-desired tag | |
| 330 | + | /// set with attribution (tag -> id of the rule that last added it). | |
| 331 | + | fn evaluate(rules: &[Rule], ctx: &RuleContext) -> Vec<(String, String)> { | |
| 332 | + | // Insertion-ordered: preserve the order tags were added for stable output. | |
| 333 | + | let mut desired: Vec<(String, String)> = Vec::new(); | |
| 334 | + | for rule in rules.iter().filter(|r| r.enabled) { | |
| 335 | + | if !rule_matches(rule, ctx) { | |
| 336 | + | continue; | |
| 337 | + | } | |
| 338 | + | let mut stop = false; | |
| 339 | + | for action in &rule.actions { | |
| 340 | + | match action { | |
| 341 | + | RuleAction::AddTag(tag) => { | |
| 342 | + | if validate_tag(tag).is_err() { | |
| 343 | + | tracing::warn!(rule = %rule.id, %tag, "rule produced an invalid tag; skipped"); | |
| 344 | + | continue; | |
| 345 | + | } | |
| 346 | + | if let Some(slot) = desired.iter_mut().find(|(t, _)| t == tag) { | |
| 347 | + | slot.1 = rule.id.clone(); | |
| 348 | + | } else { | |
| 349 | + | desired.push((tag.clone(), rule.id.clone())); | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | RuleAction::RemoveTag(tag) => { | |
| 353 | + | desired.retain(|(t, _)| t != tag); | |
| 354 | + | } | |
| 355 | + | RuleAction::Stop => stop = true, | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | if stop { | |
| 359 | + | break; | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | desired | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // ── Context loading ── | |
| 366 | + | ||
| 367 | + | /// Load a sample's testable fields. Returns `None` if the sample doesn't exist. | |
| 368 | + | #[instrument(skip_all)] | |
| 369 | + | pub fn load_context(db: &Database, hash: &str) -> Result<Option<RuleContext>> { | |
| 370 | + | let conn = db.conn(); | |
| 371 | + | let base = conn | |
| 372 | + | .query_row( | |
| 373 | + | "SELECT original_name, file_extension, file_size, source_path | |
| 374 | + | FROM samples WHERE hash = ?1", | |
| 375 | + | [hash], | |
| 376 | + | |row| { | |
| 377 | + | Ok(( | |
| 378 | + | row.get::<_, String>(0)?, | |
| 379 | + | row.get::<_, Option<String>>(1)?, | |
| 380 | + | row.get::<_, Option<i64>>(2)?, | |
| 381 | + | row.get::<_, Option<String>>(3)?, | |
| 382 | + | )) | |
| 383 | + | }, | |
| 384 | + | ) | |
| 385 | + | .ok(); | |
| 386 | + | ||
| 387 | + | let Some((name, file_extension, file_size, source_path)) = base else { | |
| 388 | + | return Ok(None); | |
| 389 | + | }; | |
| 390 | + | ||
| 391 | + | let mut ctx = RuleContext { | |
| 392 | + | hash: hash.to_string(), | |
| 393 | + | name, | |
| 394 | + | source_path, | |
| 395 | + | file_extension, | |
| 396 | + | file_size: file_size.map(|v| v as f64), | |
| 397 | + | ..Default::default() | |
| 398 | + | }; | |
| 399 | + | ||
| 400 | + | // Analysis fields (optional — sample may be unanalyzed). | |
| 401 | + | let _ = conn.query_row( | |
| 402 | + | "SELECT duration, bpm, musical_key, is_loop, spectral_centroid, spectral_flatness, | |
| 403 | + | spectral_rolloff, zero_crossing_rate, spectral_bandwidth, centroid_variance, | |
| 404 | + | crest_factor, attack_time, peak_db, rms_db, lufs, sample_rate, channels | |
| 405 | + | FROM audio_analysis WHERE hash = ?1", | |
| 406 | + | [hash], | |
| 407 | + | |row| { | |
| 408 | + | ctx.duration = row.get(0)?; | |
| 409 | + | ctx.bpm = row.get(1)?; | |
| 410 | + | ctx.musical_key = row.get(2)?; | |
| 411 | + | ctx.is_loop = row.get::<_, Option<bool>>(3)?; | |
| 412 | + | ctx.spectral_centroid = row.get(4)?; | |
| 413 | + | ctx.spectral_flatness = row.get(5)?; | |
| 414 | + | ctx.spectral_rolloff = row.get(6)?; | |
| 415 | + | ctx.zcr = row.get(7)?; | |
| 416 | + | ctx.spectral_bandwidth = row.get(8)?; | |
| 417 | + | ctx.centroid_variance = row.get(9)?; | |
| 418 | + | ctx.crest_factor = row.get(10)?; | |
| 419 | + | ctx.attack_time = row.get(11)?; | |
| 420 | + | ctx.peak_db = row.get(12)?; | |
| 421 | + | ctx.rms_db = row.get(13)?; | |
| 422 | + | ctx.lufs = row.get(14)?; | |
| 423 | + | ctx.sample_rate = row.get::<_, Option<i64>>(15)?.map(|v| v as f64); | |
| 424 | + | ctx.channels = row.get::<_, Option<i64>>(16)?.map(|v| v as f64); | |
| 425 | + | Ok(()) | |
| 426 | + | }, | |
| 427 | + | ); | |
| 428 | + | ||
| 429 | + | // Tags. | |
| 430 | + | { | |
| 431 | + | let mut stmt = conn.prepare("SELECT tag FROM tags WHERE sample_hash = ?1")?; | |
| 432 | + | ctx.tags = stmt | |
| 433 | + | .query_map([hash], |row| row.get(0))? | |
| 434 | + | .collect::<std::result::Result<Vec<_>, _>>()?; | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // VFS directory paths containing this sample (parent chain of each linking node). | |
| 438 | + | ctx.vfs_paths = sample_vfs_paths(db, hash)?; | |
| 439 | + | ||
| 440 | + | Ok(Some(ctx)) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | /// Directory paths (e.g. `Drums/808 Kicks`) of every VFS location linking this sample. | |
| 444 | + | fn sample_vfs_paths(db: &Database, hash: &str) -> Result<Vec<String>> { | |
| 445 | + | let mut stmt = db.conn().prepare( | |
| 446 | + | "WITH RECURSIVE chain(node_id, parent_id, name, depth) AS ( | |
| 447 | + | SELECT vn.id, vn.parent_id, p.name, 0 | |
| 448 | + | FROM vfs_nodes vn | |
| 449 | + | JOIN vfs_nodes p ON p.id = vn.parent_id | |
| 450 | + | WHERE vn.sample_hash = ?1 AND vn.node_type = 'sample' | |
| 451 | + | UNION ALL | |
| 452 | + | SELECT c.node_id, p.parent_id, p.name, c.depth + 1 | |
| 453 | + | FROM chain c | |
| 454 | + | JOIN vfs_nodes p ON p.id = c.parent_id | |
| 455 | + | ) | |
| 456 | + | SELECT node_id, group_concat(name, '/') FROM ( | |
| 457 | + | SELECT node_id, name, depth FROM chain ORDER BY node_id, depth DESC | |
| 458 | + | ) GROUP BY node_id", | |
| 459 | + | )?; | |
| 460 | + | let rows = stmt.query_map([hash], |row| row.get::<_, Option<String>>(1))?; | |
| 461 | + | let mut paths = Vec::new(); | |
| 462 | + | for r in rows { | |
| 463 | + | if let Some(p) = r? { | |
| 464 | + | paths.push(p); | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | Ok(paths) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // ── CRUD ── | |
| 471 | + | ||
| 472 | + | fn parse_rule(row: &rusqlite::Row) -> rusqlite::Result<Rule> { | |
| 473 | + | let conditions_json: String = row.get(5)?; | |
| 474 | + | let actions_json: String = row.get(6)?; | |
| 475 | + | Ok(Rule { | |
| 476 | + | id: row.get(0)?, | |
| 477 | + | name: row.get(1)?, | |
| 478 | + | enabled: row.get(2)?, | |
| 479 | + | priority: row.get(3)?, | |
| 480 | + | match_mode: serde_json::from_str(&row.get::<_, String>(4)?) | |
| 481 | + | .unwrap_or(MatchMode::All), | |
| 482 | + | conditions: serde_json::from_str(&conditions_json).unwrap_or_default(), | |
| 483 | + | actions: serde_json::from_str(&actions_json).unwrap_or_default(), | |
| 484 | + | created_at: row.get(7)?, | |
| 485 | + | }) | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | const RULE_COLUMNS: &str = | |
| 489 | + | "id, name, enabled, priority, match_mode, conditions, actions, created_at"; | |
| 490 | + | ||
| 491 | + | /// List all rules ordered by priority (evaluation order). | |
| 492 | + | #[instrument(skip_all)] | |
| 493 | + | pub fn list_rules(db: &Database) -> Result<Vec<Rule>> { | |
| 494 | + | let sql = format!("SELECT {RULE_COLUMNS} FROM tag_rules ORDER BY priority, created_at"); | |
| 495 | + | let mut stmt = db.conn().prepare(&sql)?; | |
| 496 | + | let rows = stmt.query_map([], parse_rule)?; | |
| 497 | + | Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | /// Fetch a single rule by id. |
Lines truncated
| @@ -26,6 +26,8 @@ pub(crate) const UPSERT_ORDER: &[&str] = &[ | |||
| 26 | 26 | "vfs_nodes", | |
| 27 | 27 | "audio_analysis", | |
| 28 | 28 | "tags", | |
| 29 | + | "tag_provenance", | |
| 30 | + | "tag_rules", | |
| 29 | 31 | "collection_members", | |
| 30 | 32 | "user_config", | |
| 31 | 33 | "edit_history", | |
| @@ -36,6 +38,8 @@ pub(crate) const DELETE_ORDER: &[&str] = &[ | |||
| 36 | 38 | "edit_history", | |
| 37 | 39 | "user_config", | |
| 38 | 40 | "collection_members", | |
| 41 | + | "tag_rules", | |
| 42 | + | "tag_provenance", | |
| 39 | 43 | "tags", | |
| 40 | 44 | "audio_analysis", | |
| 41 | 45 | "vfs_nodes", | |
| @@ -73,6 +77,11 @@ pub(crate) fn table_columns(table: &str) -> Option<&'static [&'static str]> { | |||
| 73 | 77 | "sample_hash", "created_at", | |
| 74 | 78 | ]), | |
| 75 | 79 | "tags" => Some(&["sample_hash", "tag"]), | |
| 80 | + | "tag_provenance" => Some(&["sample_hash", "tag", "source", "rule_id"]), | |
| 81 | + | "tag_rules" => Some(&[ | |
| 82 | + | "id", "name", "enabled", "priority", "match_mode", "conditions", | |
| 83 | + | "actions", "created_at", | |
| 84 | + | ]), | |
| 76 | 85 | "collections" => Some(&["id", "name", "description", "created_at", "filter_json"]), | |
| 77 | 86 | "collection_members" => Some(&["collection_id", "sample_hash", "added_at"]), | |
| 78 | 87 | "user_config" => Some(&["key", "value"]), | |
| @@ -93,6 +102,8 @@ pub(crate) fn pk_columns(table: &str) -> &'static [&'static str] { | |||
| 93 | 102 | "vfs" => &["id"], | |
| 94 | 103 | "vfs_nodes" => &["id"], | |
| 95 | 104 | "tags" => &["sample_hash", "tag"], | |
| 105 | + | "tag_provenance" => &["sample_hash", "tag"], | |
| 106 | + | "tag_rules" => &["id"], | |
| 96 | 107 | "collections" => &["id"], | |
| 97 | 108 | "collection_members" => &["collection_id", "sample_hash"], | |
| 98 | 109 | "user_config" => &["key"], |
| @@ -29,6 +29,8 @@ pub fn create_initial_snapshot(conn: &Connection) -> Result<i64> { | |||
| 29 | 29 | ("vfs", "SELECT CAST(id AS TEXT), json_object('id', id, 'name', name, 'created_at', created_at, 'modified_at', modified_at, 'sync_files', sync_files) FROM vfs"), | |
| 30 | 30 | ("vfs_nodes", "SELECT CAST(id AS TEXT), json_object('id', id, 'vfs_id', vfs_id, 'parent_id', parent_id, 'name', name, 'node_type', node_type, 'sample_hash', sample_hash, 'created_at', created_at) FROM vfs_nodes"), | |
| 31 | 31 | ("tags", "SELECT sample_hash || ':' || tag, json_object('sample_hash', sample_hash, 'tag', tag) FROM tags"), | |
| 32 | + | ("tag_provenance", "SELECT sample_hash || ':' || tag, json_object('sample_hash', sample_hash, 'tag', tag, 'source', source, 'rule_id', rule_id) FROM tag_provenance"), | |
| 33 | + | ("tag_rules", "SELECT id, json_object('id', id, 'name', name, 'enabled', enabled, 'priority', priority, 'match_mode', match_mode, 'conditions', conditions, 'actions', actions, 'created_at', created_at) FROM tag_rules"), | |
| 32 | 34 | ("collections", "SELECT CAST(id AS TEXT), json_object('id', id, 'name', name, 'description', description, 'created_at', created_at, 'filter_json', filter_json) FROM collections"), | |
| 33 | 35 | ("collection_members", "SELECT CAST(collection_id AS TEXT) || ':' || sample_hash, json_object('collection_id', collection_id, 'sample_hash', sample_hash, 'added_at', added_at) FROM collection_members"), | |
| 34 | 36 | ("user_config", "SELECT key, json_object('key', key, 'value', value) FROM user_config WHERE key NOT LIKE 'sync_%' AND key != 'loose_files'"), | |
| @@ -394,6 +396,54 @@ mod tests { | |||
| 394 | 396 | } | |
| 395 | 397 | ||
| 396 | 398 | #[test] | |
| 399 | + | fn tag_rules_insert_fires_trigger() { | |
| 400 | + | let db = setup_test_db(); | |
| 401 | + | let conn = db.conn(); | |
| 402 | + | clear_changelog(conn); | |
| 403 | + | ||
| 404 | + | conn.execute( | |
| 405 | + | "INSERT INTO tag_rules (id, name, enabled, priority, match_mode, conditions, actions, created_at) \ | |
| 406 | + | VALUES ('r1', 'kicks', 1, 0, '\"all\"', '[]', '[]', 0)", | |
| 407 | + | [], | |
| 408 | + | ).unwrap(); | |
| 409 | + | ||
| 410 | + | assert_eq!(changelog_count(conn, Some("tag_rules"), Some("INSERT")), 1); | |
| 411 | + | let row_id: String = conn.query_row( | |
| 412 | + | "SELECT row_id FROM sync_changelog WHERE table_name = 'tag_rules'", | |
| 413 | + | [], | |
| 414 | + | |row| row.get(0), | |
| 415 | + | ).unwrap(); | |
| 416 | + | // Opaque rule id is non-sensitive -> cleartext row_id. | |
| 417 | + | assert_eq!(row_id, "r1"); | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | #[test] | |
| 421 | + | fn tag_provenance_insert_fires_trigger() { | |
| 422 | + | let db = setup_test_db(); | |
| 423 | + | let conn = db.conn(); | |
| 424 | + | insert_sample(conn, "ph", "kick.wav", "wav"); | |
| 425 | + | clear_changelog(conn); | |
| 426 | + | ||
| 427 | + | conn.execute( | |
| 428 | + | "INSERT INTO tag_provenance (sample_hash, tag, source, rule_id) \ | |
| 429 | + | VALUES ('ph', 'instrument.drum.kick', 'rule', 'r1')", | |
| 430 | + | [], | |
| 431 | + | ).unwrap(); | |
| 432 | + | ||
| 433 | + | assert_eq!(changelog_count(conn, Some("tag_provenance"), Some("INSERT")), 1); | |
| 434 | + | let data: String = conn.query_row( | |
| 435 | + | "SELECT data FROM sync_changelog WHERE table_name = 'tag_provenance'", | |
| 436 | + | [], | |
| 437 | + | |row| row.get(0), | |
| 438 | + | ).unwrap(); | |
| 439 | + | let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); | |
| 440 | + | assert_eq!(parsed["sample_hash"], "ph"); | |
| 441 | + | assert_eq!(parsed["tag"], "instrument.drum.kick"); | |
| 442 | + | assert_eq!(parsed["source"], "rule"); | |
| 443 | + | assert_eq!(parsed["rule_id"], "r1"); | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | #[test] | |
| 397 | 447 | fn collection_member_insert_fires_trigger() { | |
| 398 | 448 | let db = setup_test_db(); | |
| 399 | 449 | let conn = db.conn(); |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # audiofiles Database Schema | |
| 2 | 2 | ||
| 3 | - | SQLite schema reference. 20 inline migrations. Migrations are embedded as Rust string constants in `crates/audiofiles-core/src/db.rs` and applied via `PRAGMA user_version` tracking -- not separate SQL files. | |
| 3 | + | SQLite schema reference. 22 inline migrations. Migrations are embedded as Rust string constants in `crates/audiofiles-core/src/db.rs` and applied via `PRAGMA user_version` tracking -- not separate SQL files. | |
| 4 | 4 | ||
| 5 | 5 | ## Domain Map | |
| 6 | 6 | ||
| @@ -148,6 +148,36 @@ Flat dot-namespaced tags on samples. Migration 002 replaced the original key-val | |||
| 148 | 148 | ||
| 149 | 149 | PK: `(sample_hash, tag)`. Indexes: `sample_hash`, `tag`. | |
| 150 | 150 | ||
| 151 | + | ### tag_rules | |
| 152 | + | Deterministic tag rules (Layer A of the hybrid classifier). Migration 021. Ordered | |
| 153 | + | `IF <conditions> THEN <actions>` over sample metadata + DSP features. Ships empty. | |
| 154 | + | ||
| 155 | + | | Column | Type | Notes | | |
| 156 | + | |--------|------|-------| | |
| 157 | + | | `id` | TEXT PK | Opaque generated id | | |
| 158 | + | | `name` | TEXT | User-facing label | | |
| 159 | + | | `enabled` | INTEGER | Boolean | | |
| 160 | + | | `priority` | INTEGER | Evaluation order (lower runs first) | | |
| 161 | + | | `match_mode` | TEXT | JSON: `"all"` or `"any"` | | |
| 162 | + | | `conditions` | TEXT | JSON array of `{field, op, value}` | | |
| 163 | + | | `actions` | TEXT | JSON array of `{kind, tag?}` (`add_tag`/`remove_tag`/`stop`) | | |
| 164 | + | | `created_at` | INTEGER | Unix timestamp | | |
| 165 | + | ||
| 166 | + | Index: `priority`. | |
| 167 | + | ||
| 168 | + | ### tag_provenance | |
| 169 | + | Records which machine source applied each tag, so manual tags stay sticky. Migration 022. | |
| 170 | + | A tag with **no** row here is manual; reconciliation only ever touches rule-sourced tags. | |
| 171 | + | ||
| 172 | + | | Column | Type | Notes | | |
| 173 | + | |--------|------|-------| | |
| 174 | + | | `sample_hash` | TEXT FK | -> samples (CASCADE) | | |
| 175 | + | | `tag` | TEXT | The applied tag | | |
| 176 | + | | `source` | TEXT | `rule` / `ml` / `cluster` (manual = no row) | | |
| 177 | + | | `rule_id` | TEXT | `tag_rules.id` when `source = 'rule'`, else NULL | | |
| 178 | + | ||
| 179 | + | PK: `(sample_hash, tag)`. | |
| 180 | + | ||
| 151 | 181 | ### collections | |
| 152 | 182 | Named sample collections (playlists, kits). | |
| 153 | 183 | ||
| @@ -243,7 +273,7 @@ Indexes: `source_hash`, `result_hash`. | |||
| 243 | 273 | - **Sync-excluded keys:** `user_config` sync triggers skip keys matching `sync_%` to avoid syncing sync-internal state | |
| 244 | 274 | - **Cloud-only samples:** `samples.cloud_only` flag allows local blob eviction while keeping metadata and cloud copy | |
| 245 | 275 | - **Hashed row IDs (M018):** sensitive `sync_changelog.row_id` values go through `hash_row_id(row_id_salt, canonical_key)` so the server never sees raw sample hashes or tag strings. DELETE triggers also emit the canonical PK into the encrypted `data` field so pull-side replay doesn't need to parse row_id. | |
| 246 | - | - **Synced tables:** `samples`, `sample_features`, `audio_analysis`, `vfs`, `vfs_nodes`, `tags`, `collections`, `collection_members`, `user_config`, `edit_history` | |
| 276 | + | - **Synced tables:** `samples`, `sample_features`, `audio_analysis`, `vfs`, `vfs_nodes`, `tags`, `tag_provenance`, `tag_rules`, `collections`, `collection_members`, `user_config`, `edit_history` | |
| 247 | 277 | ||
| 248 | 278 | ## Key Indexes | |
| 249 | 279 | ||
| @@ -277,3 +307,5 @@ Indexes: `source_hash`, `result_hash`. | |||
| 277 | 307 | | 018 | Hash `sync_changelog.row_id` for sensitive tables; DELETE triggers emit canonical PK in `data`; per-user `row_id_salt` in sync_state | | |
| 278 | 308 | | 019 | `samples.deleted_at` soft-delete tombstone + partial index; seed `sample_tombstone_retain_days`; re-emit samples sync triggers | | |
| 279 | 309 | | 020 | `sample_features` table (persisted 35-feature vector) + sync triggers; retired the embedded RF classifier models | | |
| 310 | + | | 021 | `tag_rules` table (deterministic tag rules, Layer A) + sync triggers | | |
| 311 | + | | 022 | `tag_provenance` table (manual-sticky tag attribution) + sync triggers | |