//! Deterministic tag rules (Layer A of the hybrid tag classifier). //! //! A rule is an ordered, user-authored `IF THEN ` over cheap //! sample metadata + DSP features. Rules ship empty; the user (or, later, a one-click //! starter pack) creates them. Evaluation is deterministic and re-runnable. //! //! Tag provenance keeps manual tags sticky: a tag applied by a rule records a //! `tag_provenance` row (`source = 'rule'`), and reconciliation only ever touches //! rule-sourced tags. A tag with no provenance row is treated as manual and is never //! removed by the engine. This is the contract the rest of the pipeline (k-NN, `.afcl` //! layers) builds on. use std::sync::atomic::{AtomicU64, Ordering}; use serde::{Deserialize, Serialize}; use tracing::instrument; use crate::db::{Database, Tx}; use crate::error::{CoreError, Result}; use crate::tags::validate_tag; // ── Model ── /// How a rule's conditions combine. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MatchMode { /// All conditions must hold (AND). #[default] All, /// Any condition may hold (OR). Any, } /// A field of a sample a condition can test. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RuleField { // String (single-valued) Name, SourcePath, MusicalKey, FileExtension, // String (multi-valued, match if any element satisfies) VfsPath, Tag, // Numeric Duration, Bpm, SpectralCentroid, SpectralFlatness, SpectralRolloff, Zcr, SpectralBandwidth, CentroidVariance, CrestFactor, AttackTime, PeakDb, RmsDb, Lufs, SampleRate, Channels, FileSize, // Boolean IsLoop, } /// A comparison operator. Applicability depends on the field's value kind; /// inapplicable combinations never match. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RuleOp { // String (case-insensitive) Contains, NotContains, Equals, NotEquals, StartsWith, EndsWith, // Numeric Lt, Le, Gt, Ge, // Boolean IsTrue, IsFalse, // Any Exists, NotExists, } /// A single condition: ` `. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RuleCondition { pub field: RuleField, pub op: RuleOp, /// Comparison operand. Parsed as a number for numeric ops; ignored for /// boolean / existence ops. #[serde(default)] pub value: String, } /// What a rule does when it matches. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind", content = "tag")] pub enum RuleAction { /// Add a tag (deduped against the working set). AddTag(String), /// Remove a tag from the rule-desired set (suppresses an earlier add). /// Never removes a manual tag. RemoveTag(String), /// Stop evaluating further rules for this sample. #[serde(rename = "stop")] Stop, } /// A complete rule. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Rule { pub id: String, pub name: String, pub enabled: bool, /// Evaluation order; lower runs first. pub priority: i64, pub match_mode: MatchMode, pub conditions: Vec, pub actions: Vec, pub created_at: i64, } /// A rule to create (id + created_at are assigned by `create_rule`). #[derive(Debug, Clone)] pub struct NewRule { pub name: String, pub enabled: bool, /// Explicit order, or `None` to append after the current last rule. pub priority: Option, pub match_mode: MatchMode, pub conditions: Vec, pub actions: Vec, } // ── ID generation (no uuid dependency; unique within a process) ── static RULE_COUNTER: AtomicU64 = AtomicU64::new(0); fn new_rule_id() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let n = RULE_COUNTER.fetch_add(1, Ordering::Relaxed); format!("{nanos:x}{n:x}") } fn now_secs() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| d.as_secs() as i64) } // ── Evaluation context ── /// Snapshot of a sample's testable fields, loaded once per evaluation. #[derive(Debug, Clone, Default)] pub struct RuleContext { pub hash: String, pub name: String, pub source_path: Option, pub file_extension: Option, pub file_size: Option, pub vfs_paths: Vec, pub tags: Vec, pub musical_key: Option, pub duration: Option, pub bpm: Option, pub is_loop: Option, pub spectral_centroid: Option, pub spectral_flatness: Option, pub spectral_rolloff: Option, pub zcr: Option, pub spectral_bandwidth: Option, pub centroid_variance: Option, pub crest_factor: Option, pub attack_time: Option, pub peak_db: Option, pub rms_db: Option, pub lufs: Option, pub sample_rate: Option, pub channels: Option, } /// A field's value for the current sample. enum FieldVal<'a> { Str(Option<&'a str>), Num(Option), Bool(Option), List(&'a [String]), } impl RuleContext { fn field(&self, field: RuleField) -> FieldVal<'_> { use RuleField::{ AttackTime, Bpm, CentroidVariance, Channels, CrestFactor, Duration, FileExtension, FileSize, IsLoop, Lufs, MusicalKey, Name, PeakDb, RmsDb, SampleRate, SourcePath, SpectralBandwidth, SpectralCentroid, SpectralFlatness, SpectralRolloff, Tag, VfsPath, Zcr, }; match field { Name => FieldVal::Str(Some(self.name.as_str())), SourcePath => FieldVal::Str(self.source_path.as_deref()), MusicalKey => FieldVal::Str(self.musical_key.as_deref()), FileExtension => FieldVal::Str(self.file_extension.as_deref()), VfsPath => FieldVal::List(&self.vfs_paths), Tag => FieldVal::List(&self.tags), Duration => FieldVal::Num(self.duration), Bpm => FieldVal::Num(self.bpm), SpectralCentroid => FieldVal::Num(self.spectral_centroid), SpectralFlatness => FieldVal::Num(self.spectral_flatness), SpectralRolloff => FieldVal::Num(self.spectral_rolloff), Zcr => FieldVal::Num(self.zcr), SpectralBandwidth => FieldVal::Num(self.spectral_bandwidth), CentroidVariance => FieldVal::Num(self.centroid_variance), CrestFactor => FieldVal::Num(self.crest_factor), AttackTime => FieldVal::Num(self.attack_time), PeakDb => FieldVal::Num(self.peak_db), RmsDb => FieldVal::Num(self.rms_db), Lufs => FieldVal::Num(self.lufs), SampleRate => FieldVal::Num(self.sample_rate), Channels => FieldVal::Num(self.channels), FileSize => FieldVal::Num(self.file_size), IsLoop => FieldVal::Bool(self.is_loop), } } } // ── Operator evaluation ── /// Apply a string operator to a single value (case-insensitive). Returns `None` /// for operators that aren't string-applicable (handled by the caller). fn str_op(op: RuleOp, s: &str, target: &str) -> Option { let s = s.to_lowercase(); let t = target.to_lowercase(); Some(match op { RuleOp::Contains => s.contains(&t), RuleOp::NotContains => !s.contains(&t), RuleOp::Equals => s == t, RuleOp::NotEquals => s != t, RuleOp::StartsWith => s.starts_with(&t), RuleOp::EndsWith => s.ends_with(&t), _ => return None, }) } /// Whether an operator is a "negative" string op (true when nothing matches). fn is_negative_str_op(op: RuleOp) -> bool { matches!(op, RuleOp::NotContains | RuleOp::NotEquals) } fn num_op(op: RuleOp, n: f64, target: &str) -> bool { let Ok(t) = target.trim().parse::() else { return false; }; match op { RuleOp::Lt => n < t, RuleOp::Le => n <= t, RuleOp::Gt => n > t, RuleOp::Ge => n >= t, RuleOp::Equals => (n - t).abs() < f64::EPSILON.max(t.abs() * 1e-9), RuleOp::NotEquals => (n - t).abs() >= f64::EPSILON.max(t.abs() * 1e-9), _ => false, } } fn eval_condition(ctx: &RuleContext, cond: &RuleCondition) -> bool { match ctx.field(cond.field) { FieldVal::Str(opt) => match cond.op { RuleOp::Exists => opt.is_some(), RuleOp::NotExists => opt.is_none(), _ => match opt { Some(s) => str_op(cond.op, s, &cond.value).unwrap_or(false), // Missing string: positive ops fail, negative ops hold. None => is_negative_str_op(cond.op), }, }, FieldVal::Num(opt) => match cond.op { RuleOp::Exists => opt.is_some(), RuleOp::NotExists => opt.is_none(), _ => opt.is_some_and(|n| num_op(cond.op, n, &cond.value)), }, FieldVal::Bool(opt) => match cond.op { RuleOp::Exists => opt.is_some(), RuleOp::NotExists => opt.is_none(), RuleOp::IsTrue => opt == Some(true), RuleOp::IsFalse => opt == Some(false), _ => false, }, FieldVal::List(items) => match cond.op { RuleOp::Exists => !items.is_empty(), RuleOp::NotExists => items.is_empty(), _ if is_negative_str_op(cond.op) => { // Negative ops hold only when NO element matches the positive form. let positive = match cond.op { RuleOp::NotContains => RuleOp::Contains, RuleOp::NotEquals => RuleOp::Equals, _ => cond.op, }; !items .iter() .any(|it| str_op(positive, it, &cond.value).unwrap_or(false)) } _ => items .iter() .any(|it| str_op(cond.op, it, &cond.value).unwrap_or(false)), }, } } /// Whether a rule's conditions hold for a sample (ignores actions). An empty /// condition list matches everything (the rule is unconditional). pub fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool { if rule.conditions.is_empty() { return true; } match rule.match_mode { MatchMode::All => rule.conditions.iter().all(|c| eval_condition(ctx, c)), MatchMode::Any => rule.conditions.iter().any(|c| eval_condition(ctx, c)), } } /// Evaluate all enabled rules in priority order, producing the rule-desired tag /// set with attribution (tag -> id of the rule that last added it). fn evaluate(rules: &[Rule], ctx: &RuleContext) -> Vec<(String, String)> { // Insertion-ordered: preserve the order tags were added for stable output. let mut desired: Vec<(String, String)> = Vec::new(); for rule in rules.iter().filter(|r| r.enabled) { if !rule_matches(rule, ctx) { continue; } let mut stop = false; for action in &rule.actions { match action { RuleAction::AddTag(tag) => { if validate_tag(tag).is_err() { tracing::warn!(rule = %rule.id, %tag, "rule produced an invalid tag; skipped"); continue; } if let Some(slot) = desired.iter_mut().find(|(t, _)| t == tag) { slot.1.clone_from(&rule.id); } else { desired.push((tag.clone(), rule.id.clone())); } } RuleAction::RemoveTag(tag) => { desired.retain(|(t, _)| t != tag); } RuleAction::Stop => stop = true, } } if stop { break; } } desired } // ── Context loading ── /// Load a sample's testable fields. Returns `None` if the sample doesn't exist. #[instrument(skip_all)] pub fn load_context(db: &Database, hash: &str) -> Result> { let conn = db.conn(); let base = conn .query_row( "SELECT original_name, file_extension, file_size, source_path FROM live_samples WHERE hash = ?1", [hash], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, )) }, ) .ok(); let Some((name, file_extension, file_size, source_path)) = base else { return Ok(None); }; let mut ctx = RuleContext { hash: hash.to_string(), name, source_path, file_extension, file_size: file_size.map(|v| v as f64), ..Default::default() }; // Analysis fields (optional, sample may be unanalyzed). let _ = conn.query_row( "SELECT duration, bpm, musical_key, is_loop, spectral_centroid, spectral_flatness, spectral_rolloff, zero_crossing_rate, spectral_bandwidth, centroid_variance, crest_factor, attack_time, peak_db, rms_db, lufs, sample_rate, channels FROM audio_analysis WHERE hash = ?1", [hash], |row| { ctx.duration = row.get(0)?; ctx.bpm = row.get(1)?; ctx.musical_key = row.get(2)?; ctx.is_loop = row.get::<_, Option>(3)?; ctx.spectral_centroid = row.get(4)?; ctx.spectral_flatness = row.get(5)?; ctx.spectral_rolloff = row.get(6)?; ctx.zcr = row.get(7)?; ctx.spectral_bandwidth = row.get(8)?; ctx.centroid_variance = row.get(9)?; ctx.crest_factor = row.get(10)?; ctx.attack_time = row.get(11)?; ctx.peak_db = row.get(12)?; ctx.rms_db = row.get(13)?; ctx.lufs = row.get(14)?; ctx.sample_rate = row.get::<_, Option>(15)?.map(|v| v as f64); ctx.channels = row.get::<_, Option>(16)?.map(|v| v as f64); Ok(()) }, ); // Tags. { let mut stmt = conn.prepare("SELECT tag FROM tags WHERE sample_hash = ?1")?; ctx.tags = stmt .query_map([hash], |row| row.get(0))? .collect::, _>>()?; } // VFS directory paths containing this sample (parent chain of each linking node). ctx.vfs_paths = sample_vfs_paths(db, hash)?; Ok(Some(ctx)) } /// Directory paths (e.g. `Drums/808 Kicks`) of every VFS location linking this sample. fn sample_vfs_paths(db: &Database, hash: &str) -> Result> { let mut stmt = db.conn().prepare( "WITH RECURSIVE chain(node_id, parent_id, name, depth) AS ( SELECT vn.id, vn.parent_id, p.name, 0 FROM vfs_nodes vn JOIN vfs_nodes p ON p.id = vn.parent_id WHERE vn.sample_hash = ?1 AND vn.node_type = 'sample' UNION ALL SELECT c.node_id, p.parent_id, p.name, c.depth + 1 FROM chain c JOIN vfs_nodes p ON p.id = c.parent_id ) SELECT node_id, group_concat(name, '/') FROM ( SELECT node_id, name, depth FROM chain ORDER BY node_id, depth DESC ) GROUP BY node_id", )?; let rows = stmt.query_map([hash], |row| row.get::<_, Option>(1))?; let mut paths = Vec::new(); for r in rows { if let Some(p) = r? { paths.push(p); } } Ok(paths) } // ── CRUD ── fn parse_rule(row: &rusqlite::Row) -> rusqlite::Result { let conditions_json: String = row.get(5)?; let actions_json: String = row.get(6)?; Ok(Rule { id: row.get(0)?, name: row.get(1)?, enabled: row.get(2)?, priority: row.get(3)?, match_mode: serde_json::from_str(&row.get::<_, String>(4)?).unwrap_or(MatchMode::All), conditions: serde_json::from_str(&conditions_json).unwrap_or_default(), actions: serde_json::from_str(&actions_json).unwrap_or_default(), created_at: row.get(7)?, }) } const RULE_COLUMNS: &str = "id, name, enabled, priority, match_mode, conditions, actions, created_at"; /// List all rules ordered by priority (evaluation order). #[instrument(skip_all)] pub fn list_rules(db: &Database) -> Result> { let sql = format!("SELECT {RULE_COLUMNS} FROM tag_rules ORDER BY priority, created_at"); let mut stmt = db.conn().prepare(&sql)?; let rows = stmt.query_map([], parse_rule)?; Ok(rows.collect::, _>>()?) } /// Fetch a single rule by id. #[instrument(skip_all)] pub fn get_rule(db: &Database, id: &str) -> Result> { let sql = format!("SELECT {RULE_COLUMNS} FROM tag_rules WHERE id = ?1"); Ok(db.conn().query_row(&sql, [id], parse_rule).ok()) } /// Serialize a rule's JSON columns. Shared by insert and update. fn rule_json(rule: &Rule) -> Result<(String, String, String)> { let match_mode = serde_json::to_string(&rule.match_mode) .map_err(|e| CoreError::Serialization(e.to_string()))?; let conditions = serde_json::to_string(&rule.conditions) .map_err(|e| CoreError::Serialization(e.to_string()))?; let actions = serde_json::to_string(&rule.actions) .map_err(|e| CoreError::Serialization(e.to_string()))?; Ok((match_mode, conditions, actions)) } /// Insert a brand-new rule. Plain INSERT (not INSERT OR REPLACE): a primary-key /// collision is a real error, not a silent clobber of an existing rule. fn insert_rule(db: &Database, rule: &Rule) -> Result<()> { let (match_mode, conditions, actions) = rule_json(rule)?; db.conn().execute( "INSERT INTO tag_rules (id, name, enabled, priority, match_mode, conditions, actions, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![ rule.id, rule.name, rule.enabled, rule.priority, match_mode, conditions, actions, rule.created_at, ], )?; Ok(()) } /// Create a rule. Validates any literal tags in its actions up front. #[instrument(skip_all)] pub fn create_rule(db: &Database, new: NewRule) -> Result { for action in &new.actions { if let RuleAction::AddTag(tag) | RuleAction::RemoveTag(tag) = action { validate_tag(tag)?; } } let priority = match new.priority { Some(p) => p, None => db .conn() .query_row( "SELECT COALESCE(MAX(priority), -1) + 1 FROM tag_rules", [], |r| r.get(0), ) .unwrap_or(0), }; let rule = Rule { id: new_rule_id(), name: new.name, enabled: new.enabled, priority, match_mode: new.match_mode, conditions: new.conditions, actions: new.actions, created_at: now_secs(), }; insert_rule(db, &rule)?; Ok(rule) } /// Replace an existing rule (matched by `id`). #[instrument(skip_all)] pub fn update_rule(db: &Database, rule: &Rule) -> Result<()> { for action in &rule.actions { if let RuleAction::AddTag(tag) | RuleAction::RemoveTag(tag) = action { validate_tag(tag)?; } } let (match_mode, conditions, actions) = rule_json(rule)?; // Real UPDATE (not INSERT OR REPLACE): updating a deleted/unknown id must // error, not silently resurrect the rule. created_at is preserved (not in // the SET list). let changed = db.conn().execute( "UPDATE tag_rules SET name = ?2, enabled = ?3, priority = ?4, match_mode = ?5, conditions = ?6, actions = ?7 WHERE id = ?1", rusqlite::params![ rule.id, rule.name, rule.enabled, rule.priority, match_mode, conditions, actions, ], )?; if changed == 0 { return Err(CoreError::RuleNotFound(rule.id.clone())); } Ok(()) } /// Toggle a rule's enabled flag and reconcile affected samples. /// /// Toggling membership without reconciling leaves stale `source = 'rule'` tags /// applied (when disabling) or missing (when enabling) until some unrelated /// `apply_*` runs. This mirrors [`delete_rule`]: the flag flip and the /// reconciliation run in one transaction so membership is never observed /// half-updated. Disabling only touches samples the rule had tagged; enabling /// re-evaluates the whole library, since the rule may now match samples it never /// tagged before. #[instrument(skip_all)] pub fn set_rule_enabled(db: &Database, id: &str, enabled: bool) -> Result<()> { let previously_tagged = samples_tagged_by_rule(db, id)?; db.transaction_core(|tx| { db.conn().execute( "UPDATE tag_rules SET enabled = ?2 WHERE id = ?1", rusqlite::params![id, enabled], )?; let rules = list_rules(db)?; let to_reconcile: Vec = if enabled { let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?; stmt.query_map([], |row| row.get(0))? .collect::, _>>()? } else { previously_tagged }; for hash in &to_reconcile { reconcile_sample(db, tx, hash, &rules)?; } Ok(()) })?; Ok(()) } /// Delete a rule and reconcile the samples it had tagged (its rule-sourced tags /// are removed unless another enabled rule still produces them). #[instrument(skip_all)] pub fn delete_rule(db: &Database, id: &str) -> Result<()> { let affected = samples_tagged_by_rule(db, id)?; // One transaction for the rule delete + reconciling every affected sample, // instead of an autocommit per sample. The rule must be deleted *before* // re-listing, so reconcile evaluates against the rule set without it and // removes its now-orphaned tags. db.transaction_core(|tx| { db.conn() .execute("DELETE FROM tag_rules WHERE id = ?1", [id])?; let rules = list_rules(db)?; for hash in &affected { reconcile_sample(db, tx, hash, &rules)?; } Ok(()) })?; Ok(()) } fn samples_tagged_by_rule(db: &Database, id: &str) -> Result> { let mut stmt = db.conn().prepare( "SELECT DISTINCT sample_hash FROM tag_provenance WHERE source = 'rule' AND rule_id = ?1", )?; let rows = stmt.query_map([id], |row| row.get(0))?; Ok(rows.collect::, _>>()?) } // ── Application / reconciliation ── /// Re-evaluate `rules` for one sample and reconcile the tag table + provenance. /// /// Only rule-sourced tags are added/removed; manual tags (no provenance row) are /// never touched. Returns `true` if any tag changed. fn reconcile_sample(db: &Database, _tx: &Tx, hash: &str, rules: &[Rule]) -> Result { let Some(ctx) = load_context(db, hash)? else { return Ok(false); }; let desired = evaluate(rules, &ctx); let desired_tags: std::collections::HashSet<&str> = desired.iter().map(|(t, _)| t.as_str()).collect(); let conn = db.conn(); // Current rule-sourced tags for this sample. let current_rule_tags: Vec = { let mut stmt = conn .prepare("SELECT tag FROM tag_provenance WHERE sample_hash = ?1 AND source = 'rule'")?; stmt.query_map([hash], |row| row.get(0))? .collect::, _>>()? }; let existing_tags: std::collections::HashSet = { let mut stmt = conn.prepare("SELECT tag FROM tags WHERE sample_hash = ?1")?; stmt.query_map([hash], |row| row.get(0))? .collect::, _>>()? }; let mut changed = false; // Remove rule tags no longer desired. for tag in ¤t_rule_tags { if !desired_tags.contains(tag.as_str()) { conn.execute( "DELETE FROM tags WHERE sample_hash = ?1 AND tag = ?2", rusqlite::params![hash, tag], )?; conn.execute( "DELETE FROM tag_provenance WHERE sample_hash = ?1 AND tag = ?2", rusqlite::params![hash, tag], )?; changed = true; } } // Add newly desired tags. A tag that already exists with NO rule provenance is // manual/legacy, leave it sticky and do not claim it. let current_rule_set: std::collections::HashSet<&str> = current_rule_tags .iter() .map(std::string::String::as_str) .collect(); for (tag, rule_id) in &desired { let already_manual = existing_tags.contains(tag) && !current_rule_set.contains(tag.as_str()); if already_manual { continue; } conn.execute( "INSERT OR IGNORE INTO tags (sample_hash, tag) VALUES (?1, ?2)", rusqlite::params![hash, tag], )?; conn.execute( "INSERT INTO tag_provenance (sample_hash, tag, source, rule_id) VALUES (?1, ?2, 'rule', ?3) ON CONFLICT(sample_hash, tag) DO UPDATE SET source = 'rule', rule_id = ?3", rusqlite::params![hash, tag, rule_id], )?; if !existing_tags.contains(tag) { changed = true; } } Ok(changed) } /// Apply all enabled rules to a single sample (e.g. after analysis/import). #[instrument(skip_all)] pub fn apply_rules_to_sample(db: &Database, hash: &str) -> Result { let rules = list_rules(db)?; db.transaction_core(|tx| reconcile_sample(db, tx, hash, &rules)) } /// Apply all enabled rules to a set of samples in a single transaction. The /// batched form of [`apply_rules_to_sample`]: callers processing many freshly /// analyzed samples reconcile them in one commit instead of one per sample. /// Returns the number of samples whose tags changed. #[instrument(skip_all)] pub fn apply_rules_to_samples(db: &Database, hashes: &[String]) -> Result { if hashes.is_empty() { return Ok(0); } let rules = list_rules(db)?; db.transaction_core(|tx| { let mut changed = 0; for hash in hashes { if reconcile_sample(db, tx, hash, &rules)? { changed += 1; } } Ok(changed) }) } /// Re-apply all rules across the whole library. Returns the number of samples changed. #[instrument(skip_all)] pub fn apply_all_rules(db: &Database) -> Result { let rules = list_rules(db)?; let hashes: Vec = { let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?; stmt.query_map([], |row| row.get(0))? .collect::, _>>()? }; db.transaction_core(|tx| { let mut changed = 0; for hash in hashes { if reconcile_sample(db, tx, &hash, &rules)? { changed += 1; } } Ok(changed) }) } /// Dry-run: how many (non-deleted) samples a rule's conditions would match. #[instrument(skip_all)] pub fn preview_rule_matches(db: &Database, rule: &Rule) -> Result { let hashes: Vec = { let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?; stmt.query_map([], |row| row.get(0))? .collect::, _>>()? }; let mut count = 0; for hash in hashes { if let Some(ctx) = load_context(db, &hash)? && rule_matches(rule, &ctx) { count += 1; } } Ok(count) } /// Apply a tag attributed to a non-rule machine source (`cluster`, `harvest`, `ml`). /// /// Sticky: the rules engine never reconciles these away (only `source = 'rule'` tags /// are regenerable). Existing provenance is preserved (`INSERT OR IGNORE`), so this /// won't downgrade a tag that is already rule-sourced or manual. Returns `true` if the /// tag was newly added to the sample. /// Requires a [`Tx`]: machine-source tagging is always a bulk operation (a whole /// cluster / harvest / ML pass), so callers must batch the loop in one /// transaction rather than autocommitting per sample. #[instrument(skip_all)] pub fn apply_tag_sourced( db: &Database, _tx: &Tx, hash: &str, tag: &str, source: &str, ) -> Result { validate_tag(tag)?; let conn = db.conn(); let added = conn.execute( "INSERT OR IGNORE INTO tags (sample_hash, tag) VALUES (?1, ?2)", rusqlite::params![hash, tag], )? > 0; conn.execute( "INSERT OR IGNORE INTO tag_provenance (sample_hash, tag, source, rule_id) VALUES (?1, ?2, ?3, NULL)", rusqlite::params![hash, tag, source], )?; Ok(added) } /// Remove every tag applied by a given source (e.g. undo a clustering pass). Only /// affects tags whose provenance matches `source`; manual tags are untouched. #[instrument(skip_all)] pub fn remove_tags_by_source(db: &Database, source: &str) -> Result { // Two dependent deletes (tags, then their provenance) in one transaction, so a // crash between them can't orphan provenance rows from their tags. db.transaction_core(|_tx| { let conn = db.conn(); let removed = conn.execute( "DELETE FROM tags WHERE (sample_hash, tag) IN (SELECT sample_hash, tag FROM tag_provenance WHERE source = ?1)", rusqlite::params![source], )?; conn.execute( "DELETE FROM tag_provenance WHERE source = ?1", rusqlite::params![source], )?; Ok(removed) }) } /// Provenance of each tag on a sample: `(tag, source, rule_id)`. Tags absent from /// the result (but present on the sample) are manual. #[instrument(skip_all)] pub fn sample_tag_provenance( db: &Database, hash: &str, ) -> Result)>> { let mut stmt = db.conn().prepare( "SELECT tag, source, rule_id FROM tag_provenance WHERE sample_hash = ?1 ORDER BY tag", )?; let rows = stmt.query_map([hash], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; Ok(rows.collect::, _>>()?) } #[cfg(test)] mod tests { use super::*; fn db_with_sample(hash: &str, name: &str) -> Database { let db = Database::open_in_memory().unwrap(); db.conn() .execute( "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \ VALUES (?1, ?2, 'wav', 1000, 0, 0)", rusqlite::params![hash, name], ) .unwrap(); db } fn cond(field: RuleField, op: RuleOp, value: &str) -> RuleCondition { RuleCondition { field, op, value: value.to_string(), } } fn new_rule(name: &str, conds: Vec, acts: Vec) -> NewRule { NewRule { name: name.to_string(), enabled: true, priority: None, match_mode: MatchMode::All, conditions: conds, actions: acts, } } #[test] fn name_contains_applies_tag() { let db = db_with_sample("h1", "808 Kick Loud.wav"); create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); assert!(apply_rules_to_sample(&db, "h1").unwrap()); let tags = crate::tags::get_sample_tags(&db, "h1").unwrap(); assert_eq!(tags, vec!["instrument.drum.kick"]); let prov = sample_tag_provenance(&db, "h1").unwrap(); assert_eq!(prov.len(), 1); assert_eq!(prov[0].1, "rule"); } #[test] fn numeric_condition_on_analysis() { let db = db_with_sample("h2", "loop.wav"); db.conn() .execute( "INSERT INTO audio_analysis (hash, duration, sample_rate, channels, bpm, analyzed_at) \ VALUES ('h2', 4.0, 44100, 2, 128.0, 0)", [], ) .unwrap(); create_rule( &db, new_rule( "fast", vec![cond(RuleField::Bpm, RuleOp::Ge, "120")], vec![RuleAction::AddTag("tempo.fast".into())], ), ) .unwrap(); apply_rules_to_sample(&db, "h2").unwrap(); assert!( crate::tags::get_sample_tags(&db, "h2") .unwrap() .contains(&"tempo.fast".to_string()) ); } #[test] fn manual_tags_are_sticky() { let db = db_with_sample("h3", "kick.wav"); crate::tags::add_tag(&db, "h3", "manual.keep").unwrap(); let rule = create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); apply_rules_to_sample(&db, "h3").unwrap(); // Deleting the rule must remove its tag but keep the manual one. delete_rule(&db, &rule.id).unwrap(); let tags = crate::tags::get_sample_tags(&db, "h3").unwrap(); assert_eq!(tags, vec!["manual.keep"]); } #[test] fn reconcile_removes_tags_when_rule_no_longer_matches() { let db = db_with_sample("h4", "kick.wav"); let mut rule = create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); apply_rules_to_sample(&db, "h4").unwrap(); assert!(!crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty()); // Narrow the rule so it no longer matches, then reconcile. rule.conditions = vec![cond(RuleField::Name, RuleOp::Contains, "snare")]; update_rule(&db, &rule).unwrap(); apply_rules_to_sample(&db, "h4").unwrap(); assert!(crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty()); } #[test] fn toggling_enabled_reconciles_membership() { let db = db_with_sample("h6", "kick.wav"); let rule = create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); apply_rules_to_sample(&db, "h6").unwrap(); assert!(!crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty()); // Disabling must remove the rule-sourced tag immediately (no separate // apply_* call), not leave it stale. set_rule_enabled(&db, &rule.id, false).unwrap(); assert!(crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty()); // Re-enabling must re-apply it across the library, again without an // explicit apply_* call. set_rule_enabled(&db, &rule.id, true).unwrap(); assert!( crate::tags::get_sample_tags(&db, "h6") .unwrap() .contains(&"instrument.drum.kick".to_string()) ); } #[test] fn update_unknown_rule_errors_not_resurrects() { let db = db_with_sample("h7", "kick.wav"); let rule = create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); delete_rule(&db, &rule.id).unwrap(); // Updating the now-deleted rule must error, not silently re-insert it. assert!(matches!( update_rule(&db, &rule), Err(CoreError::RuleNotFound(_)) )); assert!(get_rule(&db, &rule.id).unwrap().is_none()); } #[test] fn match_mode_any_vs_all() { let db = db_with_sample("h5", "snare hit.wav"); let any = create_rule( &db, NewRule { match_mode: MatchMode::Any, ..new_rule( "any", vec![ cond(RuleField::Name, RuleOp::Contains, "kick"), cond(RuleField::Name, RuleOp::Contains, "snare"), ], vec![RuleAction::AddTag("matched.any".into())], ) }, ) .unwrap(); assert_eq!(preview_rule_matches(&db, &any).unwrap(), 1); let all = Rule { match_mode: MatchMode::All, ..any }; assert_eq!(preview_rule_matches(&db, &all).unwrap(), 0); } #[test] fn stop_action_halts_later_rules() { let db = db_with_sample("h6", "kick.wav"); create_rule( &db, NewRule { priority: Some(0), ..new_rule( "first", vec![], vec![RuleAction::AddTag("a.first".into()), RuleAction::Stop], ) }, ) .unwrap(); create_rule( &db, NewRule { priority: Some(1), ..new_rule( "second", vec![], vec![RuleAction::AddTag("a.second".into())], ) }, ) .unwrap(); apply_rules_to_sample(&db, "h6").unwrap(); let tags = crate::tags::get_sample_tags(&db, "h6").unwrap(); assert_eq!(tags, vec!["a.first"]); } #[test] fn rules_round_trip_through_db() { let db = db_with_sample("h7", "x.wav"); let created = create_rule( &db, new_rule( "complex", vec![ cond(RuleField::SpectralFlatness, RuleOp::Lt, "0.2"), cond(RuleField::Tag, RuleOp::StartsWith, "instrument.drum"), ], vec![ RuleAction::AddTag("character.tonal".into()), RuleAction::Stop, ], ), ) .unwrap(); let fetched = get_rule(&db, &created.id).unwrap().unwrap(); assert_eq!(created, fetched); } #[test] fn empty_ruleset_is_noop() { let db = db_with_sample("h8", "kick.wav"); assert!(!apply_rules_to_sample(&db, "h8").unwrap()); assert!(crate::tags::get_sample_tags(&db, "h8").unwrap().is_empty()); } // ── Operator / field matrix ── // // These exercise `eval_condition` directly against a hand-built `RuleContext`, // covering the cross-product of value kind (string / numeric / boolean / list) // and operator, plus the missing-value and inapplicable-operator edges that // never reach a DB. /// One condition against a context. fn eval(ctx: &RuleContext, field: RuleField, op: RuleOp, value: &str) -> bool { eval_condition(ctx, &cond(field, op, value)) } #[test] fn str_op_is_case_insensitive_over_all_string_ops() { // Positive ops fold case on both sides. assert_eq!(str_op(RuleOp::Contains, "Kick DRUM", "kick"), Some(true)); assert_eq!(str_op(RuleOp::Contains, "snare", "KICK"), Some(false)); assert_eq!(str_op(RuleOp::Equals, "WaV", "wav"), Some(true)); assert_eq!(str_op(RuleOp::Equals, "wave", "wav"), Some(false)); assert_eq!(str_op(RuleOp::StartsWith, "808_Kick", "808"), Some(true)); assert_eq!(str_op(RuleOp::StartsWith, "kick", "808"), Some(false)); assert_eq!(str_op(RuleOp::EndsWith, "loop.WAV", ".wav"), Some(true)); assert_eq!(str_op(RuleOp::EndsWith, "loop.aif", ".wav"), Some(false)); // Negative ops are the logical inverse. assert_eq!(str_op(RuleOp::NotContains, "snare", "kick"), Some(true)); assert_eq!(str_op(RuleOp::NotContains, "Kick", "kick"), Some(false)); assert_eq!(str_op(RuleOp::NotEquals, "snare", "kick"), Some(true)); assert_eq!(str_op(RuleOp::NotEquals, "KICK", "kick"), Some(false)); // Non-string ops are not str-applicable. for op in [RuleOp::Lt, RuleOp::Ge, RuleOp::IsTrue, RuleOp::Exists] { assert_eq!( str_op(op, "x", "y"), None, "{op:?} should not be str-applicable" ); } } #[test] fn num_op_covers_every_comparison_and_bad_input() { assert!(num_op(RuleOp::Lt, 1.0, "2")); assert!(!num_op(RuleOp::Lt, 2.0, "2")); assert!(num_op(RuleOp::Le, 2.0, "2")); assert!(!num_op(RuleOp::Le, 3.0, "2")); assert!(num_op(RuleOp::Gt, 3.0, "2")); assert!(!num_op(RuleOp::Gt, 2.0, "2")); assert!(num_op(RuleOp::Ge, 2.0, "2")); assert!(!num_op(RuleOp::Ge, 1.0, "2")); assert!(num_op(RuleOp::Equals, 2.0, "2")); assert!(!num_op(RuleOp::Equals, 2.5, "2")); assert!(num_op(RuleOp::NotEquals, 2.5, "2")); assert!(!num_op(RuleOp::NotEquals, 2.0, "2")); // Whitespace in the operand is tolerated. assert!(num_op(RuleOp::Ge, 128.0, " 120 ")); // Unparseable operand never matches, for any op. for op in [ RuleOp::Lt, RuleOp::Le, RuleOp::Gt, RuleOp::Ge, RuleOp::Equals, RuleOp::NotEquals, ] { assert!( !num_op(op, 1.0, "notanumber"), "{op:?} should fail on bad operand" ); } // String-only ops are not numeric-applicable. assert!(!num_op(RuleOp::Contains, 1.0, "1")); assert!(!num_op(RuleOp::StartsWith, 1.0, "1")); } #[test] fn num_op_equals_uses_relative_epsilon() { // Exact hits and values within the relative tolerance are equal. assert!(num_op(RuleOp::Equals, 44100.0, "44100")); assert!(num_op(RuleOp::Equals, 1_000_000.0, "1000000.00005")); assert!(!num_op(RuleOp::Equals, 1_000_000.0, "1000001")); } #[test] fn string_field_present_matrix() { let ctx = RuleContext { name: "808 Kick.wav".into(), ..Default::default() }; assert!(eval(&ctx, RuleField::Name, RuleOp::Contains, "kick")); assert!(!eval(&ctx, RuleField::Name, RuleOp::Contains, "snare")); assert!(eval(&ctx, RuleField::Name, RuleOp::StartsWith, "808")); assert!(eval(&ctx, RuleField::Name, RuleOp::EndsWith, ".wav")); assert!(eval(&ctx, RuleField::Name, RuleOp::NotContains, "snare")); assert!(eval(&ctx, RuleField::Name, RuleOp::NotEquals, "other")); assert!(eval(&ctx, RuleField::Name, RuleOp::Exists, "")); assert!(!eval(&ctx, RuleField::Name, RuleOp::NotExists, "")); // A numeric operator on a string field never matches. assert!(!eval(&ctx, RuleField::Name, RuleOp::Gt, "0")); assert!(!eval(&ctx, RuleField::Name, RuleOp::IsTrue, "")); } #[test] fn string_field_missing_matrix() { // source_path is None: positive ops fail, negative ops hold, existence flips. let ctx = RuleContext::default(); assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Contains, "x")); assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Equals, "x")); assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::StartsWith, "x")); assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotContains, "x")); assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotEquals, "x")); assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Exists, "")); assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotExists, "")); } #[test] fn numeric_field_present_and_missing_matrix() { let ctx = RuleContext { bpm: Some(128.0), ..Default::default() }; assert!(eval(&ctx, RuleField::Bpm, RuleOp::Gt, "120")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::Ge, "128")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::Le, "128")); assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Lt, "128")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::Equals, "128")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::NotEquals, "120")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::Exists, "")); assert!(!eval(&ctx, RuleField::Bpm, RuleOp::NotExists, "")); // A string operator on a numeric field never matches. assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Contains, "12")); // Missing numeric: every comparison fails, only NotExists holds. let empty = RuleContext::default(); for op in [ RuleOp::Lt, RuleOp::Le, RuleOp::Gt, RuleOp::Ge, RuleOp::Equals, RuleOp::NotEquals, ] { assert!( !eval(&empty, RuleField::Bpm, op, "128"), "{op:?} on missing num" ); } assert!(!eval(&empty, RuleField::Bpm, RuleOp::Exists, "")); assert!(eval(&empty, RuleField::Bpm, RuleOp::NotExists, "")); } #[test] fn boolean_field_matrix() { let t = RuleContext { is_loop: Some(true), ..Default::default() }; let f = RuleContext { is_loop: Some(false), ..Default::default() }; let n = RuleContext::default(); assert!(eval(&t, RuleField::IsLoop, RuleOp::IsTrue, "")); assert!(!eval(&t, RuleField::IsLoop, RuleOp::IsFalse, "")); assert!(eval(&f, RuleField::IsLoop, RuleOp::IsFalse, "")); assert!(!eval(&f, RuleField::IsLoop, RuleOp::IsTrue, "")); assert!(eval(&t, RuleField::IsLoop, RuleOp::Exists, "")); assert!(eval(&n, RuleField::IsLoop, RuleOp::NotExists, "")); assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsTrue, "")); assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsFalse, "")); // Non-boolean operators never match a boolean field. assert!(!eval(&t, RuleField::IsLoop, RuleOp::Contains, "true")); assert!(!eval(&t, RuleField::IsLoop, RuleOp::Gt, "0")); } #[test] fn list_field_matrix() { let ctx = RuleContext { tags: vec!["instrument.drum.kick".into(), "character.punchy".into()], ..Default::default() }; // Positive ops match if ANY element satisfies. assert!(eval(&ctx, RuleField::Tag, RuleOp::Contains, "drum")); assert!(eval(&ctx, RuleField::Tag, RuleOp::StartsWith, "instrument")); assert!(eval( &ctx, RuleField::Tag, RuleOp::Equals, "character.punchy" )); assert!(!eval(&ctx, RuleField::Tag, RuleOp::Contains, "bass")); // Negative ops hold only when NO element matches the positive form. assert!(eval(&ctx, RuleField::Tag, RuleOp::NotContains, "bass")); assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotContains, "drum")); assert!(eval(&ctx, RuleField::Tag, RuleOp::NotEquals, "nope")); assert!(!eval( &ctx, RuleField::Tag, RuleOp::NotEquals, "character.punchy" )); // Existence tracks emptiness. assert!(eval(&ctx, RuleField::Tag, RuleOp::Exists, "")); assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotExists, "")); let empty = RuleContext::default(); assert!(!eval(&empty, RuleField::Tag, RuleOp::Exists, "")); assert!(eval(&empty, RuleField::Tag, RuleOp::NotExists, "")); // A negative op over an empty list vacuously holds; a positive op does not. assert!(eval(&empty, RuleField::Tag, RuleOp::NotContains, "x")); assert!(!eval(&empty, RuleField::Tag, RuleOp::Contains, "x")); // Inapplicable operator on a list never matches. assert!(!eval(&ctx, RuleField::Tag, RuleOp::Gt, "0")); assert!(!eval(&ctx, RuleField::Tag, RuleOp::IsTrue, "")); } #[test] fn match_mode_all_vs_any_over_conditions() { let ctx = RuleContext { name: "kick".into(), bpm: Some(90.0), ..Default::default() }; let conds = vec![ cond(RuleField::Name, RuleOp::Contains, "kick"), // true cond(RuleField::Bpm, RuleOp::Gt, "120"), // false ]; let rule = |mode| Rule { id: "r".into(), name: "r".into(), enabled: true, priority: 0, match_mode: mode, conditions: conds.clone(), actions: vec![], created_at: 0, }; assert!(!rule_matches(&rule(MatchMode::All), &ctx)); assert!(rule_matches(&rule(MatchMode::Any), &ctx)); } #[test] fn empty_conditions_match_unconditionally() { let rule = Rule { id: "r".into(), name: "r".into(), enabled: true, priority: 0, match_mode: MatchMode::All, conditions: vec![], actions: vec![], created_at: 0, }; assert!(rule_matches(&rule, &RuleContext::default())); } }