Skip to main content

max / audiofiles

Add .afcl classifier-layer file sharing (classifier Phase 7) Export the user's tagging (feature vectors, tags, rules, thresholds — no audio) to a portable .afcl JSON file, and import someone else's as a removable, weighted layer. Imported exemplars live in classifier_exemplars (no FK to samples — no audio) and join the k-NN index weighted below the user's own local data (fill gaps / break ties). Imported rules arrive disabled and grouped; policy applies only for unconfigured tags. Import is gated on afcl_version + feat_version (new CoreError::Incompatible). Migration 025: classifier_layers + classifier_exemplars + classifier_layer_rules (local-only, not synced — the file is the portable artifact). Settings > Shared Classifiers (.afcl): export (pick components, save dialog), import (open dialog), and an imported-layer list with enable/weight/remove, wired through the Backend trait + BrowserState. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 01:31 UTC
Commit: 456b1ac90624412ecfe29d4a7bf17f07a53d535d
Parent: 074e6d8
11 files changed, +945 insertions, -18 deletions
@@ -681,6 +681,45 @@ impl Backend for DirectBackend {
681 681 Ok((n, n >= trained_head::RECOMMENDED_MIN_EXEMPLARS))
682 682 }
683 683
684 + fn export_afcl(
685 + &self,
686 + path: &Path,
687 + opts: &audiofiles_core::analysis::afcl::ExportOptions,
688 + ) -> BackendResult<()> {
689 + let db = self.db.lock();
690 + Ok(audiofiles_core::analysis::afcl::export_to_path(&db, path, opts)?)
691 + }
692 +
693 + fn import_afcl(
694 + &self,
695 + path: &Path,
696 + ) -> BackendResult<audiofiles_core::analysis::afcl::ImportSummary> {
697 + let db = self.db.lock();
698 + Ok(audiofiles_core::analysis::afcl::import_from_path(&db, path)?)
699 + }
700 +
701 + fn list_classifier_layers(
702 + &self,
703 + ) -> BackendResult<Vec<audiofiles_core::analysis::afcl::ClassifierLayer>> {
704 + let db = self.db.lock();
705 + Ok(audiofiles_core::analysis::afcl::list_layers(&db)?)
706 + }
707 +
708 + fn remove_classifier_layer(&self, id: &str) -> BackendResult<()> {
709 + let db = self.db.lock();
710 + Ok(audiofiles_core::analysis::afcl::remove_layer(&db, id)?)
711 + }
712 +
713 + fn set_classifier_layer_enabled(&self, id: &str, enabled: bool) -> BackendResult<()> {
714 + let db = self.db.lock();
715 + Ok(audiofiles_core::analysis::afcl::set_layer_enabled(&db, id, enabled)?)
716 + }
717 +
718 + fn set_classifier_layer_weight(&self, id: &str, weight: f64) -> BackendResult<()> {
719 + let db = self.db.lock();
720 + Ok(audiofiles_core::analysis::afcl::set_layer_weight(&db, id, weight)?)
721 + }
722 +
684 723 fn accept_ml_tag(&self, hash: &str, tag: &str) -> BackendResult<()> {
685 724 let db = self.db.lock();
686 725 audiofiles_core::rules::apply_tag_sourced(&db, hash, tag, "ml")?;
@@ -444,6 +444,35 @@ pub trait Backend: Send + Sync {
444 444 /// advisory hint in the UI.
445 445 fn classifier_head_readiness(&self) -> BackendResult<(usize, bool)>;
446 446
447 + // --- .afcl file sharing (classifier layers) ---
448 +
449 + /// Write the user's selected classifier data to a `.afcl` file.
450 + fn export_afcl(
451 + &self,
452 + path: &Path,
453 + opts: &audiofiles_core::analysis::afcl::ExportOptions,
454 + ) -> BackendResult<()>;
455 +
456 + /// Import a `.afcl` file as a new removable layer.
457 + fn import_afcl(
458 + &self,
459 + path: &Path,
460 + ) -> BackendResult<audiofiles_core::analysis::afcl::ImportSummary>;
461 +
462 + /// All imported/official classifier layers with live counts.
463 + fn list_classifier_layers(
464 + &self,
465 + ) -> BackendResult<Vec<audiofiles_core::analysis::afcl::ClassifierLayer>>;
466 +
467 + /// Remove a layer and everything it brought in.
468 + fn remove_classifier_layer(&self, id: &str) -> BackendResult<()>;
469 +
470 + /// Enable/disable a layer (disabled = excluded from the k-NN index).
471 + fn set_classifier_layer_enabled(&self, id: &str, enabled: bool) -> BackendResult<()>;
472 +
473 + /// Set a layer's k-NN weight (clamped to `[0, 1]`).
474 + fn set_classifier_layer_weight(&self, id: &str, weight: f64) -> BackendResult<()>;
475 +
447 476 /// Cluster the library's feature vectors into `k` groups (for naming/seeding).
448 477 fn cluster_library(
449 478 &self,
@@ -195,6 +195,93 @@ impl BrowserState {
195 195 }
196 196 }
197 197
198 + // ── .afcl file sharing (classifier layers) ──
199 +
200 + /// Load the layer list (and seed export-form defaults) when the section first opens.
201 + pub fn ensure_layers_loaded(&mut self) {
202 + if !self.classifier.layers_loaded {
203 + // One-time export-form defaults (derive(Default) leaves bools false).
204 + if self.classifier.export_name.is_empty() {
205 + self.classifier.export_name = "My classifier".to_string();
206 + self.classifier.export_include_exemplars = true;
207 + self.classifier.export_include_rules = true;
208 + self.classifier.export_include_policy = true;
209 + }
210 + self.refresh_layers();
211 + }
212 + }
213 +
214 + pub fn refresh_layers(&mut self) {
215 + self.classifier.layers = self.backend.list_classifier_layers().unwrap_or_default();
216 + self.classifier.layers_loaded = true;
217 + }
218 +
219 + /// Export the user's classifier data to `path` using the export-form selections.
220 + pub fn classifier_export_afcl(&mut self, path: &std::path::Path) {
221 + use audiofiles_core::analysis::afcl::ExportOptions;
222 + let opts = ExportOptions {
223 + name: {
224 + let n = self.classifier.export_name.trim();
225 + if n.is_empty() { "My classifier".to_string() } else { n.to_string() }
226 + },
227 + description: String::new(),
228 + license_note: String::new(),
229 + include_exemplars: self.classifier.export_include_exemplars,
230 + include_rules: self.classifier.export_include_rules,
231 + include_policy: self.classifier.export_include_policy,
232 + };
233 + match self.backend.export_afcl(path, &opts) {
234 + Ok(()) => self.status = format!("Exported classifier to {}", path.display()),
235 + Err(e) => self.status = format!("Export failed: {e}"),
236 + }
237 + }
238 +
239 + /// Import a `.afcl` file as a new removable layer, refreshing dependent views.
240 + pub fn classifier_import_afcl(&mut self, path: &std::path::Path) {
241 + match self.backend.import_afcl(path) {
242 + Ok(s) => {
243 + self.status = format!(
244 + "Imported \"{}\" — {} exemplar{}, {} rule{} (disabled), {} threshold{}",
245 + s.name,
246 + s.exemplars, if s.exemplars == 1 { "" } else { "s" },
247 + s.rules, if s.rules == 1 { "" } else { "s" },
248 + s.policies, if s.policies == 1 { "" } else { "s" },
249 + );
250 + // Import adds disabled rules + policies; refresh those views too.
251 + self.refresh_layers();
252 + self.refresh_rules();
253 + self.refresh_policies();
254 + }
255 + Err(e) => self.status = format!("Import failed: {e}"),
256 + }
257 + }
258 +
259 + /// Remove a layer and everything it brought in.
260 + pub fn classifier_remove_layer(&mut self, id: &str) {
261 + match self.backend.remove_classifier_layer(id) {
262 + Ok(()) => {
263 + self.status = "Removed classifier layer.".to_string();
264 + self.refresh_layers();
265 + self.refresh_rules();
266 + self.refresh_selected_tags();
267 + }
268 + Err(e) => self.status = format!("Could not remove layer: {e}"),
269 + }
270 + }
271 +
272 + pub fn classifier_set_layer_enabled(&mut self, id: &str, enabled: bool) {
273 + if let Err(e) = self.backend.set_classifier_layer_enabled(id, enabled) {
274 + self.status = format!("Could not update layer: {e}");
275 + }
276 + self.refresh_layers();
277 + }
278 +
279 + pub fn classifier_set_layer_weight(&mut self, id: &str, weight: f64) {
280 + if let Err(e) = self.backend.set_classifier_layer_weight(id, weight) {
281 + self.status = format!("Could not set layer weight: {e}");
282 + }
283 + }
284 +
198 285 // ── Optional trained head (Layer B speed-up) ──
199 286
200 287 /// Load head summary + readiness once when the section opens.
@@ -1939,6 +1939,35 @@ mod misc {
1939 1939 }
1940 1940
1941 1941 #[test]
1942 + fn classifier_afcl_export_import_round_trip() {
1943 + // Source library: tag two samples, then export to a .afcl file.
1944 + let (mut src, src_dir) = make_state();
1945 + insert_featured_sample(&src, "k0", 0.0, Some("instrument.drum.kick"));
1946 + insert_featured_sample(&src, "k1", 0.1, Some("instrument.drum.kick"));
1947 + let path = src_dir.path().join("share.afcl");
1948 + src.classifier.export_include_exemplars = true;
1949 + src.classifier.export_include_rules = true;
1950 + src.classifier.export_include_policy = true;
1951 + src.classifier_export_afcl(&path);
1952 + assert!(path.exists(), "export wrote the file");
1953 +
1954 + // Destination library: import it as a layer.
1955 + let (mut dst, _dst_dir) = make_state();
1956 + dst.classifier_import_afcl(&path);
1957 + dst.refresh_layers();
1958 + assert_eq!(dst.classifier.layers.len(), 1);
1959 + let layer = dst.classifier.layers[0].clone();
1960 + assert_eq!(layer.exemplar_count, 2);
1961 +
1962 + // Toggle + weight + remove all route through the backend.
1963 + dst.classifier_set_layer_enabled(&layer.id, false);
1964 + assert!(!dst.classifier.layers[0].enabled);
1965 + dst.classifier_set_layer_weight(&layer.id, 0.3);
1966 + dst.classifier_remove_layer(&layer.id);
1967 + assert!(dst.classifier.layers.is_empty());
1968 + }
1969 +
1970 + #[test]
1942 1971 fn classifier_train_head_needs_examples() {
1943 1972 let (mut state, _dir) = make_state();
1944 1973 // Only two of a tag — under MIN_POSITIVE_EXAMPLES.
@@ -321,6 +321,16 @@ pub struct ClassifierUiState {
321 321 /// Editable tag per folder label, parallel to `folder_labels`.
322 322 pub folder_tags: Vec<String>,
323 323 pub folder_loaded: bool,
324 +
325 + // --- .afcl file sharing (Phase 7) ---
326 + /// Imported/official classifier layers, loaded on demand.
327 + pub layers: Vec<audiofiles_core::analysis::afcl::ClassifierLayer>,
328 + pub layers_loaded: bool,
329 + /// Export form: name + which components to include.
330 + pub export_name: String,
331 + pub export_include_exemplars: bool,
332 + pub export_include_rules: bool,
333 + pub export_include_policy: bool,
324 334 }
325 335
326 336 /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule`
@@ -144,6 +144,8 @@ pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) {
144 144 draw_cluster_header(ui, state);
145 145 ui.add_space(theme::space::SM);
146 146 draw_folder_header(ui, state);
147 + ui.add_space(theme::space::SM);
148 + draw_sharing_header(ui, state);
147 149 }
148 150
149 151 /// "Tag Rules" section (Layer A).
@@ -728,3 +730,141 @@ fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) {
728 730 }
729 731 });
730 732 }
733 +
734 + /// "Shared Classifiers (.afcl)" section: export your own data to a portable file, import
735 + /// someone else's as a removable layer, and manage imported layers.
736 + fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
737 + egui::CollapsingHeader::new(egui::RichText::new("Shared Classifiers (.afcl)").strong())
738 + .default_open(false)
739 + .show(ui, |ui| {
740 + state.ensure_layers_loaded();
741 + ui.label(
742 + egui::RichText::new(
743 + "Share your tagging as a portable .afcl file \u{2014} it carries your feature \
744 + vectors, tags, rules, and thresholds, but never any audio. Imported files \
745 + become removable layers that sit below your own tagging.",
746 + )
747 + .small()
748 + .color(theme::content_muted()),
749 + );
750 +
751 + // ── Export ──
752 + ui.add_space(theme::space::MD);
753 + widgets::subsection_label(ui, "Export");
754 + ui.horizontal(|ui| {
755 + ui.label(egui::RichText::new("Name").small().color(theme::content_muted()));
756 + ui.add(
757 + egui::TextEdit::singleline(&mut state.classifier.export_name)
758 + .desired_width(180.0)
759 + .hint_text("My classifier"),
760 + );
761 + });
762 + ui.horizontal(|ui| {
763 + ui.checkbox(&mut state.classifier.export_include_exemplars, "Exemplars");
764 + ui.checkbox(&mut state.classifier.export_include_rules, "Rules");
765 + ui.checkbox(&mut state.classifier.export_include_policy, "Thresholds");
766 + });
767 + let nothing_selected = !state.classifier.export_include_exemplars
768 + && !state.classifier.export_include_rules
769 + && !state.classifier.export_include_policy;
770 + ui.add_enabled_ui(!nothing_selected, |ui| {
771 + if ui
772 + .button("Export to file...")
773 + .on_hover_text("Save a .afcl file you can share \u{2014} no audio is included")
774 + .clicked()
775 + && let Some(path) = rfd::FileDialog::new()
776 + .set_file_name(format!("{}.afcl", sanitize_filename(&state.classifier.export_name)))
777 + .add_filter("AF classifier", &["afcl"])
778 + .save_file()
779 + {
780 + state.classifier_export_afcl(&path);
781 + }
782 + });
783 +
784 + // ── Import ──
785 + ui.add_space(theme::space::MD);
786 + widgets::subsection_label(ui, "Import");
787 + if ui
788 + .button("Import .afcl file...")
789 + .on_hover_text("Add someone's shared classifier as a removable layer")
790 + .clicked()
791 + && let Some(path) = rfd::FileDialog::new()
792 + .add_filter("AF classifier", &["afcl"])
793 + .pick_file()
794 + {
795 + state.classifier_import_afcl(&path);
796 + }
797 +
798 + // ── Imported layers ──
799 + let layers = state.classifier.layers.clone();
800 + if layers.is_empty() {
801 + return;
802 + }
803 + ui.add_space(theme::space::MD);
804 + widgets::subsection_label(ui, "Imported layers");
805 + ui.label(
806 + egui::RichText::new(
807 + "Imported rules arrive disabled \u{2014} review them in Tag Rules above before \
808 + enabling. Weight sets how much a layer counts next to your own tagging.",
809 + )
810 + .small()
811 + .color(theme::content_muted()),
812 + );
813 +
814 + let mut toggle: Option<(String, bool)> = None;
815 + let mut weight_change: Option<(String, f64)> = None;
816 + let mut remove: Option<String> = None;
817 + for layer in &layers {
818 + ui.add_space(theme::space::SM);
819 + ui.horizontal(|ui| {
820 + let mut enabled = layer.enabled;
821 + if ui.checkbox(&mut enabled, "").changed() {
822 + toggle = Some((layer.id.clone(), enabled));
823 + }
824 + ui.label(egui::RichText::new(&layer.name).small().strong());
825 + ui.label(
826 + egui::RichText::new(format!(
827 + "{} exemplar{} \u{00b7} {} rule{}",
828 + layer.exemplar_count,
829 + if layer.exemplar_count == 1 { "" } else { "s" },
830 + layer.rule_count,
831 + if layer.rule_count == 1 { "" } else { "s" },
832 + ))
833 + .small()
834 + .color(theme::content_muted()),
835 + );
836 + if ui.small_button("Remove").clicked() {
837 + remove = Some(layer.id.clone());
838 + }
839 + });
840 + ui.horizontal(|ui| {
841 + ui.label(egui::RichText::new("weight").small().color(theme::content_muted()));
842 + let mut w = layer.weight as f32;
843 + let r = ui.add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true));
844 + if r.drag_stopped() || (r.changed() && !r.dragged()) {
845 + weight_change = Some((layer.id.clone(), w as f64));
846 + }
847 + });
848 + }
849 + if let Some((id, enabled)) = toggle {
850 + state.classifier_set_layer_enabled(&id, enabled);
851 + }
852 + if let Some((id, w)) = weight_change {
853 + state.classifier_set_layer_weight(&id, w);
854 + state.refresh_layers();
855 + }
856 + if let Some(id) = remove {
857 + state.classifier_remove_layer(&id);
858 + }
859 + });
860 + }
861 +
862 + /// Reduce a user-entered name to a safe-ish default filename stem.
863 + fn sanitize_filename(name: &str) -> String {
864 + let cleaned: String = name
865 + .trim()
866 + .chars()
867 + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
868 + .collect();
869 + if cleaned.is_empty() { "classifier".to_string() } else { cleaned }
870 + }
@@ -0,0 +1,617 @@
1 + //! `.afcl` classifier-layer file sharing (Phase 7 of the hybrid classifier).
2 + //!
3 + //! A `.afcl` ("AF classifier") is a portable, self-describing classifier-layer artifact.
4 + //! It carries **no audio** — only the non-reversible 35-d feature vectors plus labels and
5 + //! deterministic rules — so sharing one exposes nothing of the source library's audio. The
6 + //! same mechanism serves user-to-user sharing and the eventual bundled official classifier
7 + //! (which ships as a `kind = "official"` `.afcl`).
8 + //!
9 + //! The container is a single self-describing **JSON document** (dep-free; the design's
10 + //! optional zip/gzip is skipped — these files are small and a plain JSON `.afcl` stays
11 + //! diffable). An import creates a removable [`ClassifierLayer`]:
12 + //!
13 + //! - **Exemplars** land in `classifier_exemplars` (vector + tags only — no sample row),
14 + //! join the k-NN index weighted *below* the user's own `local` data, and only fill gaps
15 + //! or break ties (see [`super::exemplar::build_index`]).
16 + //! - **Rules** are added as ordinary `tag_rules` rows, **disabled**, grouped under the
17 + //! layer via `classifier_layer_rules` so the user reviews before enabling.
18 + //! - **Policy** thresholds are applied only for tags the user hasn't already configured
19 + //! (never clobbers the user's own thresholds).
20 + //!
21 + //! `feat_version` is gated at import: a `.afcl` built under a different feature layout is
22 + //! rejected. The whole layer is removable in one action.
23 +
24 + use std::collections::HashSet;
25 + use std::path::Path;
26 + use std::sync::atomic::{AtomicU64, Ordering};
27 +
28 + use serde::{Deserialize, Serialize};
29 +
30 + use crate::db::Database;
31 + use crate::error::{io_err, unix_now, CoreError, Result};
32 + use crate::rules::{self, NewRule, Rule};
33 + use tracing::instrument;
34 +
35 + use super::classify::{FEATURE_VERSION, NUM_FEATURES};
36 +
37 + /// On-disk `.afcl` format version (independent of `FEATURE_VERSION`).
38 + pub const AFCL_VERSION: u32 = 1;
39 +
40 + /// Default k-NN weight for an imported layer — below the user's own `local` (1.0), so
41 + /// imported exemplars fill gaps and break ties without overriding the user's labels.
42 + pub const DEFAULT_IMPORT_WEIGHT: f64 = 0.5;
43 +
44 + // ── File format ──
45 +
46 + /// `.afcl` manifest. `feat_version` MUST match the importing app's [`FEATURE_VERSION`].
47 + #[derive(Debug, Clone, Serialize, Deserialize)]
48 + pub struct AfclManifest {
49 + pub afcl_version: u32,
50 + pub feat_version: u32,
51 + pub name: String,
52 + pub description: String,
53 + /// `"imported"` (user share) or `"official"` (bundled default classifier).
54 + pub kind: String,
55 + pub created_at: i64,
56 + /// Free-text note about the rights to the shared labels (UI surfaces it on export).
57 + pub license_note: String,
58 + pub exemplar_count: usize,
59 + pub rule_count: usize,
60 + pub policy_count: usize,
61 + }
62 +
63 + /// One shared exemplar: a feature vector and its labels (sample hash stripped).
64 + #[derive(Debug, Clone, Serialize, Deserialize)]
65 + pub struct AfclExemplar {
66 + pub vector: Vec<f64>,
67 + pub tags: Vec<String>,
68 + }
69 +
70 + /// One shared per-tag policy.
71 + #[derive(Debug, Clone, Serialize, Deserialize)]
72 + pub struct AfclPolicy {
73 + pub tag: String,
74 + pub review_threshold: f64,
75 + pub auto_threshold: f64,
76 + }
77 +
78 + /// A complete `.afcl` document.
79 + #[derive(Debug, Clone, Serialize, Deserialize)]
80 + pub struct Afcl {
81 + pub manifest: AfclManifest,
82 + #[serde(default)]
83 + pub exemplars: Vec<AfclExemplar>,
84 + #[serde(default)]
85 + pub rules: Vec<Rule>,
86 + #[serde(default)]
87 + pub policy: Vec<AfclPolicy>,
88 + }
89 +
90 + /// What to include when exporting.
91 + #[derive(Debug, Clone)]
92 + pub struct ExportOptions {
93 + pub name: String,
94 + pub description: String,
95 + pub license_note: String,
96 + pub include_exemplars: bool,
97 + pub include_rules: bool,
98 + pub include_policy: bool,
99 + }
100 +
101 + impl Default for ExportOptions {
102 + fn default() -> Self {
103 + Self {
104 + name: "My classifier".to_string(),
105 + description: String::new(),
106 + license_note: String::new(),
107 + include_exemplars: true,
108 + include_rules: true,
109 + include_policy: true,
110 + }
111 + }
112 + }
113 +
114 + /// A persisted classifier layer (a group of imported exemplars/rules).
115 + #[derive(Debug, Clone)]
116 + pub struct ClassifierLayer {
117 + pub id: String,
118 + pub name: String,
119 + pub kind: String,
120 + pub weight: f64,
121 + pub enabled: bool,
122 + pub source: Option<String>,
123 + pub imported_at: i64,
124 + /// Live counts (joined at list time).
125 + pub exemplar_count: usize,
126 + pub rule_count: usize,
127 + }
128 +
129 + /// Outcome of an import.
130 + #[derive(Debug, Clone)]
131 + pub struct ImportSummary {
132 + pub layer_id: String,
133 + pub name: String,
134 + pub exemplars: usize,
135 + pub rules: usize,
136 + pub policies: usize,
137 + }
138 +
139 + // ── Layer ids ──
140 +
141 + static LAYER_COUNTER: AtomicU64 = AtomicU64::new(0);
142 +
143 + fn new_layer_id() -> String {
144 + let n = LAYER_COUNTER.fetch_add(1, Ordering::Relaxed);
145 + format!("layer-{:x}{:x}", unix_now(), n)
146 + }
147 +
148 + // ── Export ──
149 +
150 + /// Build an [`Afcl`] from the user's own (`local`) classifier data per `opts`.
151 + #[instrument(skip_all)]
152 + pub fn build_export(db: &Database, opts: &ExportOptions) -> Result<Afcl> {
153 + let exemplars = if opts.include_exemplars { local_exemplars(db)? } else { Vec::new() };
154 + let rules = if opts.include_rules { local_rules(db)? } else { Vec::new() };
155 + let policy = if opts.include_policy { local_policy(db)? } else { Vec::new() };
156 +
157 + let manifest = AfclManifest {
158 + afcl_version: AFCL_VERSION,
159 + feat_version: FEATURE_VERSION,
160 + name: opts.name.clone(),
161 + description: opts.description.clone(),
162 + kind: "imported".to_string(),
163 + created_at: unix_now(),
164 + license_note: opts.license_note.clone(),
165 + exemplar_count: exemplars.len(),
166 + rule_count: rules.len(),
167 + policy_count: policy.len(),
168 + };
169 + Ok(Afcl { manifest, exemplars, rules, policy })
170 + }
171 +
172 + /// Serialize the user's classifier data to a `.afcl` JSON string.
173 + pub fn export_to_string(db: &Database, opts: &ExportOptions) -> Result<String> {
174 + let afcl = build_export(db, opts)?;
175 + serde_json::to_string_pretty(&afcl).map_err(|e| CoreError::Serialization(e.to_string()))
176 + }
177 +
178 + /// Write the user's classifier data to a `.afcl` file.
179 + pub fn export_to_path(db: &Database, path: &Path, opts: &ExportOptions) -> Result<()> {
180 + let json = export_to_string(db, opts)?;
181 + std::fs::write(path, json).map_err(|e| io_err(path, e))?;
182 + Ok(())
183 + }
184 +
185 + /// The user's own (`local`) exemplars: current-version feature vectors carrying >=1 non-ML
186 + /// tag. Sample hashes are intentionally dropped — only vector + tags travel.
187 + fn local_exemplars(db: &Database) -> Result<Vec<AfclExemplar>> {
188 + let conn = db.conn();
189 + // Non-ML tags per local sample.
190 + let mut tags_by_hash: std::collections::HashMap<String, Vec<String>> =
191 + std::collections::HashMap::new();
192 + {
193 + let mut stmt = conn.prepare(
194 + "SELECT t.sample_hash, t.tag FROM tags t
195 + WHERE NOT EXISTS (
196 + SELECT 1 FROM tag_provenance p
197 + WHERE p.sample_hash = t.sample_hash AND p.tag = t.tag AND p.source = 'ml')",
198 + )?;
199 + let rows = stmt
200 + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?;
201 + for r in rows {
202 + let (hash, tag) = r?;
203 + tags_by_hash.entry(hash).or_default().push(tag);
204 + }
205 + }
206 + let mut out = Vec::new();
207 + let mut stmt =
208 + conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?;
209 + let rows = stmt.query_map([FEATURE_VERSION], |row| {
210 + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
211 + })?;
212 + for r in rows {
213 + let (hash, json) = r?;
214 + let Some(tags) = tags_by_hash.remove(&hash) else { continue };
215 + let vector: Vec<f64> =
216 + serde_json::from_str(&json).map_err(|e| CoreError::Serialization(e.to_string()))?;
217 + if vector.len() == NUM_FEATURES {
218 + out.push(AfclExemplar { vector, tags });
219 + }
220 + }
221 + Ok(out)
222 + }
223 +
224 + /// The user's own rules (those not belonging to any imported layer).
225 + fn local_rules(db: &Database) -> Result<Vec<Rule>> {
226 + let imported: HashSet<String> = imported_rule_ids(db)?;
227 + Ok(rules::list_rules(db)?.into_iter().filter(|r| !imported.contains(&r.id)).collect())
228 + }
229 +
230 + fn local_policy(db: &Database) -> Result<Vec<AfclPolicy>> {
231 + Ok(super::exemplar::list_policies(db)?
232 + .into_iter()
233 + .map(|(tag, p)| AfclPolicy {
234 + tag,
235 + review_threshold: p.review_threshold,
236 + auto_threshold: p.auto_threshold,
237 + })
238 + .collect())
239 + }
240 +
241 + fn imported_rule_ids(db: &Database) -> Result<HashSet<String>> {
242 + let mut stmt = db.conn().prepare("SELECT rule_id FROM classifier_layer_rules")?;
243 + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
244 + Ok(rows.collect::<std::result::Result<HashSet<_>, _>>()?)
245 + }
246 +
247 + // ── Import ──
248 +
249 + /// Parse and import a `.afcl` JSON string as a new removable layer.
250 + #[instrument(skip_all)]
251 + pub fn import_from_string(db: &Database, json: &str, source: Option<&str>) -> Result<ImportSummary> {
252 + let afcl: Afcl =
253 + serde_json::from_str(json).map_err(|e| CoreError::Serialization(e.to_string()))?;
254 + import(db, &afcl, source)
255 + }
256 +
257 + /// Read and import a `.afcl` file as a new removable layer.
258 + pub fn import_from_path(db: &Database, path: &Path) -> Result<ImportSummary> {
259 + let json = std::fs::read_to_string(path).map_err(|e| io_err(path, e))?;
260 + let source = path.file_name().and_then(|s| s.to_str());
261 + import_from_string(db, &json, source)
262 + }
263 +
264 + /// Import a parsed `.afcl`, creating a new layer. Gated on `afcl_version`/`feat_version`.
265 + pub fn import(db: &Database, afcl: &Afcl, source: Option<&str>) -> Result<ImportSummary> {
266 + if afcl.manifest.afcl_version > AFCL_VERSION {
267 + return Err(CoreError::Incompatible(format!(
268 + "this .afcl needs a newer version of audiofiles (format v{}, supported v{})",
269 + afcl.manifest.afcl_version, AFCL_VERSION
270 + )));
271 + }
272 + if afcl.manifest.feat_version != FEATURE_VERSION {
273 + return Err(CoreError::Incompatible(format!(
274 + "this .afcl was built for a different analysis version (features v{}, this app v{})",
275 + afcl.manifest.feat_version, FEATURE_VERSION
276 + )));
277 + }
278 +
279 + let layer_id = new_layer_id();
280 + let kind = if afcl.manifest.kind == "official" { "official" } else { "imported" };
281 + db.conn().execute(
282 + "INSERT INTO classifier_layers (id, name, kind, weight, enabled, source, imported_at)
283 + VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6)",
284 + rusqlite::params![
285 + layer_id,
286 + afcl.manifest.name,
287 + kind,
288 + DEFAULT_IMPORT_WEIGHT,
289 + source,
290 + unix_now(),
291 + ],
292 + )?;
293 +
294 + // Exemplars (vector + tags only).
295 + let mut exemplars = 0;
296 + for ex in &afcl.exemplars {
297 + if ex.vector.len() != NUM_FEATURES {
298 + continue;
299 + }
300 + let vector = serde_json::to_string(&ex.vector)
301 + .map_err(|e| CoreError::Serialization(e.to_string()))?;
302 + let tags = serde_json::to_string(&ex.tags)
303 + .map_err(|e| CoreError::Serialization(e.to_string()))?;
304 + db.conn().execute(
305 + "INSERT INTO classifier_exemplars (layer_id, feat_version, vector, tags)
306 + VALUES (?1, ?2, ?3, ?4)",
307 + rusqlite::params![layer_id, FEATURE_VERSION, vector, tags],
308 + )?;
309 + exemplars += 1;
310 + }
311 +
312 + // Rules: created disabled, grouped under the layer for the user to review.
313 + let mut rule_count = 0;
314 + for r in &afcl.rules {
315 + let created = rules::create_rule(
316 + db,
317 + NewRule {
318 + name: r.name.clone(),
319 + enabled: false,
320 + priority: None,
321 + match_mode: r.match_mode,
322 + conditions: r.conditions.clone(),
323 + actions: r.actions.clone(),
324 + },
325 + )?;
326 + db.conn().execute(
327 + "INSERT INTO classifier_layer_rules (layer_id, rule_id) VALUES (?1, ?2)",
328 + rusqlite::params![layer_id, created.id],
329 + )?;
330 + rule_count += 1;
331 + }
332 +
333 + // Policy: only for tags the user hasn't configured (never clobber the user's own).
334 + let mut policies = 0;
335 + let configured: HashSet<String> =
336 + super::exemplar::list_policies(db)?.into_iter().map(|(t, _)| t).collect();
337 + for p in &afcl.policy {
338 + if configured.contains(&p.tag) {
339 + continue;
340 + }
341 + super::exemplar::set_policy(db, &p.tag, p.review_threshold, p.auto_threshold)?;
342 + policies += 1;
343 + }
344 +
345 + Ok(ImportSummary {
346 + layer_id,
347 + name: afcl.manifest.name.clone(),
348 + exemplars,
349 + rules: rule_count,
350 + policies,
351 + })
352 + }
353 +
354 + // ── Layer management ──
355 +
356 + /// All imported/official layers with live exemplar + rule counts, newest first.
357 + #[instrument(skip_all)]
358 + pub fn list_layers(db: &Database) -> Result<Vec<ClassifierLayer>> {
359 + let conn = db.conn();
360 + let mut stmt = conn.prepare(
361 + "SELECT l.id, l.name, l.kind, l.weight, l.enabled, l.source, l.imported_at,
362 + (SELECT COUNT(*) FROM classifier_exemplars e WHERE e.layer_id = l.id),
363 + (SELECT COUNT(*) FROM classifier_layer_rules r WHERE r.layer_id = l.id)
364 + FROM classifier_layers l ORDER BY l.imported_at DESC, l.id DESC",
365 + )?;
366 + let rows = stmt.query_map([], |row| {
367 + Ok(ClassifierLayer {
368 + id: row.get(0)?,
369 + name: row.get(1)?,
370 + kind: row.get(2)?,
371 + weight: row.get(3)?,
372 + enabled: row.get::<_, i64>(4)? != 0,
373 + source: row.get(5)?,
374 + imported_at: row.get(6)?,
375 + exemplar_count: row.get::<_, i64>(7)? as usize,
376 + rule_count: row.get::<_, i64>(8)? as usize,
377 + })
378 + })?;
379 + Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
380 + }
381 +
382 + /// Remove a layer and everything it brought in: its imported rules (from `tag_rules`,
383 + /// reconciling their tags away), then the layer row (cascading exemplars + membership).
384 + #[instrument(skip_all)]
385 + pub fn remove_layer(db: &Database, layer_id: &str) -> Result<()> {
386 + let rule_ids: Vec<String> = {
387 + let mut stmt =
388 + db.conn().prepare("SELECT rule_id FROM classifier_layer_rules WHERE layer_id = ?1")?;
389 + let rows = stmt.query_map([layer_id], |row| row.get::<_, String>(0))?;
390 + rows.collect::<std::result::Result<Vec<_>, _>>()?
391 + };
392 + // delete_rule reconciles each rule's tags away (manual/local tags stay sticky).
393 + for id in &rule_ids {
394 + rules::delete_rule(db, id)?;
395 + }
396 + // Cascades classifier_exemplars + classifier_layer_rules.
397 + db.conn().execute("DELETE FROM classifier_layers WHERE id = ?1", [layer_id])?;
398 + Ok(())
399 + }
400 +
401 + /// Enable or disable a layer (disabled layers contribute nothing to the k-NN index).
402 + pub fn set_layer_enabled(db: &Database, layer_id: &str, enabled: bool) -> Result<()> {
403 + db.conn().execute(
404 + "UPDATE classifier_layers SET enabled = ?2 WHERE id = ?1",
405 + rusqlite::params![layer_id, enabled as i64],
406 + )?;
407 + Ok(())
408 + }
409 +
410 + /// Set a layer's k-NN weight (clamped to `[0, 1]` — at or below the user's own data).
411 + pub fn set_layer_weight(db: &Database, layer_id: &str, weight: f64) -> Result<()> {
412 + let weight = weight.clamp(0.0, 1.0);
413 + db.conn().execute(
414 + "UPDATE classifier_layers SET weight = ?2 WHERE id = ?1",
415 + rusqlite::params![layer_id, weight],
416 + )?;
417 + Ok(())
418 + }
419 +
420 + /// An imported exemplar joining the k-NN index: a synthetic id (`layer:<id>#<rowid>`, no
421 + /// audio), its raw feature vector, tags, and the owning layer's weight.
422 + pub struct ImportedExemplar {
423 + pub id: String,
424 + pub vector: Vec<f64>,
425 + pub tags: Vec<String>,
426 + pub weight: f64,
427 + }
428 +
429 + /// Every current-version exemplar from **enabled** layers — unioned into the k-NN index by
430 + /// [`super::exemplar::build_index`], weighted below the user's own `local` data.
431 + pub fn enabled_imported_exemplars(db: &Database) -> Result<Vec<ImportedExemplar>> {
432 + let conn = db.conn();
433 + let mut stmt = conn.prepare(
434 + "SELECT e.id, e.vector, e.tags, l.weight
435 + FROM classifier_exemplars e
436 + JOIN classifier_layers l ON l.id = e.layer_id
437 + WHERE l.enabled = 1 AND e.feat_version = ?1",
438 + )?;
439 + let rows = stmt.query_map([FEATURE_VERSION], |row| {
440 + Ok((
441 + row.get::<_, i64>(0)?,
442 + row.get::<_, String>(1)?,
443 + row.get::<_, String>(2)?,
444 + row.get::<_, f64>(3)?,
445 + ))
446 + })?;
447 + let mut out = Vec::new();
448 + for r in rows {
449 + let (rowid, vec_json, tags_json, weight) = r?;
450 + let vector: Vec<f64> = serde_json::from_str(&vec_json)
451 + .map_err(|e| CoreError::Serialization(e.to_string()))?;
452 + let tags: Vec<String> = serde_json::from_str(&tags_json)
453 + .map_err(|e| CoreError::Serialization(e.to_string()))?;
454 + if vector.len() == NUM_FEATURES {
455 + out.push(ImportedExemplar { id: format!("layer#{rowid}"), vector, tags, weight });
456 + }
457 + }
458 + Ok(out)
459 + }
460 +
461 + #[cfg(test)]
462 + mod tests {
463 + use super::*;
464 + use crate::analysis::classify::NUM_FEATURES;
465 +
466 + fn insert(db: &Database, hash: &str, fill: f64, tags: &[&str]) {
467 + db.conn()
468 + .execute(
469 + "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
470 + VALUES (?1, ?1, 'wav', 1, 0, 0)",
471 + [hash],
472 + )
473 + .unwrap();
474 + let v = vec![fill; NUM_FEATURES];
475 + let json = serde_json::to_string(&v).unwrap();
476 + db.conn()
477 + .execute(
478 + "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)",
479 + rusqlite::params![hash, FEATURE_VERSION, json],
480 + )
481 + .unwrap();
482 + for t in tags {
483 + crate::tags::add_tag(db, hash, t).unwrap();
484 + }
485 + }
486 +
487 + fn seed_library(db: &Database) {
488 + insert(db, "k1", 0.0, &["instrument.drum.kick"]);
489 + insert(db, "k2", 0.1, &["instrument.drum.kick"]);
490 + insert(db, "s1", 9.0, &["instrument.drum.snare"]);
491 + super::super::exemplar::set_policy(db, "instrument.drum.kick", 0.4, 0.8).unwrap();
492 + rules::create_rule(
493 + db,
494 + NewRule {
495 + name: "kicks folder".to_string(),
496 + enabled: true,
497 + priority: None,
498 + match_mode: crate::rules::MatchMode::All,
499 + conditions: vec![],
500 + actions: vec![crate::rules::RuleAction::AddTag("instrument.drum.kick".to_string())],
Lines truncated
@@ -94,6 +94,9 @@ struct Exemplar {
94 94 /// Standardized feature vector.
95 95 vector: Vec<f64>,
96 96 tags: Vec<String>,
97 + /// Layer weight multiplier on this exemplar's k-NN contribution. `1.0` for the user's
98 + /// own (`local`) data; imported `.afcl` layers sit below it (see [`super::afcl`]).
99 + weight: f64,
97 100 }
98 101
99 102 /// In-memory k-NN index over the labeled library. Rebuild after tag changes.
@@ -165,8 +168,8 @@ pub fn build_index(db: &Database) -> Result<ExemplarIndex> {
165 168 }
166 169 }
167 170
168 - // Raw vectors for those samples.
169 - let mut raw: Vec<(String, Vec<f64>)> = Vec::new();
171 + // Raw vectors for those samples (the user's own `local` exemplars; weight 1.0).
172 + let mut raw: Vec<(String, Vec<f64>, Vec<String>, f64)> = Vec::new();
170 173 {
171 174 let mut stmt =
172 175 conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?;
@@ -175,25 +178,29 @@ pub fn build_index(db: &Database) -> Result<ExemplarIndex> {
175 178 })?;
176 179 for r in rows {
177 180 let (hash, json) = r?;
178 - if !tags_by_hash.contains_key(&hash) {
179 - continue;
180 - }
181 + let Some(tags) = tags_by_hash.remove(&hash) else { continue };
181 182 let v: Vec<f64> = serde_json::from_str(&json)
182 183 .map_err(|e| CoreError::Serialization(e.to_string()))?;
183 184 if v.len() == NUM_FEATURES {
184 - raw.push((hash, v));
185 + raw.push((hash, v, tags, 1.0));
185 186 }
186 187 }
187 188 }
188 189
189 - // Standardization params over the exemplar set.
190 - let (means, stds) = standardization_params(&raw);
190 + // Imported `.afcl` layer exemplars (enabled layers), weighted below `local`.
191 + for imp in super::afcl::enabled_imported_exemplars(db)? {
192 + raw.push((imp.id, imp.vector, imp.tags, imp.weight));
193 + }
194 +
195 + // Standardization params over the full exemplar set (local + imported).
196 + let raw_vecs: Vec<(String, Vec<f64>)> =
197 + raw.iter().map(|(h, v, _, _)| (h.clone(), v.clone())).collect();
198 + let (means, stds) = standardization_params(&raw_vecs);
191 199 let exemplars = raw
192 200 .into_iter()
193 - .map(|(hash, v)| {
201 + .map(|(hash, v, tags, weight)| {
194 202 let vector = standardize_vec(&v, &means, &stds);
195 - let tags = tags_by_hash.remove(&hash).unwrap_or_default();
196 - Exemplar { hash, vector, tags }
203 + Exemplar { hash, vector, tags, weight }
197 204 })
198 205 .collect();
199 206
@@ -286,7 +293,8 @@ impl ExemplarIndex {
286 293 let weighted: Vec<(usize, f64)> = dists
287 294 .iter()
288 295 .map(|&(i, d2)| {
289 - let w = (-d2 / denom).exp();
296 + // Gaussian kernel scaled by the exemplar's layer weight (imported < local).
297 + let w = (-d2 / denom).exp() * self.exemplars[i].weight;
290 298 total_weight += w;
291 299 (i, w)
292 300 })
@@ -532,6 +540,46 @@ mod tests {
532 540 }
533 541
534 542 #[test]
543 + fn imported_layer_exemplars_join_the_index() {
544 + let db = Database::open_in_memory().unwrap();
545 + // The user has no labels at all; an imported layer carries the only kicks.
546 + let afcl = super::super::afcl::Afcl {
547 + manifest: super::super::afcl::AfclManifest {
548 + afcl_version: super::super::afcl::AFCL_VERSION,
549 + feat_version: FEATURE_VERSION,
550 + name: "kicks".into(),
551 + description: String::new(),
552 + kind: "imported".into(),
553 + created_at: 0,
554 + license_note: String::new(),
555 + exemplar_count: 2,
556 + rule_count: 0,
557 + policy_count: 0,
558 + },
559 + exemplars: vec![
560 + super::super::afcl::AfclExemplar {
561 + vector: vec![0.0; NUM_FEATURES],
562 + tags: vec!["instrument.drum.kick".into()],
563 + },
564 + super::super::afcl::AfclExemplar {
565 + vector: vec![0.05; NUM_FEATURES],
566 + tags: vec!["instrument.drum.kick".into()],
567 + },
568 + ],
569 + rules: vec![],
570 + policy: vec![],
571 + };
572 + super::super::afcl::import(&db, &afcl, None).unwrap();
573 +
574 + // A query near the imported kicks gets the kick tag from the imported layer alone.
575 + insert(&db, "q", vec_at(0.02), &[]);
576 + let index = build_index(&db).unwrap();
577 + assert_eq!(index.len(), 2, "imported exemplars are in the index");
578 + let scores = index.score(&vec_at(0.02), DEFAULT_K, Some("q"));
579 + assert!(scores.iter().any(|s| s.tag == "instrument.drum.kick" && s.score > 0.5));
580 + }
581 +
582 + #[test]
535 583 fn review_band_between_thresholds() {
536 584 let db = Database::open_in_memory().unwrap();
537 585 // Two equidistant neighbors with different tags -> each ~0.5 score.
@@ -4,6 +4,7 @@
4 4 //! stages (loudness, spectral features, BPM/key detection, loop detection,
5 5 //! classification) and persists results to the `audio_analysis` table.
6 6
7 + pub mod afcl;
7 8 pub mod basic;
8 9 pub mod bpm;
9 10 pub mod classify;
@@ -1310,6 +1310,41 @@ CREATE TABLE IF NOT EXISTS trained_head (
1310 1310 );
1311 1311 "#;
1312 1312
1313 + const MIGRATION_025: &str = r#"
1314 + -- Phase 7: classifier layers (.afcl file sharing). Imported exemplars/rules are grouped
1315 + -- into removable, weightable layers; the user's own data is the implicit 'local' layer
1316 + -- (not a row here). Local-only for now (no sync triggers): the .afcl file is the portable
1317 + -- artifact and re-imports per device — cross-device sync of imported layers is a follow-up.
1318 + CREATE TABLE IF NOT EXISTS classifier_layers (
1319 + id TEXT PRIMARY KEY,
1320 + name TEXT NOT NULL,
1321 + kind TEXT NOT NULL, -- 'imported' | 'official'
1322 + weight REAL NOT NULL DEFAULT 1.0,
1323 + enabled INTEGER NOT NULL DEFAULT 1,
1324 + source TEXT, -- .afcl filename / provenance
1325 + imported_at INTEGER NOT NULL
1326 + );
1327 +
1328 + -- Imported exemplars: feature vector + tags only (no audio, no sample row), so they live
1329 + -- here rather than in sample_features (which FKs to samples).
1330 + CREATE TABLE IF NOT EXISTS classifier_exemplars (
1331 + id INTEGER PRIMARY KEY,
1332 + layer_id TEXT NOT NULL REFERENCES classifier_layers(id) ON DELETE CASCADE,
1333 + feat_version INTEGER NOT NULL,
1334 + vector TEXT NOT NULL, -- JSON array of 35 f64
1335 + tags TEXT NOT NULL -- JSON array of String
1336 + );
1337 + CREATE INDEX IF NOT EXISTS idx_classifier_exemplars_layer ON classifier_exemplars(layer_id);
1338 +
1339 + -- Membership of imported rules in a layer. The rules themselves are ordinary tag_rules
1340 + -- rows (added disabled); this join lets a layer be removed in one action.
1341 + CREATE TABLE IF NOT EXISTS classifier_layer_rules (
1342 + layer_id TEXT NOT NULL REFERENCES classifier_layers(id) ON DELETE CASCADE,
1343 + rule_id TEXT NOT NULL REFERENCES tag_rules(id) ON DELETE CASCADE,
1344 + PRIMARY KEY (layer_id, rule_id)
1345 + );
1346 + "#;
1347 +
1313 1348 /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite
1314 1349 /// function on the given connection. Used by the M018 sync triggers so the
1315 1350 /// `sync_changelog.row_id` field never carries cleartext content (tag strings,
@@ -1414,6 +1449,7 @@ impl Database {
1414 1449 MIGRATION_022,
1415 1450 MIGRATION_023,
1416 1451 MIGRATION_024,
1452 + MIGRATION_025,
1417 1453 ];
1418 1454
1419 1455 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -1567,6 +1603,9 @@ mod tests {
1567 1603
1568 1604 let expected = vec![
1569 1605 "audio_analysis",
1606 + "classifier_exemplars",
1607 + "classifier_layer_rules",
1608 + "classifier_layers",
1570 1609 "collection_members",
1571 1610 "collections",
1572 1611 "edit_history",
@@ -1595,7 +1634,7 @@ mod tests {
1595 1634 .conn()
1596 1635 .query_row("PRAGMA user_version", [], |row| row.get(0))
1597 1636 .unwrap();
1598 - assert_eq!(version, 24);
1637 + assert_eq!(version, 25);
1599 1638 }
1600 1639
1601 1640 #[test]
@@ -1606,7 +1645,7 @@ mod tests {
1606 1645 .conn()
1607 1646 .query_row("PRAGMA user_version", [], |row| row.get(0))
1608 1647 .unwrap();
1609 - assert_eq!(version, 24);
1648 + assert_eq!(version, 25);
1610 1649 }
1611 1650
1612 1651 /// Open a fresh file-backed DB, close, reopen. The second open re-enters
@@ -1625,7 +1664,7 @@ mod tests {
1625 1664 .conn()
1626 1665 .query_row("PRAGMA user_version", [], |row| row.get(0))
1627 1666 .unwrap();
1628 - assert_eq!(version, 24);
1667 + assert_eq!(version, 25);
1629 1668 }
1630 1669
1631 1670 /// Simulates the worst-case recovery path: a prior partial migration left
@@ -1669,7 +1708,7 @@ mod tests {
1669 1708 .conn()
1670 1709 .query_row("PRAGMA user_version", [], |row| row.get(0))
1671 1710 .unwrap();
1672 - assert_eq!(version, 24);
1711 + assert_eq!(version, 25);
1673 1712 }
1674 1713
1675 1714 /// M018 contract: the `sync_changelog.row_id` for sensitive tables must
@@ -1891,7 +1930,7 @@ mod tests {
1891 1930 let initial_version: i32 = conn
1892 1931 .query_row("PRAGMA user_version", [], |row| row.get(0))
1893 1932 .unwrap();
1894 - assert_eq!(initial_version, 24);
1933 + assert_eq!(initial_version, 25);
1895 1934
1896 1935 let batch = format!(
1897 1936 "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;",
@@ -1954,7 +1993,7 @@ mod tests {
1954 1993 .conn()
1955 1994 .query_row("PRAGMA user_version", [], |row| row.get(0))
1956 1995 .unwrap();
1957 - assert_eq!(version, 24);
1996 + assert_eq!(version, 25);
1958 1997 }
1959 1998
1960 1999 #[test]
@@ -78,6 +78,11 @@ pub enum CoreError {
78 78 /// Internal logic error (e.g. invalid arguments to an internal function).
79 79 #[error("internal error: {0}")]
80 80 Internal(String),
81 +
82 + /// A user-supplied artifact (e.g. an `.afcl` file) is incompatible with this build.
83 + /// Message is shown to the user verbatim (no prefix).
84 + #[error("{0}")]
85 + Incompatible(String),
81 86 }
82 87
83 88 /// Typed errors for the audio analysis/decode pipeline.