Skip to main content

max / audiofiles

Add exemplar k-NN tag suggestion (classifier Phase 3) Layer B: the user's labeled library is the model. Score a sample by its nearest labeled neighbors in standardized feature space and suggest tags. - analysis/exemplar.rs: build_index over tagged sample_features (z-standardized; excludes source='ml' tags so the layer never trains on its own output); soft weighted multi-label scoring (adaptive Gaussian bandwidth; per-tag score = neighbor-weight share) with the driving neighbors surfaced for explainability; preview_sample / apply_ml_suggestions (auto-applies source='ml'); get/set/list_policy. - Migration 023 tag_policy: per-tag review/auto thresholds (auto >= review; equal = binary; absent = in-code defaults 0.5/0.85). Sync triggers + registered in audiofiles-sync. Core 510, sync 47, browser 207 green; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-14 23:13 UTC
Commit: 4d2ed15ee65f53d3dd15e1bb9a34664fa63e849b
Parent: 808fa0a
6 files changed, +561 insertions, -8 deletions
@@ -0,0 +1,494 @@
1 + //! Exemplar k-NN tag suggestion (Layer B of the hybrid classifier).
2 + //!
3 + //! The user's labeled library *is* the model: every sample carrying a non-ML tag is an
4 + //! exemplar. A query sample is scored by its nearest labeled neighbors in standardized
5 + //! feature space; each candidate tag gets a soft, similarity-weighted multi-label score
6 + //! in [0, 1]. Per-tag dual thresholds (`tag_policy`) split scores into auto-apply /
7 + //! review / ignore.
8 + //!
9 + //! ML-applied tags (`source = 'ml'`) are excluded from the exemplar labels so the layer
10 + //! never trains on its own output. Suggestions carry their driving neighbors for
11 + //! explainability ("looks like these kicks you have"). See [`crate::rules`] for provenance.
12 +
13 + use std::collections::HashMap;
14 +
15 + use crate::db::Database;
16 + use crate::error::{CoreError, Result};
17 + use tracing::instrument;
18 +
19 + use super::classify::{FEATURE_VERSION, NUM_FEATURES};
20 +
21 + /// Default neighbor count.
22 + pub const DEFAULT_K: usize = 15;
23 + /// Default score at/above which a tag is queued for review.
24 + pub const DEFAULT_REVIEW_THRESHOLD: f64 = 0.5;
25 + /// Default score at/above which a tag is auto-applied.
26 + pub const DEFAULT_AUTO_THRESHOLD: f64 = 0.85;
27 +
28 + // ── Per-tag policy ──
29 +
30 + /// Confidence thresholds for a tag. `auto >= review`; setting them equal collapses to
31 + /// binary behaviour (auto-apply above, ignore below, no review band).
32 + #[derive(Debug, Clone, Copy, PartialEq)]
33 + pub struct TagPolicy {
34 + pub review_threshold: f64,
35 + pub auto_threshold: f64,
36 + }
37 +
38 + impl Default for TagPolicy {
39 + fn default() -> Self {
40 + Self {
41 + review_threshold: DEFAULT_REVIEW_THRESHOLD,
42 + auto_threshold: DEFAULT_AUTO_THRESHOLD,
43 + }
44 + }
45 + }
46 +
47 + /// Fetch a tag's policy, or the default if none is configured.
48 + #[instrument(skip_all)]
49 + pub fn get_policy(db: &Database, tag: &str) -> Result<TagPolicy> {
50 + let policy = db
51 + .conn()
52 + .query_row(
53 + "SELECT review_threshold, auto_threshold FROM tag_policy WHERE tag = ?1",
54 + [tag],
55 + |row| Ok(TagPolicy { review_threshold: row.get(0)?, auto_threshold: row.get(1)? }),
56 + )
57 + .ok()
58 + .unwrap_or_default();
59 + Ok(policy)
60 + }
61 +
62 + /// Set a tag's thresholds. `auto` is clamped to be >= `review`, both into [0, 1].
63 + #[instrument(skip_all)]
64 + pub fn set_policy(db: &Database, tag: &str, review: f64, auto: f64) -> Result<()> {
65 + let review = review.clamp(0.0, 1.0);
66 + let auto = auto.clamp(review, 1.0);
67 + db.conn().execute(
68 + "INSERT INTO tag_policy (tag, review_threshold, auto_threshold) VALUES (?1, ?2, ?3)
69 + ON CONFLICT(tag) DO UPDATE SET review_threshold = ?2, auto_threshold = ?3",
70 + rusqlite::params![tag, review, auto],
71 + )?;
72 + Ok(())
73 + }
74 +
75 + /// All configured per-tag policies.
76 + #[instrument(skip_all)]
77 + pub fn list_policies(db: &Database) -> Result<Vec<(String, TagPolicy)>> {
78 + let mut stmt = db
79 + .conn()
80 + .prepare("SELECT tag, review_threshold, auto_threshold FROM tag_policy ORDER BY tag")?;
81 + let rows = stmt.query_map([], |row| {
82 + Ok((
83 + row.get::<_, String>(0)?,
84 + TagPolicy { review_threshold: row.get(1)?, auto_threshold: row.get(2)? },
85 + ))
86 + })?;
87 + Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
88 + }
89 +
90 + // ── Index + scoring ──
91 +
92 + struct Exemplar {
93 + hash: String,
94 + /// Standardized feature vector.
95 + vector: Vec<f64>,
96 + tags: Vec<String>,
97 + }
98 +
99 + /// In-memory k-NN index over the labeled library. Rebuild after tag changes.
100 + pub struct ExemplarIndex {
101 + means: Vec<f64>,
102 + stds: Vec<f64>,
103 + exemplars: Vec<Exemplar>,
104 + pub feature_version: u32,
105 + }
106 +
107 + /// A neighbor that contributed to a tag's score.
108 + #[derive(Debug, Clone)]
109 + pub struct NeighborContribution {
110 + pub hash: String,
111 + pub weight: f64,
112 + }
113 +
114 + /// A candidate tag with its similarity-weighted score and the neighbors behind it.
115 + #[derive(Debug, Clone)]
116 + pub struct TagScore {
117 + pub tag: String,
118 + pub score: f64,
119 + pub neighbors: Vec<NeighborContribution>,
120 + }
121 +
122 + /// Whether a scored tag should be auto-applied or queued for review.
123 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
124 + pub enum MlAction {
125 + AutoApply,
126 + Review,
127 + }
128 +
129 + /// A suggestion with its disposition under the per-tag policy.
130 + #[derive(Debug, Clone)]
131 + pub struct MlSuggestion {
132 + pub tag: String,
133 + pub score: f64,
134 + pub action: MlAction,
135 + pub neighbors: Vec<NeighborContribution>,
136 + }
137 +
138 + /// Auto-applied vs review-queued suggestions for a sample.
139 + #[derive(Debug, Clone, Default)]
140 + pub struct MlOutcome {
141 + pub auto_applied: Vec<MlSuggestion>,
142 + pub pending_review: Vec<MlSuggestion>,
143 + }
144 +
145 + /// Build the index from every sample carrying at least one non-ML tag.
146 + #[instrument(skip_all)]
147 + pub fn build_index(db: &Database) -> Result<ExemplarIndex> {
148 + let conn = db.conn();
149 +
150 + // Non-ML tags per sample (the trusted labels).
151 + let mut tags_by_hash: HashMap<String, Vec<String>> = HashMap::new();
152 + {
153 + let mut stmt = conn.prepare(
154 + "SELECT t.sample_hash, t.tag FROM tags t
155 + WHERE NOT EXISTS (
156 + SELECT 1 FROM tag_provenance p
157 + WHERE p.sample_hash = t.sample_hash AND p.tag = t.tag AND p.source = 'ml')",
158 + )?;
159 + let rows = stmt.query_map([], |row| {
160 + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
161 + })?;
162 + for r in rows {
163 + let (hash, tag) = r?;
164 + tags_by_hash.entry(hash).or_default().push(tag);
165 + }
166 + }
167 +
168 + // Raw vectors for those samples.
169 + let mut raw: Vec<(String, Vec<f64>)> = Vec::new();
170 + {
171 + let mut stmt =
172 + conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?;
173 + let rows = stmt.query_map([FEATURE_VERSION], |row| {
174 + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
175 + })?;
176 + for r in rows {
177 + let (hash, json) = r?;
178 + if !tags_by_hash.contains_key(&hash) {
179 + continue;
180 + }
181 + let v: Vec<f64> = serde_json::from_str(&json)
182 + .map_err(|e| CoreError::Serialization(e.to_string()))?;
183 + if v.len() == NUM_FEATURES {
184 + raw.push((hash, v));
185 + }
186 + }
187 + }
188 +
189 + // Standardization params over the exemplar set.
190 + let (means, stds) = standardization_params(&raw);
191 + let exemplars = raw
192 + .into_iter()
193 + .map(|(hash, v)| {
194 + let vector = standardize_vec(&v, &means, &stds);
195 + let tags = tags_by_hash.remove(&hash).unwrap_or_default();
196 + Exemplar { hash, vector, tags }
197 + })
198 + .collect();
199 +
200 + Ok(ExemplarIndex { means, stds, exemplars, feature_version: FEATURE_VERSION })
201 + }
202 +
203 + fn standardization_params(raw: &[(String, Vec<f64>)]) -> (Vec<f64>, Vec<f64>) {
204 + if raw.is_empty() {
205 + return (vec![0.0; NUM_FEATURES], vec![1.0; NUM_FEATURES]);
206 + }
207 + let n = raw.len() as f64;
208 + let dims = raw[0].1.len();
209 + let mut means = vec![0.0; dims];
210 + let mut stds = vec![1.0; dims];
211 + for d in 0..dims {
212 + let mean = raw.iter().map(|(_, v)| v[d]).sum::<f64>() / n;
213 + let var = raw.iter().map(|(_, v)| (v[d] - mean).powi(2)).sum::<f64>() / n;
214 + means[d] = mean;
215 + let s = var.sqrt();
216 + stds[d] = if s > f64::EPSILON { s } else { 1.0 };
217 + }
218 + (means, stds)
219 + }
220 +
221 + fn standardize_vec(v: &[f64], means: &[f64], stds: &[f64]) -> Vec<f64> {
222 + v.iter()
223 + .zip(means)
224 + .zip(stds)
225 + .map(|((x, m), s)| (x - m) / s)
226 + .collect()
227 + }
228 +
229 + fn sq_dist(a: &[f64], b: &[f64]) -> f64 {
230 + a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum()
231 + }
232 +
233 + impl ExemplarIndex {
234 + pub fn len(&self) -> usize {
235 + self.exemplars.len()
236 + }
237 +
238 + pub fn is_empty(&self) -> bool {
239 + self.exemplars.is_empty()
240 + }
241 +
242 + /// Score candidate tags for a raw (un-standardized) query vector. `exclude_hash`
243 + /// drops the query's own exemplar (when scoring an already-labeled sample).
244 + pub fn score(&self, raw_query: &[f64], k: usize, exclude_hash: Option<&str>) -> Vec<TagScore> {
245 + if self.exemplars.is_empty() || raw_query.len() != NUM_FEATURES || k == 0 {
246 + return Vec::new();
247 + }
248 + let q = standardize_vec(raw_query, &self.means, &self.stds);
249 +
250 + // Distances to all eligible exemplars.
251 + let mut dists: Vec<(usize, f64)> = self
252 + .exemplars
253 + .iter()
254 + .enumerate()
255 + .filter(|(_, e)| exclude_hash != Some(e.hash.as_str()))
256 + .map(|(i, e)| (i, sq_dist(&q, &e.vector)))
257 + .collect();
258 + if dists.is_empty() {
259 + return Vec::new();
260 + }
261 + dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
262 + dists.truncate(k);
263 +
264 + // Adaptive Gaussian bandwidth = mean neighbor distance.
265 + let mean_d = (dists.iter().map(|(_, d2)| d2.sqrt()).sum::<f64>() / dists.len() as f64)
266 + .max(f64::EPSILON);
267 + let denom = 2.0 * mean_d * mean_d;
268 +
269 + let mut total_weight = 0.0;
270 + let weighted: Vec<(usize, f64)> = dists
271 + .iter()
272 + .map(|&(i, d2)| {
273 + let w = (-d2 / denom).exp();
274 + total_weight += w;
275 + (i, w)
276 + })
277 + .collect();
278 + if total_weight <= f64::EPSILON {
279 + return Vec::new();
280 + }
281 +
282 + // Accumulate per-tag weight + contributing neighbors.
283 + let mut acc: HashMap<&str, (f64, Vec<NeighborContribution>)> = HashMap::new();
284 + for &(i, w) in &weighted {
285 + let e = &self.exemplars[i];
286 + for tag in &e.tags {
287 + let slot = acc.entry(tag.as_str()).or_insert((0.0, Vec::new()));
288 + slot.0 += w;
289 + slot.1.push(NeighborContribution { hash: e.hash.clone(), weight: w });
290 + }
291 + }
292 +
293 + let mut scores: Vec<TagScore> = acc
294 + .into_iter()
295 + .map(|(tag, (sum_w, neighbors))| TagScore {
296 + tag: tag.to_string(),
297 + score: sum_w / total_weight,
298 + neighbors,
299 + })
300 + .collect();
301 + scores.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap().then(a.tag.cmp(&b.tag)));
302 + scores
303 + }
304 + }
305 +
306 + /// Load a sample's raw feature vector (current version), if present.
307 + fn load_query_vector(db: &Database, hash: &str) -> Result<Option<Vec<f64>>> {
308 + let json: Option<String> = db
309 + .conn()
310 + .query_row(
311 + "SELECT vector FROM sample_features WHERE hash = ?1 AND feat_version = ?2",
312 + rusqlite::params![hash, FEATURE_VERSION],
313 + |row| row.get(0),
314 + )
315 + .ok();
316 + match json {
317 + Some(j) => {
318 + let v: Vec<f64> =
319 + serde_json::from_str(&j).map_err(|e| CoreError::Serialization(e.to_string()))?;
320 + Ok(Some(v))
321 + }
322 + None => Ok(None),
323 + }
324 + }
325 +
326 + /// Split scored tags into auto-apply / review under each tag's policy, dropping tags the
327 + /// sample already has and anything below its review threshold.
328 + fn apply_policy(
329 + db: &Database,
330 + scores: Vec<TagScore>,
331 + existing: &std::collections::HashSet<String>,
332 + ) -> Result<MlOutcome> {
333 + let mut outcome = MlOutcome::default();
334 + for s in scores {
335 + if existing.contains(&s.tag) {
336 + continue;
337 + }
338 + let policy = get_policy(db, &s.tag)?;
339 + if s.score >= policy.auto_threshold {
340 + outcome.auto_applied.push(MlSuggestion {
341 + tag: s.tag,
342 + score: s.score,
343 + action: MlAction::AutoApply,
344 + neighbors: s.neighbors,
345 + });
346 + } else if s.score >= policy.review_threshold {
347 + outcome.pending_review.push(MlSuggestion {
348 + tag: s.tag,
349 + score: s.score,
350 + action: MlAction::Review,
351 + neighbors: s.neighbors,
352 + });
353 + }
354 + }
355 + Ok(outcome)
356 + }
357 +
358 + fn sample_tag_set(db: &Database, hash: &str) -> Result<std::collections::HashSet<String>> {
359 + Ok(crate::tags::get_sample_tags(db, hash)?.into_iter().collect())
360 + }
361 +
362 + /// Score a stored sample against the index without writing anything.
363 + #[instrument(skip_all)]
364 + pub fn preview_sample(
365 + db: &Database,
366 + hash: &str,
367 + index: &ExemplarIndex,
368 + k: usize,
369 + ) -> Result<MlOutcome> {
370 + let Some(raw) = load_query_vector(db, hash)? else {
371 + return Ok(MlOutcome::default());
372 + };
373 + let scores = index.score(&raw, k, Some(hash));
374 + let existing = sample_tag_set(db, hash)?;
375 + apply_policy(db, scores, &existing)
376 + }
377 +
378 + /// Score a stored sample and **auto-apply** the above-threshold tags (`source = 'ml'`),
379 + /// returning what was applied and what's pending review.
380 + #[instrument(skip_all)]
381 + pub fn apply_ml_suggestions(
382 + db: &Database,
383 + hash: &str,
384 + index: &ExemplarIndex,
385 + k: usize,
386 + ) -> Result<MlOutcome> {
387 + let outcome = preview_sample(db, hash, index, k)?;
388 + for s in &outcome.auto_applied {
389 + crate::rules::apply_tag_sourced(db, hash, &s.tag, "ml")?;
390 + }
391 + Ok(outcome)
392 + }
393 +
394 + #[cfg(test)]
395 + mod tests {
396 + use super::*;
397 +
398 + fn insert(db: &Database, hash: &str, vec: [f64; NUM_FEATURES], tags: &[&str]) {
399 + db.conn()
400 + .execute(
401 + "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
402 + VALUES (?1, ?1, 'wav', 1, 0, 0)",
403 + [hash],
404 + )
405 + .unwrap();
406 + let json = serde_json::to_string(&vec.to_vec()).unwrap();
407 + db.conn()
408 + .execute(
409 + "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)",
410 + rusqlite::params![hash, FEATURE_VERSION, json],
411 + )
412 + .unwrap();
413 + for t in tags {
414 + crate::tags::add_tag(db, hash, t).unwrap();
415 + }
416 + }
417 +
418 + fn vec_at(base: f64) -> [f64; NUM_FEATURES] {
419 + [base; NUM_FEATURES]
420 + }
421 +
422 + #[test]
423 + fn policy_defaults_and_clamping() {
424 + let db = Database::open_in_memory().unwrap();
425 + assert_eq!(get_policy(&db, "x").unwrap(), TagPolicy::default());
426 + // auto clamped to >= review.
427 + set_policy(&db, "x", 0.7, 0.3).unwrap();
428 + let p = get_policy(&db, "x").unwrap();
429 + assert_eq!(p.review_threshold, 0.7);
430 + assert_eq!(p.auto_threshold, 0.7);
431 + }
432 +
433 + #[test]
434 + fn empty_index_yields_nothing() {
435 + let db = Database::open_in_memory().unwrap();
436 + let index = build_index(&db).unwrap();
437 + assert!(index.is_empty());
438 + insert(&db, "q", vec_at(0.0), &[]);
439 + assert!(apply_ml_suggestions(&db, "q", &index, DEFAULT_K).unwrap().auto_applied.is_empty());
440 + }
441 +
442 + #[test]
443 + fn auto_applies_dominant_neighbor_tag() {
444 + let db = Database::open_in_memory().unwrap();
445 + // A tight cluster of kicks, plus one far-away snare.
446 + insert(&db, "k1", vec_at(0.0), &["instrument.drum.kick"]);
447 + insert(&db, "k2", vec_at(0.05), &["instrument.drum.kick"]);
448 + insert(&db, "k3", vec_at(-0.05), &["instrument.drum.kick"]);
449 + insert(&db, "s1", vec_at(100.0), &["instrument.drum.snare"]);
450 + // Unlabeled query right next to the kicks.
451 + insert(&db, "q", vec_at(0.02), &[]);
452 +
453 + let index = build_index(&db).unwrap();
454 + let outcome = apply_ml_suggestions(&db, "q", &index, DEFAULT_K).unwrap();
455 + let applied: Vec<&str> = outcome.auto_applied.iter().map(|s| s.tag.as_str()).collect();
456 + assert!(applied.contains(&"instrument.drum.kick"), "got {applied:?}");
457 + // The applied tag is provenance 'ml'.
458 + let prov = crate::rules::sample_tag_provenance(&db, "q").unwrap();
459 + assert!(prov.iter().any(|(t, s, _)| t == "instrument.drum.kick" && s == "ml"));
460 + // Neighbors are surfaced for explainability.
461 + let kick = outcome.auto_applied.iter().find(|s| s.tag == "instrument.drum.kick").unwrap();
462 + assert!(!kick.neighbors.is_empty());
463 + }
464 +
465 + #[test]
466 + fn ml_tags_excluded_from_exemplars() {
467 + let db = Database::open_in_memory().unwrap();
468 + // One real exemplar + one whose only tag is ML-sourced (must not count as label).
469 + insert(&db, "k1", vec_at(0.0), &["instrument.drum.kick"]);
470 + insert(&db, "m1", vec_at(0.0), &[]);
471 + crate::rules::apply_tag_sourced(&db, "m1", "bogus.ml", "ml").unwrap();
472 +
473 + let index = build_index(&db).unwrap();
474 + // Only k1 is a labeled exemplar (m1's lone tag is ML).
475 + assert_eq!(index.len(), 1);
476 + }
477 +
478 + #[test]
479 + fn review_band_between_thresholds() {
480 + let db = Database::open_in_memory().unwrap();
481 + // Two equidistant neighbors with different tags -> each ~0.5 score.
482 + insert(&db, "a", vec_at(0.0), &["x.a"]);
483 + insert(&db, "b", vec_at(10.0), &["x.b"]);
484 + insert(&db, "q", vec_at(5.0), &[]);
485 + // Lower review threshold so the ~0.5 tags surface for review, never auto.
486 + set_policy(&db, "x.a", 0.2, 0.99).unwrap();
487 + set_policy(&db, "x.b", 0.2, 0.99).unwrap();
488 +
489 + let index = build_index(&db).unwrap();
490 + let outcome = preview_sample(&db, "q", &index, DEFAULT_K).unwrap();
491 + assert!(outcome.auto_applied.is_empty());
492 + assert!(!outcome.pending_review.is_empty());
493 + }
494 + }
@@ -8,6 +8,7 @@ pub mod basic;
8 8 pub mod bpm;
9 9 pub mod classify;
10 10 pub mod cluster;
11 + pub mod exemplar;
11 12 pub mod config;
12 13 pub mod decode;
13 14 pub mod loop_detect;
@@ -1256,6 +1256,46 @@ BEGIN
1256 1256 END;
1257 1257 "#;
1258 1258
1259 + const MIGRATION_023: &str = r#"
1260 + -- Phase 3: per-tag ML thresholds (Layer B policy). Absent tag => default policy in code.
1261 + CREATE TABLE IF NOT EXISTS tag_policy (
1262 + tag TEXT PRIMARY KEY,
1263 + review_threshold REAL NOT NULL,
1264 + auto_threshold REAL NOT NULL
1265 + );
1266 +
1267 + -- Sync triggers (tag string is sensitive => hashed row_id, mirroring tags; UPDATE
1268 + -- supported because set_policy upserts).
1269 + CREATE TRIGGER IF NOT EXISTS sync_tag_policy_insert AFTER INSERT ON tag_policy
1270 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1271 + BEGIN
1272 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1273 + VALUES ('tag_policy', 'INSERT',
1274 + hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), NEW.tag),
1275 + json_object('tag', NEW.tag, 'review_threshold', NEW.review_threshold,
1276 + 'auto_threshold', NEW.auto_threshold));
1277 + END;
1278 +
1279 + CREATE TRIGGER IF NOT EXISTS sync_tag_policy_update AFTER UPDATE ON tag_policy
1280 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1281 + BEGIN
1282 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1283 + VALUES ('tag_policy', 'UPDATE',
1284 + hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), NEW.tag),
1285 + json_object('tag', NEW.tag, 'review_threshold', NEW.review_threshold,
1286 + 'auto_threshold', NEW.auto_threshold));
1287 + END;
1288 +
1289 + CREATE TRIGGER IF NOT EXISTS sync_tag_policy_delete AFTER DELETE ON tag_policy
1290 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1291 + BEGIN
1292 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1293 + VALUES ('tag_policy', 'DELETE',
1294 + hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), OLD.tag),
1295 + json_object('tag', OLD.tag));
1296 + END;
1297 + "#;
1298 +
1259 1299 /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite
1260 1300 /// function on the given connection. Used by the M018 sync triggers so the
1261 1301 /// `sync_changelog.row_id` field never carries cleartext content (tag strings,
@@ -1358,6 +1398,7 @@ impl Database {
1358 1398 MIGRATION_020,
1359 1399 MIGRATION_021,
1360 1400 MIGRATION_022,
1401 + MIGRATION_023,
1361 1402 ];
1362 1403
1363 1404 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -1519,6 +1560,7 @@ mod tests {
1519 1560 "samples",
1520 1561 "sync_changelog",
1521 1562 "sync_state",
1563 + "tag_policy",
1522 1564 "tag_provenance",
1523 1565 "tag_rules",
1524 1566 "tags",
@@ -1537,7 +1579,7 @@ mod tests {
1537 1579 .conn()
1538 1580 .query_row("PRAGMA user_version", [], |row| row.get(0))
1539 1581 .unwrap();
1540 - assert_eq!(version, 22);
1582 + assert_eq!(version, 23);
1541 1583 }
1542 1584
1543 1585 #[test]
@@ -1548,7 +1590,7 @@ mod tests {
1548 1590 .conn()
1549 1591 .query_row("PRAGMA user_version", [], |row| row.get(0))
1550 1592 .unwrap();
1551 - assert_eq!(version, 22);
1593 + assert_eq!(version, 23);
1552 1594 }
1553 1595
1554 1596 /// Open a fresh file-backed DB, close, reopen. The second open re-enters
@@ -1567,7 +1609,7 @@ mod tests {
1567 1609 .conn()
1568 1610 .query_row("PRAGMA user_version", [], |row| row.get(0))
1569 1611 .unwrap();
1570 - assert_eq!(version, 22);
1612 + assert_eq!(version, 23);
1571 1613 }
1572 1614
1573 1615 /// Simulates the worst-case recovery path: a prior partial migration left
@@ -1611,7 +1653,7 @@ mod tests {
1611 1653 .conn()
1612 1654 .query_row("PRAGMA user_version", [], |row| row.get(0))
1613 1655 .unwrap();
1614 - assert_eq!(version, 22);
1656 + assert_eq!(version, 23);
1615 1657 }
1616 1658
1617 1659 /// M018 contract: the `sync_changelog.row_id` for sensitive tables must
@@ -1833,7 +1875,7 @@ mod tests {
1833 1875 let initial_version: i32 = conn
1834 1876 .query_row("PRAGMA user_version", [], |row| row.get(0))
1835 1877 .unwrap();
1836 - assert_eq!(initial_version, 22);
1878 + assert_eq!(initial_version, 23);
1837 1879
1838 1880 let batch = format!(
1839 1881 "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;",
@@ -1896,7 +1938,7 @@ mod tests {
1896 1938 .conn()
1897 1939 .query_row("PRAGMA user_version", [], |row| row.get(0))
1898 1940 .unwrap();
1899 - assert_eq!(version, 22);
1941 + assert_eq!(version, 23);
1900 1942 }
1901 1943
1902 1944 #[test]
@@ -28,6 +28,7 @@ pub(crate) const UPSERT_ORDER: &[&str] = &[
28 28 "tags",
29 29 "tag_provenance",
30 30 "tag_rules",
31 + "tag_policy",
31 32 "collection_members",
32 33 "user_config",
33 34 "edit_history",
@@ -38,6 +39,7 @@ pub(crate) const DELETE_ORDER: &[&str] = &[
38 39 "edit_history",
39 40 "user_config",
40 41 "collection_members",
42 + "tag_policy",
41 43 "tag_rules",
42 44 "tag_provenance",
43 45 "tags",
@@ -82,6 +84,7 @@ pub(crate) fn table_columns(table: &str) -> Option<&'static [&'static str]> {
82 84 "id", "name", "enabled", "priority", "match_mode", "conditions",
83 85 "actions", "created_at",
84 86 ]),
87 + "tag_policy" => Some(&["tag", "review_threshold", "auto_threshold"]),
85 88 "collections" => Some(&["id", "name", "description", "created_at", "filter_json"]),
86 89 "collection_members" => Some(&["collection_id", "sample_hash", "added_at"]),
87 90 "user_config" => Some(&["key", "value"]),
@@ -104,6 +107,7 @@ pub(crate) fn pk_columns(table: &str) -> &'static [&'static str] {
104 107 "tags" => &["sample_hash", "tag"],
105 108 "tag_provenance" => &["sample_hash", "tag"],
106 109 "tag_rules" => &["id"],
110 + "tag_policy" => &["tag"],
107 111 "collections" => &["id"],
108 112 "collection_members" => &["collection_id", "sample_hash"],
109 113 "user_config" => &["key"],
@@ -31,6 +31,7 @@ pub fn create_initial_snapshot(conn: &Connection) -> Result<i64> {
31 31 ("tags", "SELECT sample_hash || ':' || tag, json_object('sample_hash', sample_hash, 'tag', tag) FROM tags"),
32 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 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"),
34 + ("tag_policy", "SELECT tag, json_object('tag', tag, 'review_threshold', review_threshold, 'auto_threshold', auto_threshold) FROM tag_policy"),
34 35 ("collections", "SELECT CAST(id AS TEXT), json_object('id', id, 'name', name, 'description', description, 'created_at', created_at, 'filter_json', filter_json) FROM collections"),
35 36 ("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"),
36 37 ("user_config", "SELECT key, json_object('key', key, 'value', value) FROM user_config WHERE key NOT LIKE 'sync_%' AND key != 'loose_files'"),
@@ -1,6 +1,6 @@
1 1 # audiofiles Database Schema
2 2
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.
3 + SQLite schema reference. 23 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
@@ -178,6 +178,16 @@ A tag with **no** row here is manual; reconciliation only ever touches rule-sour
178 178
179 179 PK: `(sample_hash, tag)`.
180 180
181 + ### tag_policy
182 + Per-tag ML thresholds (Layer B). Migration 023. A tag with no row uses the in-code
183 + default (review 0.5, auto 0.85).
184 +
185 + | Column | Type | Notes |
186 + |--------|------|-------|
187 + | `tag` | TEXT PK | The tag these thresholds apply to |
188 + | `review_threshold` | REAL | Score at/above which a k-NN suggestion is queued for review |
189 + | `auto_threshold` | REAL | Score at/above which it is auto-applied (`>= review_threshold`) |
190 +
181 191 ### collections
182 192 Named sample collections (playlists, kits).
183 193
@@ -273,7 +283,7 @@ Indexes: `source_hash`, `result_hash`.
273 283 - **Sync-excluded keys:** `user_config` sync triggers skip keys matching `sync_%` to avoid syncing sync-internal state
274 284 - **Cloud-only samples:** `samples.cloud_only` flag allows local blob eviction while keeping metadata and cloud copy
275 285 - **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.
276 - - **Synced tables:** `samples`, `sample_features`, `audio_analysis`, `vfs`, `vfs_nodes`, `tags`, `tag_provenance`, `tag_rules`, `collections`, `collection_members`, `user_config`, `edit_history`
286 + - **Synced tables:** `samples`, `sample_features`, `audio_analysis`, `vfs`, `vfs_nodes`, `tags`, `tag_provenance`, `tag_rules`, `tag_policy`, `collections`, `collection_members`, `user_config`, `edit_history`
277 287
278 288 ## Key Indexes
279 289
@@ -309,3 +319,4 @@ Indexes: `source_hash`, `result_hash`.
309 319 | 020 | `sample_features` table (persisted 35-feature vector) + sync triggers; retired the embedded RF classifier models |
310 320 | 021 | `tag_rules` table (deterministic tag rules, Layer A) + sync triggers |
311 321 | 022 | `tag_provenance` table (manual-sticky tag attribution) + sync triggers |
322 + | 023 | `tag_policy` table (per-tag k-NN review/auto thresholds) + sync triggers |