Skip to main content

max / audiofiles

Stop presenting rule-based classification at a fixed 0.7 confidence classify_ml returned confidence 0.0 as a sentinel meaning "rule-based, not probabilistic". suggest.rs read that sentinel and did the opposite of what it implied: it substituted a fixed 0.7 and made the suggestion unconditional, so a label measured at 23.6% strict accuracy always suggested and always looked moderately confident. Clap and Tom are unreachable by any rule, so a user tagging those got a confident wrong suggestion every time. Make the absence explicit instead of encoding it as a number. ClassificationResult.confidence is now Option<f64>, classify_ml returns None, and the analysis result carries the Option through so rule-based rows store NULL. Tag suggestion proposes a classification tag only when a confidence is reported and clears 0.5. The class still shows in the UI; it just no longer proposes a tag it cannot stand behind. audiofiles-bench carries the Option through and prints n/a rather than averaging the sentinel into its accuracy table.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 23:45 UTC
Signed with PGP, not checked
Commit: 8bc8156581f5b487c041c243086c3e016a585215
Parent: 0aadac6
4 files changed, +79 insertions, -39 deletions
@@ -159,10 +159,12 @@
159 159 struct ClassifyResult {
160 160 expected: SampleClass,
161 161 predicted: SampleClass,
162 - confidence: f64,
162 + /// `None` when the classifier reported no confidence, which is every sample
163 + /// while the rule-based tree is the producer.
164 + confidence: Option<f64>,
163 165 }
164 166
165 - fn classify_file(path: &Path) -> Option<(SampleClass, f64)> {
167 + fn classify_file(path: &Path) -> Option<(SampleClass, Option<f64>)> {
166 168 let decoded = decode::decode_to_mono(path).ok()?;
167 169 let sr = decoded.sample_rate;
168 170 let max_samples = (30.0 * sr as f64) as usize;
@@ -795,8 +797,16 @@
795 797 .filter(|r| r.predicted == r.expected)
796 798 .count();
797 799 let acc = correct as f64 / n as f64 * 100.0;
798 - let avg_conf = class_results.iter().map(|r| r.confidence).sum::<f64>() / n as f64;
799 - println!(" {dir_name:<12} {n:>6} {correct:>8} {acc:>9.1}% {avg_conf:>9.2}");
800 + let reported: Vec<f64> = class_results.iter().filter_map(|r| r.confidence).collect();
801 + let avg_conf = if reported.is_empty() {
802 + "n/a".to_string()
803 + } else {
804 + format!(
805 + "{:.2}",
806 + reported.iter().sum::<f64>() / reported.len() as f64
807 + )
808 + };
809 + println!(" {dir_name:<12} {n:>6} {correct:>8} {acc:>9.1}% {avg_conf:>9}");
800 810 }
801 811 println!();
802 812
@@ -904,11 +914,12 @@
904 914 match analysis::analyze_sample("edge", path, &config) {
905 915 Ok(r) => {
906 916 println!(
907 - " {} → OK (dur={:.2}s, class={}, conf={:.2})",
917 + " {} → OK (dur={:.2}s, class={}, conf={})",
908 918 name,
909 919 r.duration,
910 920 r.classification.map_or("none", |c| c.as_str()),
911 - r.classification_confidence.unwrap_or(0.0)
921 + r.classification_confidence
922 + .map_or_else(|| "n/a".to_string(), |c| format!("{c:.2}"))
912 923 );
913 924 }
914 925 Err(e) => {
@@ -194,7 +194,11 @@
194 194 #[derive(Debug, Clone)]
195 195 pub struct ClassificationResult {
196 196 pub class: SampleClass,
197 - pub confidence: f64,
197 + /// `None` when the producing path is not probabilistic and has no confidence
198 + /// to report. Not a 0.0 sentinel: consumers must not read the absence of a
199 + /// number as a number, which is how the rule tree came to be presented at a
200 + /// fixed 0.7.
201 + pub confidence: Option<f64>,
198 202 }
199 203
200 204 // ClassifyInput
@@ -279,14 +283,15 @@
279 283 /// Classify a sample into a `SampleClass` using the rule-based threshold tree.
280 284 ///
281 285 /// The trained Random Forest layer was retired in the Phase 0 rework; this now
282 - /// delegates to `classify_full`. Confidence is reported as `0.0` to signal a
283 - /// rule-based (non-probabilistic) result, the same signal the prior no-model
284 - /// fallback emitted, which downstream tag suggestion already handles.
286 + /// delegates to `classify_full`, a threshold tree with no probability behind it,
287 + /// so confidence is `None`. Measured strict accuracy of the label is 23.6%, and
288 + /// `Clap`/`Tom` are unreachable, so nothing downstream should treat it as a
289 + /// confident answer.
285 290 #[instrument(skip_all)]
286 291 pub fn classify_ml(input: &ClassifyInput) -> ClassificationResult {
287 292 ClassificationResult {
288 293 class: classify_full(input),
289 - confidence: 0.0,
294 + confidence: None,
290 295 }
291 296 }
292 297
@@ -816,7 +821,8 @@
816 821 mfcc_variances: [0.0; 13],
817 822 };
818 823 let result = classify_ml(&input);
819 - // Should classify as a drum class with nonzero confidence
824 + // The rule tree has no probability behind it, so it reports no confidence.
825 + assert!(result.confidence.is_none());
820 826 assert!(
821 827 matches!(
822 828 result.class,
@@ -244,7 +244,7 @@
244 244 if config.classify {
245 245 let ml_result = classify::classify_ml(&input);
246 246 result.classification = Some(ml_result.class);
247 - result.classification_confidence = Some(ml_result.confidence);
247 + result.classification_confidence = ml_result.confidence;
248 248 }
249 249 }
250 250
@@ -36,34 +36,33 @@
36 36 pub source: SuggestionSource,
37 37 }
38 38
39 + /// Minimum reported confidence for a classification to propose its tag.
40 + const CLASSIFICATION_SUGGEST_FLOOR: f64 = 0.5;
41 +
39 42 /// Generate tag suggestions from analysis results. Pure function, no DB access.
40 43 #[instrument(skip_all)]
41 44 pub fn suggest_tags(result: &AnalysisResult) -> Vec<TagSuggestion> {
42 45 let mut suggestions = Vec::new();
43 46
44 - // 1. Classification tag, use ML confidence when available
45 - if let Some(ref class) = result.classification {
46 - let ml_conf = result.classification_confidence.unwrap_or(0.0);
47 - // Only suggest if ML confidence >= 0.5 (or rule-based fallback with 0.0 confidence)
48 - let suggest_conf = if ml_conf > 0.0 { ml_conf as f32 } else { 0.7 };
49 - if ml_conf == 0.0 || ml_conf >= 0.5 {
50 - let reason = if ml_conf > 0.0 {
51 - format!("ML classifier: {:.0}% confidence", ml_conf * 100.0)
52 - } else {
53 - format!(
54 - "Spectral: centroid {:.0} Hz, flatness {:.2}, crest {:.1}",
55 - result.spectral_centroid.unwrap_or(0.0),
56 - result.spectral_flatness.unwrap_or(0.0),
57 - result.crest_factor.unwrap_or(0.0),
58 - )
59 - };
60 - suggestions.push(TagSuggestion {
61 - tag: class.tag().to_string(),
62 - reason,
63 - confidence: suggest_conf,
64 - source: SuggestionSource::Classification,
65 - });
66 - }
47 + // 1. Classification tag, only from a path that reports a real confidence.
48 + //
49 + // A missing confidence means the producer is not probabilistic (today: the
50 + // rule-based threshold tree), not that it is certain. Suggesting on that
51 + // basis proposed a label measured at 23.6% strict accuracy, with two of the
52 + // seven classes unreachable and therefore always wrong. The class still
53 + // shows in the UI; it just does not propose a tag it cannot stand behind.
54 + if let (Some(class), Some(conf)) = (
55 + result.classification.as_ref(),
56 + result
57 + .classification_confidence
58 + .filter(|c| *c >= CLASSIFICATION_SUGGEST_FLOOR),
59 + ) {
60 + suggestions.push(TagSuggestion {
61 + tag: class.tag().to_string(),
62 + reason: format!("ML classifier: {:.0}% confidence", conf * 100.0),
63 + confidence: conf as f32,
64 + source: SuggestionSource::Classification,
65 + });
67 66 }
68 67
69 68 // 2. BPM tag, high confidence (0.8) because BPM detection is fairly reliable
@@ -219,10 +218,34 @@
219 218 }
220 219
221 220 #[test]
222 - fn suggests_classification_tag() {
223 - let result = make_result();
221 + fn suggests_classification_tag_when_confidence_is_reported() {
222 + let mut result = make_result();
223 + result.classification_confidence = Some(0.87);
224 224 let tags = suggest_tags(&result);
225 - assert!(tags.iter().any(|t| t.tag == "instrument.drum.kick"));
225 + let tag = tags
226 + .iter()
227 + .find(|t| t.tag == "instrument.drum.kick")
228 + .expect("classification tag");
229 + assert!((tag.confidence - 0.87).abs() < f32::EPSILON);
230 + }
231 +
232 + #[test]
233 + fn no_classification_tag_without_a_confidence() {
234 + // The rule-based tree reports no confidence. It used to be laundered into
235 + // a fixed 0.7 and suggested unconditionally.
236 + let result = make_result();
237 + assert!(result.classification.is_some());
238 + assert!(result.classification_confidence.is_none());
239 + let tags = suggest_tags(&result);
240 + assert!(!tags.iter().any(|t| t.tag == "instrument.drum.kick"));
241 + }
242 +
243 + #[test]
244 + fn no_classification_tag_below_the_floor() {
245 + let mut result = make_result();
246 + result.classification_confidence = Some(0.49);
247 + let tags = suggest_tags(&result);
248 + assert!(!tags.iter().any(|t| t.tag == "instrument.drum.kick"));
226 249 }
227 250
228 251 #[test]