Skip to main content

max / audiofiles

Add clustering bootstrap and folder-label harvest (classifier Phase 2) Cold-start label sources that seed the first tags before any rules or manual tagging exist, feeding the upcoming k-NN layer. - analysis/cluster.rs: k-means over z-standardized sample_features vectors with deterministic farthest-first seeding (no RNG dependency, reproducible); medoid representative per cluster for naming; suggest_k heuristic. cluster_library computes; apply_cluster_tag labels members (source='cluster'). - harvest.rs: harvest_folder_labels turns VFS directories that directly contain samples into normalized tag suggestions; apply_harvested_tag labels them (source='harvest'). - rules::apply_tag_sourced / remove_tags_by_source: shared provenance helpers. Both bootstrap sources are sticky (non-rule, never reconciled away) and bulk-undoable by source. No new tables: reads sample_features/vfs, writes existing tags/tag_provenance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-14 23:04 UTC
Commit: 808fa0a450f7b00713c33b8ce6bf3044840a60da
Parent: 723d5d2
5 files changed, +538 insertions, -0 deletions
@@ -0,0 +1,336 @@
1 + //! Unsupervised clustering bootstrap (cold-start for the tag pipeline).
2 + //!
3 + //! Groups a library's persisted feature vectors so the user can name clusters and seed
4 + //! the first labels before any rules or manual tagging exist. k-means runs over
5 + //! z-standardized features (MFCC and spectral scales differ by orders of magnitude) with
6 + //! deterministic farthest-first seeding — no RNG dependency, reproducible results.
7 + //!
8 + //! Clustering itself writes nothing; `apply_cluster_tag` is what a named cluster does,
9 + //! recording `source = 'cluster'` provenance (sticky — the rules engine never reconciles
10 + //! these away). See [`crate::rules`] for the provenance model.
11 +
12 + use crate::db::Database;
13 + use crate::error::{CoreError, Result};
14 + use tracing::instrument;
15 +
16 + use super::classify::{FEATURE_VERSION, NUM_FEATURES};
17 +
18 + /// One cluster: a representative (medoid) sample plus its members.
19 + #[derive(Debug, Clone)]
20 + pub struct Cluster {
21 + pub id: usize,
22 + /// Member nearest the centroid — a playable representative for naming.
23 + pub medoid_hash: String,
24 + pub member_hashes: Vec<String>,
25 + }
26 +
27 + /// Result of clustering a library.
28 + #[derive(Debug, Clone)]
29 + pub struct ClusterResult {
30 + pub clusters: Vec<Cluster>,
31 + /// Feature-extractor version the vectors were produced with.
32 + pub feature_version: u32,
33 + }
34 +
35 + /// Suggested cluster count for `n` samples: ~sqrt(n/2), clamped to a sane UI range.
36 + pub fn suggest_k(n: usize) -> usize {
37 + if n < 2 {
38 + return n;
39 + }
40 + let k = ((n as f64 / 2.0).sqrt()).round() as usize;
41 + k.clamp(2, 24).min(n)
42 + }
43 +
44 + /// Load all current-version feature vectors as `(hash, vector)` pairs.
45 + fn load_vectors(db: &Database) -> Result<Vec<(String, Vec<f64>)>> {
46 + let mut stmt = db.conn().prepare(
47 + "SELECT hash, vector FROM sample_features WHERE feat_version = ?1",
48 + )?;
49 + let rows = stmt.query_map([FEATURE_VERSION], |row| {
50 + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
51 + })?;
52 +
53 + let mut out = Vec::new();
54 + for r in rows {
55 + let (hash, json) = r?;
56 + let v: Vec<f64> =
57 + serde_json::from_str(&json).map_err(|e| CoreError::Serialization(e.to_string()))?;
58 + if v.len() == NUM_FEATURES {
59 + out.push((hash, v));
60 + }
61 + }
62 + Ok(out)
63 + }
64 +
65 + /// z-standardize each dimension across the dataset (in place). Zero-variance dims
66 + /// collapse to 0.0. Returns nothing — operates on the supplied matrix.
67 + fn standardize(vectors: &mut [Vec<f64>]) {
68 + if vectors.is_empty() {
69 + return;
70 + }
71 + let dims = vectors[0].len();
72 + let n = vectors.len() as f64;
73 + for d in 0..dims {
74 + let mean = vectors.iter().map(|v| v[d]).sum::<f64>() / n;
75 + let var = vectors.iter().map(|v| (v[d] - mean).powi(2)).sum::<f64>() / n;
76 + let std = var.sqrt();
77 + if std > f64::EPSILON {
78 + for v in vectors.iter_mut() {
79 + v[d] = (v[d] - mean) / std;
80 + }
81 + } else {
82 + for v in vectors.iter_mut() {
83 + v[d] = 0.0;
84 + }
85 + }
86 + }
87 + }
88 +
89 + fn sq_dist(a: &[f64], b: &[f64]) -> f64 {
90 + a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum()
91 + }
92 +
93 + /// Deterministic farthest-first seeding (Gonzalez): start from the point nearest the
94 + /// dataset mean, then repeatedly take the point maximizing its distance to the chosen
95 + /// set. Ties break by lowest index.
96 + fn init_centroids(points: &[Vec<f64>], k: usize) -> Vec<Vec<f64>> {
97 + let dims = points[0].len();
98 + let n = points.len() as f64;
99 + let mean: Vec<f64> = (0..dims)
100 + .map(|d| points.iter().map(|p| p[d]).sum::<f64>() / n)
101 + .collect();
102 +
103 + let first = (0..points.len())
104 + .min_by(|&a, &b| {
105 + sq_dist(&points[a], &mean)
106 + .partial_cmp(&sq_dist(&points[b], &mean))
107 + .unwrap()
108 + })
109 + .unwrap();
110 +
111 + let mut centroids = vec![points[first].clone()];
112 + let mut min_d: Vec<f64> = points.iter().map(|p| sq_dist(p, &points[first])).collect();
113 +
114 + while centroids.len() < k {
115 + let next = (0..points.len())
116 + .max_by(|&a, &b| min_d[a].partial_cmp(&min_d[b]).unwrap())
117 + .unwrap();
118 + centroids.push(points[next].clone());
119 + for (i, p) in points.iter().enumerate() {
120 + min_d[i] = min_d[i].min(sq_dist(p, &points[next]));
121 + }
122 + }
123 + centroids
124 + }
125 +
126 + /// Cluster the library's feature vectors into `k` groups.
127 + ///
128 + /// Returns clusters with member hashes and a medoid representative. `k` is clamped to
129 + /// the number of vectors. Returns an empty result if nothing is analyzed yet.
130 + #[instrument(skip_all)]
131 + pub fn cluster_library(db: &Database, k: usize, max_iters: usize) -> Result<ClusterResult> {
132 + let loaded = load_vectors(db)?;
133 + if loaded.is_empty() || k == 0 {
134 + return Ok(ClusterResult { clusters: Vec::new(), feature_version: FEATURE_VERSION });
135 + }
136 + let hashes: Vec<String> = loaded.iter().map(|(h, _)| h.clone()).collect();
137 + let mut points: Vec<Vec<f64>> = loaded.into_iter().map(|(_, v)| v).collect();
138 + standardize(&mut points);
139 +
140 + let k = k.min(points.len());
141 + let mut centroids = init_centroids(&points, k);
142 + let mut assign = vec![0usize; points.len()];
143 +
144 + for _ in 0..max_iters.max(1) {
145 + // Assignment step.
146 + let mut changed = false;
147 + for (i, p) in points.iter().enumerate() {
148 + let best = (0..k)
149 + .min_by(|&a, &b| {
150 + sq_dist(p, &centroids[a])
151 + .partial_cmp(&sq_dist(p, &centroids[b]))
152 + .unwrap()
153 + })
154 + .unwrap();
155 + if best != assign[i] {
156 + assign[i] = best;
157 + changed = true;
158 + }
159 + }
160 + if !changed {
161 + break;
162 + }
163 + // Update step. Empty clusters keep their previous centroid (no NaN drift).
164 + let dims = points[0].len();
165 + let mut sums = vec![vec![0.0f64; dims]; k];
166 + let mut counts = vec![0usize; k];
167 + for (i, p) in points.iter().enumerate() {
168 + let c = assign[i];
169 + counts[c] += 1;
170 + for d in 0..dims {
171 + sums[c][d] += p[d];
172 + }
173 + }
174 + for c in 0..k {
175 + if counts[c] > 0 {
176 + for d in 0..dims {
177 + centroids[c][d] = sums[c][d] / counts[c] as f64;
178 + }
179 + }
180 + }
181 + }
182 +
183 + // Build clusters + medoids.
184 + let mut members: Vec<Vec<usize>> = vec![Vec::new(); k];
185 + for (i, &c) in assign.iter().enumerate() {
186 + members[c].push(i);
187 + }
188 + let mut clusters = Vec::new();
189 + for (c, idxs) in members.into_iter().enumerate() {
190 + if idxs.is_empty() {
191 + continue;
192 + }
193 + let medoid = *idxs
194 + .iter()
195 + .min_by(|&&a, &&b| {
196 + sq_dist(&points[a], &centroids[c])
197 + .partial_cmp(&sq_dist(&points[b], &centroids[c]))
198 + .unwrap()
199 + })
200 + .unwrap();
201 + clusters.push(Cluster {
202 + id: c,
203 + medoid_hash: hashes[medoid].clone(),
204 + member_hashes: idxs.into_iter().map(|i| hashes[i].clone()).collect(),
205 + });
206 + }
207 +
208 + Ok(ClusterResult { clusters, feature_version: FEATURE_VERSION })
209 + }
210 +
211 + /// Name a cluster: apply `tag` to every member with `source = 'cluster'` provenance.
212 + /// Returns the number of samples that gained the tag.
213 + #[instrument(skip_all)]
214 + pub fn apply_cluster_tag(db: &Database, member_hashes: &[String], tag: &str) -> Result<usize> {
215 + let mut added = 0;
216 + for hash in member_hashes {
217 + if crate::rules::apply_tag_sourced(db, hash, tag, "cluster")? {
218 + added += 1;
219 + }
220 + }
221 + Ok(added)
222 + }
223 +
224 + #[cfg(test)]
225 + mod tests {
226 + use super::*;
227 +
228 + fn db_with_features(rows: &[(&str, [f64; NUM_FEATURES])]) -> Database {
229 + let db = Database::open_in_memory().unwrap();
230 + for (hash, vec) in rows {
231 + db.conn()
232 + .execute(
233 + "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
234 + VALUES (?1, ?1, 'wav', 1, 0, 0)",
235 + [hash],
236 + )
237 + .unwrap();
238 + let json = serde_json::to_string(&vec.to_vec()).unwrap();
239 + db.conn()
240 + .execute(
241 + "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)",
242 + rusqlite::params![hash, FEATURE_VERSION, json],
243 + )
244 + .unwrap();
245 + }
246 + db
247 + }
248 +
249 + fn vec_at(base: f64) -> [f64; NUM_FEATURES] {
250 + [base; NUM_FEATURES]
251 + }
252 +
253 + #[test]
254 + fn suggest_k_is_sane() {
255 + assert_eq!(suggest_k(0), 0);
256 + assert_eq!(suggest_k(1), 1);
257 + assert!((2..=24).contains(&suggest_k(100)));
258 + assert_eq!(suggest_k(3), 2); // sqrt(1.5) rounds to 1, clamped up to 2
259 + }
260 +
261 + #[test]
262 + fn empty_library_yields_no_clusters() {
263 + let db = Database::open_in_memory().unwrap();
264 + let res = cluster_library(&db, 4, 20).unwrap();
265 + assert!(res.clusters.is_empty());
266 + }
267 +
268 + #[test]
269 + fn separates_two_obvious_groups() {
270 + // Two tight groups far apart in feature space.
271 + let rows = [
272 + ("a1", vec_at(0.0)),
273 + ("a2", vec_at(0.1)),
274 + ("a3", vec_at(-0.1)),
275 + ("b1", vec_at(100.0)),
276 + ("b2", vec_at(100.1)),
277 + ("b3", vec_at(99.9)),
278 + ];
279 + let db = db_with_features(&rows);
280 + let res = cluster_library(&db, 2, 50).unwrap();
281 + assert_eq!(res.clusters.len(), 2);
282 +
283 + // Each cluster must be all-a or all-b.
284 + for cluster in &res.clusters {
285 + let all_a = cluster.member_hashes.iter().all(|h| h.starts_with('a'));
286 + let all_b = cluster.member_hashes.iter().all(|h| h.starts_with('b'));
287 + assert!(all_a || all_b, "cluster mixed groups: {:?}", cluster.member_hashes);
288 + assert_eq!(cluster.member_hashes.len(), 3);
289 + }
290 + }
291 +
292 + #[test]
293 + fn deterministic_across_runs() {
294 + let rows = [
295 + ("a1", vec_at(0.0)),
296 + ("b1", vec_at(50.0)),
297 + ("c1", vec_at(100.0)),
298 + ("a2", vec_at(1.0)),
299 + ("b2", vec_at(51.0)),
300 + ];
301 + let db = db_with_features(&rows);
302 + let r1 = cluster_library(&db, 3, 50).unwrap();
303 + let r2 = cluster_library(&db, 3, 50).unwrap();
304 + let sig = |r: &ClusterResult| {
305 + let mut v: Vec<Vec<String>> = r
306 + .clusters
307 + .iter()
308 + .map(|c| {
309 + let mut m = c.member_hashes.clone();
310 + m.sort();
311 + m
312 + })
313 + .collect();
314 + v.sort();
315 + v
316 + };
317 + assert_eq!(sig(&r1), sig(&r2));
318 + }
319 +
320 + #[test]
321 + fn apply_cluster_tag_writes_sticky_provenance() {
322 + let rows = [("a1", vec_at(0.0)), ("a2", vec_at(0.1))];
323 + let db = db_with_features(&rows);
324 + let members = vec!["a1".to_string(), "a2".to_string()];
325 + assert_eq!(apply_cluster_tag(&db, &members, "kit.my-kicks").unwrap(), 2);
326 +
327 + assert!(crate::tags::get_sample_tags(&db, "a1").unwrap().contains(&"kit.my-kicks".to_string()));
328 + let prov = crate::rules::sample_tag_provenance(&db, "a1").unwrap();
329 + assert_eq!(prov[0].1, "cluster");
330 +
331 + // Removable as a batch (undo the pass).
332 + let removed = crate::rules::remove_tags_by_source(&db, "cluster").unwrap();
333 + assert_eq!(removed, 2);
334 + assert!(crate::tags::get_sample_tags(&db, "a1").unwrap().is_empty());
335 + }
336 + }
@@ -7,6 +7,7 @@
7 7 pub mod basic;
8 8 pub mod bpm;
9 9 pub mod classify;
10 + pub mod cluster;
10 11 pub mod config;
11 12 pub mod decode;
12 13 pub mod loop_detect;
@@ -0,0 +1,164 @@
1 + //! Folder-label harvesting (cold-start companion to clustering).
2 + //!
3 + //! Turns an already-organized library's folder structure into candidate tags: each VFS
4 + //! directory that directly contains samples becomes a suggested tag (normalized from the
5 + //! folder name) over those samples. The user reviews/edits the mapping, then applies it.
6 + //!
7 + //! Applied harvest tags carry `source = 'harvest'` provenance — sticky, like manual and
8 + //! cluster tags (the rules engine never reconciles them away). See [`crate::rules`].
9 +
10 + use crate::db::Database;
11 + use crate::error::Result;
12 + use crate::tags::validate_tag;
13 + use tracing::instrument;
14 +
15 + /// A candidate label derived from one VFS folder.
16 + #[derive(Debug, Clone, PartialEq, Eq)]
17 + pub struct FolderLabel {
18 + /// The raw directory name (e.g. `808 Kicks`).
19 + pub folder_name: String,
20 + /// Normalized, validated tag suggestion (e.g. `808-kicks`). The user can
21 + /// re-namespace it before applying.
22 + pub suggested_tag: String,
23 + /// Hashes of the samples directly under this folder.
24 + pub sample_hashes: Vec<String>,
25 + }
26 +
27 + /// Normalize a folder name into a valid flat tag, or `None` if it can't form one.
28 + ///
29 + /// Lowercases, maps every non-alphanumeric run to a single hyphen, trims hyphens, and
30 + /// validates against the tag grammar.
31 + pub fn normalize_to_tag(folder_name: &str) -> Option<String> {
32 + let mut out = String::with_capacity(folder_name.len());
33 + let mut last_dash = false;
34 + for ch in folder_name.chars() {
35 + if ch.is_ascii_alphanumeric() {
36 + out.push(ch.to_ascii_lowercase());
37 + last_dash = false;
38 + } else if !last_dash {
39 + out.push('-');
40 + last_dash = true;
41 + }
42 + }
43 + let trimmed = out.trim_matches('-').to_string();
44 + if trimmed.is_empty() {
45 + return None;
46 + }
47 + validate_tag(&trimmed).ok().map(|_| trimmed)
48 + }
49 +
50 + /// Scan the VFS for directories that directly contain samples and propose a label per
51 + /// folder. Folders whose name can't normalize to a valid tag are skipped. Ordered by
52 + /// folder name.
53 + #[instrument(skip_all)]
54 + pub fn harvest_folder_labels(db: &Database) -> Result<Vec<FolderLabel>> {
55 + let mut stmt = db.conn().prepare(
56 + "SELECT d.name, s.sample_hash
57 + FROM vfs_nodes d
58 + JOIN vfs_nodes s ON s.parent_id = d.id AND s.node_type = 'sample'
59 + WHERE d.node_type = 'directory' AND s.sample_hash IS NOT NULL
60 + ORDER BY d.name, s.sample_hash",
61 + )?;
62 + let rows = stmt.query_map([], |row| {
63 + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
64 + })?;
65 +
66 + // Group hashes by folder name, preserving first-seen folder order.
67 + let mut order: Vec<String> = Vec::new();
68 + let mut by_folder: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
69 + for r in rows {
70 + let (name, hash) = r?;
71 + by_folder
72 + .entry(name.clone())
73 + .or_insert_with(|| {
74 + order.push(name.clone());
75 + Vec::new()
76 + })
77 + .push(hash);
78 + }
79 +
80 + let mut labels = Vec::new();
81 + for folder_name in order {
82 + if let Some(suggested_tag) = normalize_to_tag(&folder_name) {
83 + let sample_hashes = by_folder.remove(&folder_name).unwrap_or_default();
84 + labels.push(FolderLabel { folder_name, suggested_tag, sample_hashes });
85 + }
86 + }
87 + Ok(labels)
88 + }
89 +
90 + /// Apply a harvested tag to the given samples (`source = 'harvest'` provenance).
91 + /// Returns the number of samples that gained the tag.
92 + #[instrument(skip_all)]
93 + pub fn apply_harvested_tag(db: &Database, hashes: &[String], tag: &str) -> Result<usize> {
94 + let mut added = 0;
95 + for hash in hashes {
96 + if crate::rules::apply_tag_sourced(db, hash, tag, "harvest")? {
97 + added += 1;
98 + }
99 + }
100 + Ok(added)
101 + }
102 +
103 + #[cfg(test)]
104 + mod tests {
105 + use super::*;
106 +
107 + #[test]
108 + fn normalize_handles_messy_names() {
109 + assert_eq!(normalize_to_tag("808 Kicks").as_deref(), Some("808-kicks"));
110 + assert_eq!(normalize_to_tag(" Hi-Hats!! ").as_deref(), Some("hi-hats"));
111 + assert_eq!(normalize_to_tag("Vocals/Chops").as_deref(), Some("vocals-chops"));
112 + assert_eq!(normalize_to_tag("___").as_deref(), None);
113 + assert_eq!(normalize_to_tag("").as_deref(), None);
114 + }
115 +
116 + fn setup(db: &Database) -> (i64, i64) {
117 + let conn = db.conn();
118 + conn.execute(
119 + "INSERT INTO vfs (name, created_at, modified_at, sync_files) VALUES ('Lib', 0, 0, 0)",
120 + [],
121 + )
122 + .unwrap();
123 + let vfs_id = conn.last_insert_rowid();
124 + // Directory "808 Kicks" with two samples.
125 + conn.execute(
126 + "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, created_at) VALUES (?1, NULL, '808 Kicks', 'directory', 0)",
127 + [vfs_id],
128 + )
129 + .unwrap();
130 + let dir_id = conn.last_insert_rowid();
131 + for h in ["k1", "k2"] {
132 + conn.execute(
133 + "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) VALUES (?1, ?1, 'wav', 1, 0, 0)",
134 + [h],
135 + )
136 + .unwrap();
137 + conn.execute(
138 + "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, ?2, ?3, 'sample', ?3, 0)",
139 + rusqlite::params![vfs_id, dir_id, h],
140 + )
141 + .unwrap();
142 + }
143 + (vfs_id, dir_id)
144 + }
145 +
146 + #[test]
147 + fn harvest_and_apply() {
148 + let db = Database::open_in_memory().unwrap();
149 + setup(&db);
150 +
151 + let labels = harvest_folder_labels(&db).unwrap();
152 + assert_eq!(labels.len(), 1);
153 + assert_eq!(labels[0].folder_name, "808 Kicks");
154 + assert_eq!(labels[0].suggested_tag, "808-kicks");
155 + assert_eq!(labels[0].sample_hashes.len(), 2);
156 +
157 + // Apply under a re-namespaced tag.
158 + let n = apply_harvested_tag(&db, &labels[0].sample_hashes, "instrument.drum.kick").unwrap();
159 + assert_eq!(n, 2);
160 + assert!(crate::tags::get_sample_tags(&db, "k1").unwrap().contains(&"instrument.drum.kick".to_string()));
161 + let prov = crate::rules::sample_tag_provenance(&db, "k1").unwrap();
162 + assert_eq!(prov[0].1, "harvest");
163 + }
164 + }
@@ -41,6 +41,7 @@ pub mod error;
41 41 pub mod export;
42 42 pub mod fingerprint;
43 43 pub mod forge;
44 + pub mod harvest;
44 45 pub mod id_types;
45 46 pub mod instrument;
46 47 pub mod rename;
@@ -724,6 +724,42 @@ pub fn preview_rule_matches(db: &Database, rule: &Rule) -> Result<usize> {
724 724 Ok(count)
725 725 }
726 726
727 + /// Apply a tag attributed to a non-rule machine source (`cluster`, `harvest`, `ml`).
728 + ///
729 + /// Sticky: the rules engine never reconciles these away (only `source = 'rule'` tags
730 + /// are regenerable). Existing provenance is preserved (`INSERT OR IGNORE`), so this
731 + /// won't downgrade a tag that is already rule-sourced or manual. Returns `true` if the
732 + /// tag was newly added to the sample.
733 + #[instrument(skip_all)]
734 + pub fn apply_tag_sourced(db: &Database, hash: &str, tag: &str, source: &str) -> Result<bool> {
735 + validate_tag(tag)?;
736 + let conn = db.conn();
737 + let added = conn.execute(
738 + "INSERT OR IGNORE INTO tags (sample_hash, tag) VALUES (?1, ?2)",
739 + rusqlite::params![hash, tag],
740 + )? > 0;
741 + conn.execute(
742 + "INSERT OR IGNORE INTO tag_provenance (sample_hash, tag, source, rule_id)
743 + VALUES (?1, ?2, ?3, NULL)",
744 + rusqlite::params![hash, tag, source],
745 + )?;
746 + Ok(added)
747 + }
748 +
749 + /// Remove every tag applied by a given source (e.g. undo a clustering pass). Only
750 + /// affects tags whose provenance matches `source`; manual tags are untouched.
751 + #[instrument(skip_all)]
752 + pub fn remove_tags_by_source(db: &Database, source: &str) -> Result<usize> {
753 + let conn = db.conn();
754 + let removed = conn.execute(
755 + "DELETE FROM tags WHERE (sample_hash, tag) IN
756 + (SELECT sample_hash, tag FROM tag_provenance WHERE source = ?1)",
757 + rusqlite::params![source],
758 + )?;
759 + conn.execute("DELETE FROM tag_provenance WHERE source = ?1", rusqlite::params![source])?;
760 + Ok(removed)
761 + }
762 +
727 763 /// Provenance of each tag on a sample: `(tag, source, rule_id)`. Tags absent from
728 764 /// the result (but present on the sample) are manual.
729 765 #[instrument(skip_all)]