Skip to main content

max / audiofiles

Make clusters first-class named objects k-means produced a grouping and then discarded it: apply_cluster_tag wrote tags and the pile itself stopped existing. The grouping was the valuable part, and it is what answers "what is this pile" for the population the filename rules cannot answer at all. M039 adds clusters + cluster_members. A cluster carries the user's name, the medoid, and the run that produced it (k, feature_version, run_at). Membership is derived and machine-dependent, so neither table carries sync triggers, on the sample_neighbours model in M038. A re-run replaces the set and carries names across by medoid: exact medoid match first, then containment, greedy, each stored name reaching at most one fresh pile. Without that, re-clustering throws away every word the user typed, which is the reason naming a pile was not worth doing before. The browser reads the stored clusters when the section opens, writes a name through on commit, and tolerates a medoid whose sample has since been deleted. The migration tests pinned user_version to the literal 38 in six places; they now read SCHEMA_VERSION, which is what they were asserting. The one test that pins the number on purpose still does.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 01:49 UTC
Signed with PGP, not checked
Commit: 2f562f855d360a572915d6498c4609c70060b369
Parent: b2f6719
9 files changed, +566 insertions, -48 deletions
M Cargo.lock +37 -27
@@ -4246,6 +4246,7 @@
4246 4246 [[package]]
4247 4247 name = "quasi-immediate"
4248 4248 version = "0.48.0"
4249 + source = "git+https://makenot.work/git/max/quasi.git#b23c0c0f34323a65a611dc5fda74533b0ec5c3c9"
4249 4250 dependencies = [
4250 4251 "docengine",
4251 4252 "egui",
@@ -4256,6 +4257,7 @@
4256 4257 [[package]]
4257 4258 name = "quasi-router"
4258 4259 version = "0.48.0"
4260 + source = "git+https://makenot.work/git/max/quasi.git#b23c0c0f34323a65a611dc5fda74533b0ec5c3c9"
4259 4261 dependencies = [
4260 4262 "makeover-layout",
4261 4263 ]
@@ -5687,7 +5689,7 @@
5687 5689
5688 5690 [[package]]
5689 5691 name = "tagtree"
5690 - version = "0.4.0"
5692 + version = "0.4.1"
5691 5693
5692 5694 [[package]]
5693 5695 name = "target-lexicon"
@@ -7545,33 +7547,9 @@
7545 7547 ]
7546 7548
7547 7549 [[patch.unused]]
7548 - name = "quasi-axum"
7549 - version = "0.48.0"
7550 -
7551 - [[patch.unused]]
7552 - name = "quasi-basics"
7553 - version = "0.48.0"
7554 -
7555 - [[patch.unused]]
7556 - name = "quasi-http"
7557 - version = "0.48.0"
7558 -
7559 - [[patch.unused]]
7560 - name = "quasi-notifs"
7561 - version = "0.48.0"
7562 -
7563 - [[patch.unused]]
7564 - name = "quasi-store"
7550 + name = "quasi-type"
7565 7551 version = "0.1.0"
7566 7552
7567 - [[patch.unused]]
7568 - name = "quasi-tauri"
7569 - version = "0.48.0"
7570 -
7571 - [[patch.unused]]
7572 - name = "quasi-webview"
7573 - version = "0.48.0"
7574 -
7575 7553 [[patch.unused]]
7576 7554 name = "kberg"
7577 7555 version = "0.1.0"
@@ -7585,5 +7563,37 @@
7585 7563 version = "0.1.0"
7586 7564
7587 7565 [[patch.unused]]
7588 - name = "quasi-type"
7566 + name = "quasi-axum"
7567 + version = "0.49.0"
7568 +
7569 + [[patch.unused]]
7570 + name = "quasi-basics"
7571 + version = "0.49.0"
7572 +
7573 + [[patch.unused]]
7574 + name = "quasi-http"
7575 + version = "0.49.0"
7576 +
7577 + [[patch.unused]]
7578 + name = "quasi-immediate"
7579 + version = "0.49.0"
7580 +
7581 + [[patch.unused]]
7582 + name = "quasi-notifs"
7583 + version = "0.49.0"
7584 +
7585 + [[patch.unused]]
7586 + name = "quasi-router"
7587 + version = "0.49.0"
7588 +
7589 + [[patch.unused]]
7590 + name = "quasi-store"
7589 7591 version = "0.1.0"
7592 +
7593 + [[patch.unused]]
7594 + name = "quasi-tauri"
7595 + version = "0.49.0"
7596 +
7597 + [[patch.unused]]
7598 + name = "quasi-webview"
7599 + version = "0.49.0"
@@ -68,7 +68,7 @@
68 68 .map(|h| ClassifierJobResult::Trained(h.map(|head| head_info(&head)))),
69 69 ClassifierJob::AutoApply { k } => auto_apply(db, k).map(ClassifierJobResult::AutoApplied),
70 70 ClassifierJob::Cluster { k } => {
71 - cluster::cluster_library(db, k, 50).map(|r| ClassifierJobResult::Clustered(r.clusters))
71 + cluster::cluster_and_persist(db, k, 50).map(ClassifierJobResult::Clustered)
72 72 }
73 73 ClassifierJob::Export { path, opts } => {
74 74 afcl::export_to_path(db, &path, &opts).map(|()| ClassifierJobResult::Exported(path))
@@ -1827,6 +1827,49 @@
1827 1827 END;
1828 1828 ";
1829 1829
1830 + const MIGRATION_039: &str = r"
1831 + -- Clusters become first-class named objects.
1832 + --
1833 + -- Before this, `cluster_library` produced a grouping and then threw it away:
1834 + -- `apply_cluster_tag` wrote tags and the pile itself stopped existing. The
1835 + -- grouping was the valuable part. A persisted cluster is what answers 'what is
1836 + -- this pile' for the population the filename rules cannot answer at all.
1837 + --
1838 + -- Membership is derived, on the `sample_neighbours` model in M038: it is
1839 + -- recomputable from `sample_features`, and machine-dependent besides, because
1840 + -- k-means runs over features standardized against this library's own ranges.
1841 + -- So neither table carries sync triggers. The user's name is NOT derived, and
1842 + -- whether it should sync is a separate decision, filed rather than answered
1843 + -- here.
1844 + CREATE TABLE IF NOT EXISTS clusters (
1845 + id INTEGER PRIMARY KEY,
1846 + -- NULL until the user names it. Naming is the whole point; an unnamed
1847 + -- cluster is a pile still waiting for a word.
1848 + name TEXT,
1849 + -- The member nearest the centroid: a playable representative, and the key a
1850 + -- re-run matches on to carry the name across. Nullable because deleting the
1851 + -- representative sample must not destroy the name the user typed.
1852 + medoid_hash TEXT REFERENCES samples(hash) ON DELETE SET NULL,
1853 + -- The run that produced it: the k asked for, the feature extractor the
1854 + -- vectors came from, and when. A cluster built under a stale
1855 + -- `feature_version` is comparable to nothing built since.
1856 + k INTEGER NOT NULL,
1857 + feature_version INTEGER NOT NULL,
1858 + run_at INTEGER NOT NULL,
1859 + named_at INTEGER
1860 + );
1861 + CREATE INDEX IF NOT EXISTS idx_clusters_medoid ON clusters(medoid_hash);
1862 +
1863 + CREATE TABLE IF NOT EXISTS cluster_members (
1864 + cluster_id INTEGER NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
1865 + sample_hash TEXT NOT NULL REFERENCES samples(hash) ON DELETE CASCADE,
1866 + PRIMARY KEY (cluster_id, sample_hash)
1867 + );
1868 + -- 'which pile is this sample in' is the read the detail pane makes, and it runs
1869 + -- against the grain of the primary key.
1870 + CREATE INDEX IF NOT EXISTS idx_cluster_members_hash ON cluster_members(sample_hash);
1871 + ";
1872 +
1830 1873 /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite
1831 1874 /// function on the given connection. Used by the M018 sync triggers so the
1832 1875 /// `sync_changelog.row_id` field never carries cleartext content (tag strings,
@@ -1904,6 +1947,7 @@
1904 1947 MIGRATION_036,
1905 1948 MIGRATION_037,
1906 1949 MIGRATION_038,
1950 + MIGRATION_039,
1907 1951 ];
1908 1952
1909 1953 /// The schema version this build produces, and the highest one it can read.
@@ -2486,6 +2530,8 @@
2486 2530 "classifier_exemplars",
2487 2531 "classifier_layer_rules",
2488 2532 "classifier_layers",
2533 + "cluster_members",
2534 + "clusters",
2489 2535 "collection_members",
2490 2536 "collections",
2491 2537 "config_key_policy",
@@ -2519,7 +2565,7 @@
2519 2565 .conn()
2520 2566 .query_row("PRAGMA user_version", [], |row| row.get(0))
2521 2567 .unwrap();
2522 - assert_eq!(version, 38);
2568 + assert_eq!(version, SCHEMA_VERSION);
2523 2569 }
2524 2570
2525 2571 #[test]
@@ -2530,7 +2576,7 @@
2530 2576 .conn()
2531 2577 .query_row("PRAGMA user_version", [], |row| row.get(0))
2532 2578 .unwrap();
2533 - assert_eq!(version, 38);
2579 + assert_eq!(version, SCHEMA_VERSION);
2534 2580 }
2535 2581
2536 2582 #[test]
@@ -2714,7 +2760,7 @@
2714 2760 .conn()
2715 2761 .query_row("PRAGMA user_version", [], |row| row.get(0))
2716 2762 .unwrap();
2717 - assert_eq!(version, 38);
2763 + assert_eq!(version, SCHEMA_VERSION);
2718 2764 }
2719 2765
2720 2766 /// Simulates the worst-case recovery path: a prior partial migration left
@@ -2758,7 +2804,7 @@
2758 2804 .conn()
2759 2805 .query_row("PRAGMA user_version", [], |row| row.get(0))
2760 2806 .unwrap();
2761 - assert_eq!(version, 38);
2807 + assert_eq!(version, SCHEMA_VERSION);
2762 2808 }
2763 2809
2764 2810 /// A vault written by a newer audiofiles is refused, not opened.
@@ -2824,7 +2870,7 @@
2824 2870 /// means adding a migration without meaning to shows up as a failure.
2825 2871 #[test]
2826 2872 fn schema_version_matches_the_migration_list() {
2827 - assert_eq!(SCHEMA_VERSION, 38);
2873 + assert_eq!(SCHEMA_VERSION, 39);
2828 2874 assert_eq!(SCHEMA_VERSION as usize, MIGRATIONS.len());
2829 2875 }
2830 2876
@@ -3160,7 +3206,7 @@
3160 3206 let initial_version: i32 = conn
3161 3207 .query_row("PRAGMA user_version", [], |row| row.get(0))
3162 3208 .unwrap();
3163 - assert_eq!(initial_version, 38);
3209 + assert_eq!(initial_version, SCHEMA_VERSION);
3164 3210
3165 3211 let batch = format!("BEGIN;\n{bad_sql}\nPRAGMA user_version = 999;\nCOMMIT;");
3166 3212 let first_err = conn.execute_batch(&batch).unwrap_err();
@@ -3225,7 +3271,7 @@
3225 3271 .conn()
3226 3272 .query_row("PRAGMA user_version", [], |row| row.get(0))
3227 3273 .unwrap();
3228 - assert_eq!(version, 38);
3274 + assert_eq!(version, SCHEMA_VERSION);
3229 3275 }
3230 3276
3231 3277 #[test]
@@ -301,7 +301,7 @@
301 301 pub enum ClassifierJobResult {
302 302 Trained(Option<TrainedHeadInfo>),
303 303 AutoApplied(usize),
304 - Clustered(Vec<audiofiles_core::analysis::cluster::Cluster>),
304 + Clustered(Vec<audiofiles_core::analysis::cluster::PersistedCluster>),
305 305 Exported(std::path::PathBuf),
306 306 /// Suggestions for the sample identified by `hash`. The hash lets the GUI
307 307 /// drop the result if the selection changed while the job ran.
@@ -639,11 +639,20 @@
639 639 /// Set a layer's k-NN weight (clamped to `[0, 1]`).
640 640 fn set_classifier_layer_weight(&self, id: &str, weight: f64) -> BackendResult<()>;
641 641
642 - /// Cluster the library's feature vectors into `k` groups (for naming/seeding).
642 + /// Cluster the library's feature vectors into `k` groups and persist them,
643 + /// carrying the names of the previous run across.
643 644 fn cluster_library(
644 645 &self,
645 646 k: usize,
646 - ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::Cluster>>;
647 + ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::PersistedCluster>>;
648 +
649 + /// The stored clusters, so a session opens on the piles the user already named.
650 + fn load_clusters(
651 + &self,
652 + ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::PersistedCluster>>;
653 +
654 + /// Name a stored cluster, or clear the name if `name` is blank.
655 + fn rename_cluster(&self, id: i64, name: &str) -> BackendResult<()>;
647 656
648 657 /// Apply a tag to every member of a named cluster (`source = 'cluster'`).
649 658 fn apply_cluster_tag(&self, members: &[String], tag: &str) -> BackendResult<usize>;
@@ -518,8 +518,12 @@
518 518 );
519 519 }
520 520 R::Clustered(clusters) => {
521 - self.classifier.cluster_names = vec![String::new(); clusters.len()];
521 + self.classifier.cluster_names = clusters
522 + .iter()
523 + .map(|c| c.name.clone().unwrap_or_default())
524 + .collect();
522 525 self.classifier.clusters = clusters;
526 + self.classifier.clusters_loaded = true;
523 527 self.status = format!(
524 528 "Found {} cluster{}.",
525 529 self.classifier.clusters.len(),
@@ -875,6 +879,44 @@
875 879 );
876 880 }
877 881
882 + /// Read the stored clusters once per session, so the piles the user already
883 + /// named are there before any re-run.
884 + pub fn ensure_clusters_loaded(&mut self) {
885 + if self.classifier.clusters_loaded {
886 + return;
887 + }
888 + self.classifier.clusters_loaded = true;
889 + let clusters = self.backend.load_clusters().unwrap_or_default();
890 + self.classifier.cluster_names = clusters
891 + .iter()
892 + .map(|c| c.name.clone().unwrap_or_default())
893 + .collect();
894 + self.classifier.clusters = clusters;
895 + }
896 +
897 + /// Persist the edited name of cluster `idx`. Naming is the authored part of a
898 + /// cluster, so it is written through rather than held in the widget buffer.
899 + pub fn commit_cluster_name(&mut self, idx: usize) {
900 + let Some(cluster) = self.classifier.clusters.get(idx) else {
901 + return;
902 + };
903 + let name = self
904 + .classifier
905 + .cluster_names
906 + .get(idx)
907 + .map(|n| n.trim().to_string())
908 + .unwrap_or_default();
909 + let id = cluster.id;
910 + match self.backend.rename_cluster(id, &name) {
911 + Ok(()) => {
912 + if let Some(cluster) = self.classifier.clusters.get_mut(idx) {
913 + cluster.name = if name.is_empty() { None } else { Some(name) };
914 + }
915 + }
916 + Err(e) => self.status = format!("Could not name cluster: {e}"),
917 + }
918 + }
919 +
878 920 /// Apply the named tag to every member of cluster `idx`.
879 921 pub fn apply_cluster(&mut self, idx: usize) {
880 922 let Some(cluster) = self.classifier.clusters.get(idx) else {
@@ -892,6 +934,9 @@
892 934 self.status = "Name the cluster before tagging it.".to_string();
893 935 return;
894 936 }
937 + // Tagging a pile is also naming it: the word the user typed is the
938 + // cluster's name whether or not they pressed anything else first.
939 + self.commit_cluster_name(idx);
895 940 match self.backend.apply_cluster_tag(&members, &tag) {
896 941 Ok(n) => {
897 942 self.status = format!(
@@ -379,10 +379,13 @@
379 379 // --- Clustering bootstrap ---
380 380 /// Requested cluster count; 0 = auto (suggest_k).
381 381 pub cluster_k: u32,
382 - /// Last clustering result.
383 - pub clusters: Vec<audiofiles_core::analysis::cluster::Cluster>,
384 - /// Editable name (tag) per cluster, parallel to `clusters`.
382 + /// The stored clusters, as of the last run or load.
383 + pub clusters: Vec<audiofiles_core::analysis::cluster::PersistedCluster>,
384 + /// Editable name per cluster, parallel to `clusters`. Seeded from the stored
385 + /// name, so a session opens on the words the user already typed.
385 386 pub cluster_names: Vec<String>,
387 + /// Whether the stored clusters have been read this session.
388 + pub clusters_loaded: bool,
386 389
387 390 // --- Folder-label harvest ---
388 391 pub folder_labels: Vec<audiofiles_core::harvest::FolderLabel>,
@@ -843,6 +843,9 @@
843 843 .color(theme::content_muted()),
844 844 );
845 845 ui.add_space(theme::space::bound());
846 + // The stored piles come back before anything is re-run, so a session
847 + // opens on the words the user already typed.
848 + state.ensure_clusters_loaded();
846 849 let busy = state.classifier.busy.is_some();
847 850 ui.horizontal(|ui| {
848 851 let mut k = state.classifier.cluster_k.max(2);
@@ -884,6 +887,7 @@
884 887 let clusters = std::mem::take(&mut state.classifier.clusters);
885 888 let mut play: Option<String> = None;
886 889 let mut apply: Option<usize> = None;
890 + let mut rename: Option<usize> = None;
887 891 for (i, cluster) in clusters.iter().enumerate() {
888 892 ui.horizontal(|ui| {
889 893 ui.label(
@@ -891,20 +895,28 @@
891 895 .small()
892 896 .color(theme::content_secondary()),
893 897 );
898 + // The representative can be gone: deleting a sample must not
899 + // take the pile or its name with it.
900 + let medoid = cluster.medoid_hash.clone();
894 901 if ui
895 - .small_button("Play")
902 + .add_enabled(medoid.is_some(), egui::Button::new("Play").small())
896 903 .on_hover_text("Preview a representative sample")
897 904 .clicked()
898 905 {
899 - play = Some(cluster.medoid_hash.clone());
906 + play = medoid;
900 907 }
901 908 if let Some(name) = state.classifier.cluster_names.get_mut(i) {
902 - widgets::text_field(
909 + let response = widgets::text_field(
903 910 ui,
904 911 egui::TextEdit::singleline(name)
905 912 .desired_width(150.0)
906 - .hint_text("tag for this group"),
913 + .hint_text("name this group"),
907 914 );
915 + // Written through on commit, not per keystroke: the name
916 + // outlives the widget and the run that produced the pile.
917 + if response.lost_focus() || response.changed() && !response.has_focus() {
918 + rename = Some(i);
919 + }
908 920 }
909 921 if ui.small_button("Tag").clicked() {
910 922 apply = Some(i);
@@ -915,6 +927,9 @@
915 927 if let Some(hash) = play {
916 928 state.trigger_preview(&hash);
917 929 }
930 + if let Some(i) = rename {
931 + state.commit_cluster_name(i);
932 + }
918 933 if let Some(i) = apply {
919 934 state.apply_cluster(i);
920 935 }
@@ -11,6 +11,7 @@
11 11
12 12 use crate::db::Database;
13 13 use crate::error::Result;
14 + use rusqlite::OptionalExtension;
14 15 use tracing::instrument;
15 16
16 17 use super::features::{FEATURE_VERSION, NUM_FEATURES};
@@ -227,6 +228,220 @@
227 228 })
228 229 }
229 230
231 + /// A cluster as it lives in the vault: the grouping, plus the name the user gave it.
232 + ///
233 + /// The unnamed form is not a failure state. A fresh run produces piles with no
234 + /// words on them yet, and naming one is the act this whole table exists for.
235 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
236 + pub struct PersistedCluster {
237 + pub id: i64,
238 + /// `None` until the user names it.
239 + pub name: Option<String>,
240 + /// `None` only if the representative sample has since been deleted. A name
241 + /// outlives its medoid; the `ON DELETE SET NULL` in M039 is what says so.
242 + pub medoid_hash: Option<String>,
243 + pub member_hashes: Vec<String>,
244 + }
245 +
246 + fn now_secs() -> i64 {
247 + use std::time::{SystemTime, UNIX_EPOCH};
248 + SystemTime::now()
249 + .duration_since(UNIX_EPOCH)
250 + .map_or(0, |d| d.as_secs() as i64)
251 + }
252 +
253 + /// How strongly a stored cluster claims to be the same pile as a fresh one.
254 + ///
255 + /// Two rungs, and the gap between them is deliberate. An exact medoid match is
256 + /// the same representative choosing the same pile, which is as close to identity
257 + /// as a re-clustering gets. Containment is weaker: the old representative landed
258 + /// in this pile, so this pile inherited it, but the pile may have absorbed or
259 + /// shed a lot around it.
260 + fn claim_strength(old_medoid: &str, fresh: &Cluster) -> u8 {
261 + if fresh.medoid_hash == old_medoid {
262 + 2
263 + } else {
264 + // 1 for containment, 0 for no claim at all.
265 + u8::from(fresh.member_hashes.iter().any(|h| h == old_medoid))
266 + }
267 + }
268 +
269 + /// Match fresh clusters to the stored named ones, so a re-run does not throw
270 + /// away the words the user typed.
271 + ///
272 + /// Returns `names[i]` for `fresh[i]`. Greedy over claim strength: every stored
273 + /// name is carried to at most one fresh cluster, and every fresh cluster takes
274 + /// at most one name. Ties resolve by cluster order, which k-means makes
275 + /// deterministic, so the same library re-clustered at the same `k` carries the
276 + /// same names every time.
277 + fn carry_names(fresh: &[Cluster], stored: &[(i64, String, String)]) -> Vec<Option<String>> {
278 + let mut claims: Vec<(u8, usize, usize)> = Vec::new();
279 + for (si, (_, _, medoid)) in stored.iter().enumerate() {
280 + for (fi, f) in fresh.iter().enumerate() {
281 + let strength = claim_strength(medoid, f);
282 + if strength > 0 {
283 + claims.push((strength, fi, si));
284 + }
285 + }
286 + }
287 + // Strongest first; then fresh order, then stored order, so the result does
288 + // not depend on how the rows came back from SQLite.
289 + claims.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
290 +
291 + let mut names: Vec<Option<String>> = vec![None; fresh.len()];
292 + let mut used = vec![false; stored.len()];
293 + for (_, fi, si) in claims {
294 + if names[fi].is_some() || used[si] {
295 + continue;
296 + }
297 + names[fi] = Some(stored[si].1.clone());
298 + used[si] = true;
299 + }
300 + names
301 + }
302 +
303 + /// Replace the stored cluster set with `result`, carrying names across.
304 + ///
305 + /// The whole set is replaced rather than merged: a clustering run is a single
306 + /// answer over the whole library, so half of one run beside half of another is
307 + /// not a state the user could interpret. What survives a replacement is the
308 + /// naming, which is the only part the user authored.
309 + #[instrument(skip_all)]
310 + pub fn persist_clusters(
311 + db: &Database,
312 + result: &ClusterResult,
313 + k: usize,
314 + ) -> Result<Vec<PersistedCluster>> {
315 + let run_at = now_secs();
316 + db.transaction_core(|_tx| {
317 + let stored: Vec<(i64, String, String)> = {
318 + let mut stmt = db.conn().prepare(
319 + "SELECT id, name, medoid_hash FROM clusters \
320 + WHERE name IS NOT NULL AND medoid_hash IS NOT NULL ORDER BY id",
321 + )?;
322 + let rows = stmt.query_map([], |row| {
323 + Ok((
324 + row.get::<_, i64>(0)?,
325 + row.get::<_, String>(1)?,
326 + row.get::<_, String>(2)?,
327 + ))
328 + })?;
329 + rows.collect::<std::result::Result<_, _>>()?
330 + };
331 + let names = carry_names(&result.clusters, &stored);
332 +
333 + // Members go with the row: M039 cascades cluster_members off clusters.
334 + db.conn().execute("DELETE FROM clusters", [])?;
335 +
336 + let mut out = Vec::with_capacity(result.clusters.len());
337 + for (fresh, name) in result.clusters.iter().zip(names) {
338 + db.conn().execute(
339 + "INSERT INTO clusters (name, medoid_hash, k, feature_version, run_at, named_at) \
340 + VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
341 + rusqlite::params![
342 + name,
343 + fresh.medoid_hash,
344 + k as i64,
345 + result.feature_version,
346 + run_at,
347 + name.as_ref().map(|_| run_at),
348 + ],
349 + )?;
350 + let id = db.conn().last_insert_rowid();
351 + for hash in &fresh.member_hashes {
352 + db.conn().execute(
353 + "INSERT OR IGNORE INTO cluster_members (cluster_id, sample_hash) VALUES (?1, ?2)",
354 + rusqlite::params![id, hash],
355 + )?;
356 + }
357 + out.push(PersistedCluster {
358 + id,
359 + name,
360 + medoid_hash: Some(fresh.medoid_hash.clone()),
361 + member_hashes: fresh.member_hashes.clone(),
362 + });
363 + }
364 + Ok(out)
365 + })
366 + }
367 +
368 + /// Cluster the library and persist the result in one step.
369 + ///
370 + /// The pairing is the point: a run that is not persisted is the behaviour this
371 + /// table replaced.
372 + #[instrument(skip_all)]
373 + pub fn cluster_and_persist(
374 + db: &Database,
375 + k: usize,
376 + max_iters: usize,
377 + ) -> Result<Vec<PersistedCluster>> {
378 + let result = cluster_library(db, k, max_iters)?;
379 + persist_clusters(db, &result, k)
380 + }
381 +
382 + /// The stored clusters, in id order, with their members.
383 + #[instrument(skip_all)]
384 + pub fn load_clusters(db: &Database) -> Result<Vec<PersistedCluster>> {
385 + let mut stmt = db
386 + .conn()
387 + .prepare("SELECT id, name, medoid_hash FROM clusters ORDER BY id")?;
388 + let heads: Vec<(i64, Option<String>, Option<String>)> = stmt
389 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
390 + .collect::<std::result::Result<_, _>>()?;
391 +
392 + let mut members = db.conn().prepare(
393 + "SELECT sample_hash FROM cluster_members WHERE cluster_id = ?1 ORDER BY sample_hash",
394 + )?;
395 + let mut out = Vec::with_capacity(heads.len());
396 + for (id, name, medoid_hash) in heads {
397 + let member_hashes: Vec<String> = members
398 + .query_map([id], |row| row.get(0))?
399 + .collect::<std::result::Result<_, _>>()?;
400 + out.push(PersistedCluster {
401 + id,
402 + name,
403 + medoid_hash,
404 + member_hashes,
405 + });
406 + }
407 + Ok(out)
408 + }
409 +
410 + /// Name a stored cluster, or clear the name if `name` is blank.
411 + ///
412 + /// Naming does not tag: [`apply_cluster_tag`] is still what writes tags, and
413 + /// keeping them separate is what lets a user name a pile to think with before
414 + /// deciding the whole pile deserves the word on every member.
415 + #[instrument(skip_all)]
416 + pub fn rename_cluster(db: &Database, id: i64, name: &str) -> Result<()> {
417 + let trimmed = name.trim();
418 + let name: Option<&str> = if trimmed.is_empty() {
419 + None
420 + } else {
421 + Some(trimmed)
422 + };
423 + db.conn().execute(
424 + "UPDATE clusters SET name = ?1, named_at = ?2 WHERE id = ?3",
425 + rusqlite::params![name, name.map(|_| now_secs()), id],
426 + )?;
427 + Ok(())
428 + }
429 +
430 + /// The cluster a sample belongs to, if the library has been clustered.
431 + #[instrument(skip_all)]
432 + pub fn cluster_for_sample(db: &Database, hash: &str) -> Result<Option<PersistedCluster>> {
433 + let id: Option<i64> = db
434 + .conn()
435 + .query_row(
436 + "SELECT cluster_id FROM cluster_members WHERE sample_hash = ?1",
437 + [hash],
438 + |row| row.get(0),
439 + )
440 + .optional()?;
441 + let Some(id) = id else { return Ok(None) };
442 + Ok(load_clusters(db)?.into_iter().find(|c| c.id == id))
443 + }
444 +
230 445 #[cfg(test)]
231 446 mod tests {
232 447 use super::*;
@@ -256,6 +471,166 @@
256 471 [base; NUM_FEATURES]
257 472 }
258 473
474 + /// The library used by every persistence test: two tight groups far apart, so
475 + /// the grouping is not in question and only the bookkeeping is under test.
476 + fn two_group_db() -> Database {
477 + db_with_features(&[
478 + ("a1", vec_at(0.0)),
479 + ("a2", vec_at(0.1)),
480 + ("a3", vec_at(-0.1)),
481 + ("b1", vec_at(100.0)),
482 + ("b2", vec_at(100.1)),
483 + ("b3", vec_at(99.9)),
484 + ])
485 + }
486 +
487 + #[test]
488 + fn persisted_clusters_round_trip() {
489 + let db = two_group_db();
490 + let persisted = cluster_and_persist(&db, 2, 50).unwrap();
491 + assert_eq!(persisted.len(), 2);
492 + assert!(persisted.iter().all(|c| c.name.is_none()));
493 +
494 + let loaded = load_clusters(&db).unwrap();
495 + assert_eq!(loaded.len(), 2);
496 + for (a, b) in persisted.iter().zip(&loaded) {
497 + assert_eq!(a.id, b.id);
498 + assert_eq!(a.medoid_hash, b.medoid_hash);
499 + let mut left = a.member_hashes.clone();
500 + left.sort();
501 + assert_eq!(left, b.member_hashes);
502 + }
503 + }
504 +
505 + #[test]
506 + fn renaming_persists_and_blank_clears() {
507 + let db = two_group_db();
508 + let persisted = cluster_and_persist(&db, 2, 50).unwrap();
509 + let id = persisted[0].id;
510 +
511 + rename_cluster(&db, id, " kicks ").unwrap();
512 + let named = load_clusters(&db).unwrap();
513 + assert_eq!(named[0].name.as_deref(), Some("kicks"));
514 +
515 + rename_cluster(&db, id, " ").unwrap();
516 + assert!(load_clusters(&db).unwrap()[0].name.is_none());
517 + }
518 +
519 + #[test]
520 + fn a_re_run_carries_the_name_across() {
521 + let db = two_group_db();
522 + let first = cluster_and_persist(&db, 2, 50).unwrap();
523 + // Name whichever pile holds a1.
524 + let target = first
525 + .iter()
526 + .find(|c| c.member_hashes.iter().any(|h| h == "a1"))
527 + .unwrap();
528 + rename_cluster(&db, target.id, "kicks").unwrap();
529 +
530 + let second = cluster_and_persist(&db, 2, 50).unwrap();
531 + let carried = second
532 + .iter()
533 + .find(|c| c.member_hashes.iter().any(|h| h == "a1"))
534 + .unwrap();
535 + assert_eq!(carried.name.as_deref(), Some("kicks"));
536 + // The other pile is untouched: a name is carried, never invented.
537 + assert_eq!(second.iter().filter(|c| c.name.is_some()).count(), 1);
538 + }
539 +
540 + #[test]
541 + fn re_run_replaces_the_set_rather_than_accumulating() {
542 + let db = two_group_db();
543 + cluster_and_persist(&db, 2, 50).unwrap();
544 + cluster_and_persist(&db, 2, 50).unwrap();
545 + assert_eq!(load_clusters(&db).unwrap().len(), 2);
546 + let members: i64 = db
547 + .conn()
548 + .query_row("SELECT COUNT(*) FROM cluster_members", [], |r| r.get(0))
549 + .unwrap();
550 + assert_eq!(members, 6);
551 + }
552 +
553 + #[test]
554 + fn containment_carries_a_name_when_the_medoid_moves() {
555 + // Old representative "b1" is not the fresh medoid, but is in the pile.
556 + let fresh = vec![Cluster {
557 + id: 0,
558 + medoid_hash: "b2".to_string(),
559 + member_hashes: vec!["b1".to_string(), "b2".to_string()],
560 + }];
561 + let stored = vec![(1, "hats".to_string(), "b1".to_string())];
562 + assert_eq!(carry_names(&fresh, &stored), vec![Some("hats".to_string())]);
563 + }
564 +
565 + #[test]
566 + fn one_stored_name_reaches_only_one_fresh_cluster() {
567 + // Both fresh piles can claim "b1": one by exact medoid, one by
568 + // containment. The exact match wins and the weaker claim gets nothing.
569 + let fresh = vec![
570 + Cluster {
571 + id: 0,
572 + medoid_hash: "x".to_string(),
573 + member_hashes: vec!["x".to_string(), "b1".to_string()],
574 + },
575 + Cluster {
576 + id: 1,
577 + medoid_hash: "b1".to_string(),
578 + member_hashes: vec!["b1".to_string()],
579 + },
580 + ];
581 + let stored = vec![(1, "hats".to_string(), "b1".to_string())];
582 + assert_eq!(
583 + carry_names(&fresh, &stored),
584 + vec![None, Some("hats".to_string())]
585 + );
586 + }
587 +
588 + #[test]
589 + fn an_unmatched_pile_carries_no_name() {
590 + let fresh = vec![Cluster {
591 + id: 0,
592 + medoid_hash: "z1".to_string(),
593 + member_hashes: vec!["z1".to_string()],
594 + }];
595 + let stored = vec![(1, "hats".to_string(), "b1".to_string())];
596 + assert_eq!(carry_names(&fresh, &stored), vec![None]);
597 + }
598 +
599 + #[test]
600 + fn deleting_a_sample_drops_its_membership_but_not_the_name() {
601 + let db = two_group_db();
602 + let persisted = cluster_and_persist(&db, 2, 50).unwrap();
603 + let target = persisted
604 + .iter()
605 + .find(|c| c.medoid_hash.as_deref() == Some("a1"))
606 + .or_else(|| persisted.first())
607 + .unwrap();
608 + let medoid = target.medoid_hash.clone().unwrap();
609 + rename_cluster(&db, target.id, "kicks").unwrap();
610 +
611 + db.conn()
612 + .execute("DELETE FROM samples WHERE hash = ?1", [&medoid])
613 + .unwrap();
614 +
615 + let after = load_clusters(&db)
616 + .unwrap()
617 + .into_iter()
618 + .find(|c| c.id == target.id)
619 + .expect("the cluster row outlives its medoid");
620 + assert_eq!(after.name.as_deref(), Some("kicks"));
621 + assert_eq!(after.medoid_hash, None);
622 + assert!(!after.member_hashes.contains(&medoid));
623 + }
624 +
625 + #[test]
626 + fn cluster_for_sample_finds_the_pile() {
627 + let db = two_group_db();
628 + cluster_and_persist(&db, 2, 50).unwrap();
629 + let found = cluster_for_sample(&db, "a1").unwrap().unwrap();
630 + assert!(found.member_hashes.iter().any(|h| h == "a1"));
631 + assert!(cluster_for_sample(&db, "nope").unwrap().is_none());
632 + }
633 +
259 634 #[test]
260 635 fn suggest_k_is_sane() {
261 636 assert_eq!(suggest_k(0), 0);
@@ -176,9 +176,24 @@
176 176 fn cluster_library(
177 177 &self,
178 178 k: usize,
179 - ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::Cluster>> {
179 + ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::PersistedCluster>> {
180 180 let db = self.db.lock();
181 - Ok(audiofiles_core::analysis::cluster::cluster_library(&db, k, 50)?.clusters)
181 + Ok(audiofiles_core::analysis::cluster::cluster_and_persist(
182 + &db, k, 50,
183 + )?)
184 + }
185 +
186 + fn load_clusters(
187 + &self,
188 + ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::PersistedCluster>> {
189 + let db = self.db.lock();
190 + Ok(audiofiles_core::analysis::cluster::load_clusters(&db)?)
191 + }
192 +
193 + fn rename_cluster(&self, id: i64, name: &str) -> BackendResult<()> {
194 + let db = self.db.lock();
195 + audiofiles_core::analysis::cluster::rename_cluster(&db, id, name)?;
196 + Ok(())
182 197 }
183 198
184 199 fn apply_cluster_tag(&self, members: &[String], tag: &str) -> BackendResult<usize> {