Skip to main content

max / audiofiles

11.6 KB · 317 lines History Blame Raw
1 //! The coarse family taxonomy, and the projection from corpus labels onto it.
2 //!
3 //! Everything the layer is graded on runs through here. The corpus is labelled at
4 //! instrument resolution because that is what its folder names carry, but
5 //! instrument resolution is not what audiofiles classifies at and has not been
6 //! since 2026-07-29: the same 35 features separate coarse families at 92.4% on one
7 //! unfitted centroid cut and specific instruments at 33.4% with ~40 tuned
8 //! thresholds, and only 13.5% of real users tag a specific instrument at all.
9 //! Wiki `af-coarse-families` is the taxonomy, `af-browse-axes` the evidence.
10 //!
11 //! So the vault keeps the fine labels (the `.afcl` export is built from the same
12 //! vault and is unchanged by any of this) and the evaluation projects them onto
13 //! families as it reads them back. One swap, no second corpus, and the retired
14 //! instrument-resolution question stays reproducible via [`LabelSpace::Instrument`]
15 //! rather than being deleted out from under the two write-ups that report it.
16 //!
17 //! # What this corpus can and cannot say
18 //!
19 //! The labelled corpus is 1,049 drum one-shots. Projected onto families it covers
20 //! **two of seven**: `low` and `drum-bright`. It says nothing about `bass`,
21 //! `tonal`, `vocal`, `texture` or `music`, and a number measured here must never
22 //! be reported as a verdict on the layer. Widening the corpus is Phase 3.
23
24 /// A coarse family: the resolution the classifier is meant to answer at.
25 pub(crate) struct Family {
26 /// Short label for report tables.
27 pub(crate) label: &'static str,
28 /// The tag carried in the index.
29 pub(crate) tag: &'static str,
30 }
31
32 /// The seven families from `af-coarse-families`, in register order.
33 ///
34 /// The `family.` prefix is **ratified** (2026-08-07), not a placeholder. The note
35 /// writes them as bare words in prose; the tags are namespaced, for three reasons
36 /// that are mechanical rather than cosmetic:
37 ///
38 /// - `exemplar::apply_policy` skips any tag the sample already carries. Sharing
39 /// `instrument.*` with the filename rules would mean the layer silently no-ops
40 /// wherever a rule already fired, and the layer's value-add over the rules is
41 /// the one number the ship decision most needs.
42 /// - A sample is one family but legitimately several instruments, so exclusivity
43 /// is expressible under a dedicated prefix (`tags::remove_tags_by_prefix`) and
44 /// never under `instrument.*`.
45 /// - Not a novel namespace: `starter_rules::OTHER_CLASSES` already ships
46 /// `type.loop` and `character.fx` beside `instrument.*`.
47 pub(crate) const FAMILIES: &[Family] = &[
48 Family {
49 label: "low",
50 tag: "family.low",
51 },
52 Family {
53 label: "bass",
54 tag: "family.bass",
55 },
56 Family {
57 label: "tonal",
58 tag: "family.tonal",
59 },
60 Family {
61 label: "drum-bright",
62 tag: "family.drum-bright",
63 },
64 Family {
65 label: "vocal",
66 tag: "family.vocal",
67 },
68 Family {
69 label: "texture",
70 tag: "family.texture",
71 },
72 Family {
73 label: "music",
74 tag: "family.music",
75 },
76 ];
77
78 /// `tom` held out as its own class, to test whether it earns a family.
79 ///
80 /// Not a member of [`FAMILIES`] and not a shipping candidate. `af-coarse-families`
81 /// calls tom the one open split worth measuring: it sits between `low` and `bass`
82 /// on centroid (p25-p75 904-1996 against kick's 397-885) and is 246 files, 23% of
83 /// the drum corpus, so folding it into `low` silently is a big assumption to leave
84 /// untested.
85 pub(crate) const TOM_PROVISIONAL: &str = "family.tom-provisional";
86
87 /// Which resolution an evaluation runs at.
88 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
89 pub(crate) enum LabelSpace {
90 /// Seven specific drum instruments. The retired question, kept runnable so the
91 /// results already written up stay reproducible.
92 Instrument,
93 /// Coarse families, `tom` folded into `low` as the note proposes.
94 Family,
95 /// Coarse families, `tom` held out as its own class.
96 FamilyTomSplit,
97 }
98
99 impl LabelSpace {
100 /// Parse `AF_BENCH_EVAL_LABELS`. Unknown values fall back to the default
101 /// rather than erroring, but say so.
102 pub(crate) fn from_env() -> Self {
103 match std::env::var("AF_BENCH_EVAL_LABELS").as_deref() {
104 Ok("instrument") => Self::Instrument,
105 Ok("family-tom-split") => Self::FamilyTomSplit,
106 Ok("family") | Err(_) => Self::Family,
107 Ok(other) => {
108 eprintln!(
109 "AF_BENCH_EVAL_LABELS={other} is not one of instrument, family, \
110 family-tom-split; using family"
111 );
112 Self::Family
113 }
114 }
115 }
116
117 pub(crate) fn describe(self) -> &'static str {
118 match self {
119 Self::Instrument => "instrument (retired resolution, kept reproducible)",
120 Self::Family => "coarse family, tom folded into low",
121 Self::FamilyTomSplit => "coarse family, tom held out as its own class",
122 }
123 }
124 }
125
126 /// Project one corpus tag onto the evaluation's label space.
127 ///
128 /// `None` drops the row from the run entirely, train and test both. There is
129 /// exactly one such case and it is deliberate: see [`DROPPED_NOTE`].
130 pub(crate) fn project(space: LabelSpace, corpus_tag: &str) -> Option<&'static str> {
131 if space == LabelSpace::Instrument {
132 // Corpus tags are already instrument tags, so this is the identity. The
133 // table is still walked, for the `'static` copy the caller needs and to
134 // reject a tag the corpus never produced.
135 return CORPUS
136 .iter()
137 .find(|(t, _, _)| *t == corpus_tag)
138 .map(|(t, _, _)| *t);
139 }
140 let (_, family, split) = CORPUS.iter().find(|(t, _, _)| *t == corpus_tag)?;
141 match (space, split) {
142 (LabelSpace::FamilyTomSplit, Some(s)) => Some(s),
143 _ => *family,
144 }
145 }
146
147 /// Corpus tag -> (family, class when tom is split out).
148 ///
149 /// A `None` family is a folder with no honest family label. `percussion` is the
150 /// only one and it is 167 files, 16% of the corpus. `af-coarse-families` measured
151 /// it 47 low / 120 bright, 13% coherence across 19 perceptual bins, and
152 /// deliberately gives it no family: its members belong in `low` or `drum-bright`
153 /// by register. That per-file split cannot be made here. The only register signal
154 /// available is spectral centroid, which is one of the 35 features the layer
155 /// scores on, so labelling ground truth with it would grade the classifier against
156 /// its own input and report a class it cannot miss. Excluded, loudly, rather than
157 /// guessed.
158 type CorpusRow = (&'static str, Option<&'static str>, Option<&'static str>);
159 const CORPUS: &[CorpusRow] = &[
160 ("instrument.drum.kick", Some("family.low"), None),
161 (
162 "instrument.drum.tom",
163 Some("family.low"),
164 Some(TOM_PROVISIONAL),
165 ),
166 ("instrument.drum.snare", Some("family.drum-bright"), None),
167 ("instrument.drum.clap", Some("family.drum-bright"), None),
168 ("instrument.drum.hihat", Some("family.drum-bright"), None),
169 ("instrument.drum.cymbal", Some("family.drum-bright"), None),
170 ("instrument.percussion", None, None),
171 ];
172
173 /// Printed whenever a projection drops rows, so the exclusion is never silent.
174 pub(crate) const DROPPED_NOTE: &str =
175 " Percussion has no honest family label: it measures 47 low / 120 bright and 13%
176 coherence across 19 perceptual bins, and the only per-file register signal
177 available is a feature the layer already scores on. Splitting it by centroid
178 would grade the classifier against its own input. Dropped from train and test
179 both; texture is its likely home once Phase 3 has material to check against.";
180
181 /// Report label for a tag in this space.
182 pub(crate) fn label_for(space: LabelSpace, tag: &str) -> &str {
183 if space == LabelSpace::Instrument {
184 return crate::labelled::label_for_tag(tag);
185 }
186 if tag == TOM_PROVISIONAL {
187 return "tom-prov";
188 }
189 FAMILIES
190 .iter()
191 .find(|f| f.tag == tag)
192 .map_or(tag, |f| f.label)
193 }
194
195 /// Which families the corpus can say anything at all about, for the verdict.
196 pub(crate) fn covered_families(present: &[String]) -> (Vec<&'static str>, Vec<&'static str>) {
197 let covered: Vec<&'static str> = FAMILIES
198 .iter()
199 .filter(|f| present.iter().any(|p| p == f.tag))
200 .map(|f| f.label)
201 .collect();
202 let uncovered: Vec<&'static str> = FAMILIES
203 .iter()
204 .filter(|f| !present.iter().any(|p| p == f.tag))
205 .map(|f| f.label)
206 .collect();
207 (covered, uncovered)
208 }
209
210 #[cfg(test)]
211 mod tests {
212 use super::*;
213
214 #[test]
215 fn drums_project_onto_two_families() {
216 assert_eq!(
217 project(LabelSpace::Family, "instrument.drum.kick"),
218 Some("family.low")
219 );
220 assert_eq!(
221 project(LabelSpace::Family, "instrument.drum.tom"),
222 Some("family.low")
223 );
224 for bright in [
225 "instrument.drum.snare",
226 "instrument.drum.clap",
227 "instrument.drum.hihat",
228 "instrument.drum.cymbal",
229 ] {
230 assert_eq!(
231 project(LabelSpace::Family, bright),
232 Some("family.drum-bright"),
233 "{bright}"
234 );
235 }
236 }
237
238 #[test]
239 fn percussion_is_dropped_not_guessed() {
240 // The class the note calls genuinely both. Any Some() here would be a
241 // ground-truth label invented from the classifier's own input.
242 assert_eq!(project(LabelSpace::Family, "instrument.percussion"), None);
243 assert_eq!(
244 project(LabelSpace::FamilyTomSplit, "instrument.percussion"),
245 None
246 );
247 assert_eq!(
248 project(LabelSpace::Instrument, "instrument.percussion"),
249 Some("instrument.percussion")
250 );
251 }
252
253 #[test]
254 fn the_tom_split_moves_only_tom() {
255 assert_eq!(
256 project(LabelSpace::FamilyTomSplit, "instrument.drum.tom"),
257 Some(TOM_PROVISIONAL)
258 );
259 assert_eq!(
260 project(LabelSpace::FamilyTomSplit, "instrument.drum.kick"),
261 Some("family.low")
262 );
263 }
264
265 #[test]
266 fn instrument_space_is_the_identity_on_corpus_tags() {
267 for (tag, _, _) in CORPUS {
268 assert_eq!(project(LabelSpace::Instrument, tag), Some(*tag));
269 }
270 }
271
272 #[test]
273 fn an_unknown_tag_projects_nowhere() {
274 assert_eq!(project(LabelSpace::Family, "instrument.bass"), None);
275 assert_eq!(project(LabelSpace::Instrument, "instrument.bass"), None);
276 }
277
278 #[test]
279 fn every_corpus_family_is_a_real_family() {
280 for (tag, family, _) in CORPUS {
281 if let Some(f) = family {
282 assert!(
283 FAMILIES.iter().any(|x| x.tag == *f),
284 "{tag} maps to {f}, which is not a family"
285 );
286 }
287 }
288 }
289
290 #[test]
291 fn coverage_names_the_five_families_the_corpus_cannot_reach() {
292 let present = vec!["family.low".to_string(), "family.drum-bright".to_string()];
293 let (covered, uncovered) = covered_families(&present);
294 assert_eq!(covered, vec!["low", "drum-bright"]);
295 assert_eq!(
296 uncovered,
297 vec!["bass", "tonal", "vocal", "texture", "music"]
298 );
299 }
300
301 #[test]
302 fn labels_shorten_for_report_tables() {
303 assert_eq!(
304 label_for(LabelSpace::Family, "family.drum-bright"),
305 "drum-bright"
306 );
307 assert_eq!(
308 label_for(LabelSpace::FamilyTomSplit, TOM_PROVISIONAL),
309 "tom-prov"
310 );
311 assert_eq!(
312 label_for(LabelSpace::Instrument, "instrument.drum.kick"),
313 "kick"
314 );
315 }
316 }
317