Skip to main content

max / audiofiles

Add deterministic tag rules engine (classifier Phase 1) Layer A of the hybrid tag classifier: ordered, user-authored IF/THEN rules over sample metadata + DSP features, with manual-sticky provenance. - New audiofiles-core::rules module: Rule/RuleCondition/RuleField/RuleOp/ RuleAction/MatchMode; structured condition evaluation over a sample's metadata, audio_analysis fields, tags, and VFS folder paths; ordered evaluation with Stop; CRUD; reconciliation (apply_rules_to_sample / apply_all_rules); dry-run preview_rule_matches. - Migration 021 tag_rules + 022 tag_provenance (manual tags = no row, so reconciliation only ever touches rule-sourced tags) + sync triggers; registered both tables in audiofiles-sync. - Harden Phase 0 sample_features triggers to hash the row_id, matching the post-M018 privacy treatment of audio_analysis (nothing deployed yet). - String ops only for now (contains/equals/starts_with/ends_with + negations); no regex/glob dependency. scope_ml action deferred to Phase 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-14 22:59 UTC
Commit: 723d5d216cf46612f2028bc5af36dc3be17a4582
Parent: 6abab38
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 |