//! The coarse family taxonomy, and the projection from corpus labels onto it. //! //! Everything the layer is graded on runs through here. The corpus is labelled at //! instrument resolution because that is what its folder names carry, but //! instrument resolution is not what audiofiles classifies at and has not been //! since 2026-07-29: the same 35 features separate coarse families at 92.4% on one //! unfitted centroid cut and specific instruments at 33.4% with ~40 tuned //! thresholds, and only 13.5% of real users tag a specific instrument at all. //! Wiki `af-coarse-families` is the taxonomy, `af-browse-axes` the evidence. //! //! So the vault keeps the fine labels (the `.afcl` export is built from the same //! vault and is unchanged by any of this) and the evaluation projects them onto //! families as it reads them back. One swap, no second corpus, and the retired //! instrument-resolution question stays reproducible via [`LabelSpace::Instrument`] //! rather than being deleted out from under the two write-ups that report it. //! //! # What this corpus can and cannot say //! //! The labelled corpus is 1,049 drum one-shots. Projected onto families it covers //! **two of seven**: `low` and `drum-bright`. It says nothing about `bass`, //! `tonal`, `vocal`, `texture` or `music`, and a number measured here must never //! be reported as a verdict on the layer. Widening the corpus is Phase 3. /// A coarse family: the resolution the classifier is meant to answer at. pub(crate) struct Family { /// Short label for report tables. pub(crate) label: &'static str, /// The tag carried in the index. pub(crate) tag: &'static str, } /// The seven families from `af-coarse-families`, in register order. /// /// The `family.` prefix is **ratified** (2026-08-07), not a placeholder. The note /// writes them as bare words in prose; the tags are namespaced, for three reasons /// that are mechanical rather than cosmetic: /// /// - `exemplar::apply_policy` skips any tag the sample already carries. Sharing /// `instrument.*` with the filename rules would mean the layer silently no-ops /// wherever a rule already fired, and the layer's value-add over the rules is /// the one number the ship decision most needs. /// - A sample is one family but legitimately several instruments, so exclusivity /// is expressible under a dedicated prefix (`tags::remove_tags_by_prefix`) and /// never under `instrument.*`. /// - Not a novel namespace: `starter_rules::OTHER_CLASSES` already ships /// `type.loop` and `character.fx` beside `instrument.*`. pub(crate) const FAMILIES: &[Family] = &[ Family { label: "low", tag: "family.low", }, Family { label: "bass", tag: "family.bass", }, Family { label: "tonal", tag: "family.tonal", }, Family { label: "drum-bright", tag: "family.drum-bright", }, Family { label: "vocal", tag: "family.vocal", }, Family { label: "texture", tag: "family.texture", }, Family { label: "music", tag: "family.music", }, ]; /// `tom` held out as its own class, to test whether it earns a family. /// /// Not a member of [`FAMILIES`] and not a shipping candidate. `af-coarse-families` /// calls tom the one open split worth measuring: it sits between `low` and `bass` /// on centroid (p25-p75 904-1996 against kick's 397-885) and is 246 files, 23% of /// the drum corpus, so folding it into `low` silently is a big assumption to leave /// untested. pub(crate) const TOM_PROVISIONAL: &str = "family.tom-provisional"; /// Which resolution an evaluation runs at. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum LabelSpace { /// Seven specific drum instruments. The retired question, kept runnable so the /// results already written up stay reproducible. Instrument, /// Coarse families, `tom` folded into `low` as the note proposes. Family, /// Coarse families, `tom` held out as its own class. FamilyTomSplit, } impl LabelSpace { /// Parse `AF_BENCH_EVAL_LABELS`. Unknown values fall back to the default /// rather than erroring, but say so. pub(crate) fn from_env() -> Self { match std::env::var("AF_BENCH_EVAL_LABELS").as_deref() { Ok("instrument") => Self::Instrument, Ok("family-tom-split") => Self::FamilyTomSplit, Ok("family") | Err(_) => Self::Family, Ok(other) => { eprintln!( "AF_BENCH_EVAL_LABELS={other} is not one of instrument, family, \ family-tom-split; using family" ); Self::Family } } } pub(crate) fn describe(self) -> &'static str { match self { Self::Instrument => "instrument (retired resolution, kept reproducible)", Self::Family => "coarse family, tom folded into low", Self::FamilyTomSplit => "coarse family, tom held out as its own class", } } } /// Project one corpus tag onto the evaluation's label space. /// /// `None` drops the row from the run entirely, train and test both. There is /// exactly one such case and it is deliberate: see [`DROPPED_NOTE`]. pub(crate) fn project(space: LabelSpace, corpus_tag: &str) -> Option<&'static str> { if space == LabelSpace::Instrument { // Corpus tags are already instrument tags, so this is the identity. The // table is still walked, for the `'static` copy the caller needs and to // reject a tag the corpus never produced. return CORPUS .iter() .find(|(t, _, _)| *t == corpus_tag) .map(|(t, _, _)| *t); } let (_, family, split) = CORPUS.iter().find(|(t, _, _)| *t == corpus_tag)?; match (space, split) { (LabelSpace::FamilyTomSplit, Some(s)) => Some(s), _ => *family, } } /// Corpus tag -> (family, class when tom is split out). /// /// A `None` family is a folder with no honest family label. `percussion` is the /// only one and it is 167 files, 16% of the corpus. `af-coarse-families` measured /// it 47 low / 120 bright, 13% coherence across 19 perceptual bins, and /// deliberately gives it no family: its members belong in `low` or `drum-bright` /// by register. That per-file split cannot be made here. The only register signal /// available is spectral centroid, which is one of the 35 features the layer /// scores on, so labelling ground truth with it would grade the classifier against /// its own input and report a class it cannot miss. Excluded, loudly, rather than /// guessed. type CorpusRow = (&'static str, Option<&'static str>, Option<&'static str>); const CORPUS: &[CorpusRow] = &[ ("instrument.drum.kick", Some("family.low"), None), ( "instrument.drum.tom", Some("family.low"), Some(TOM_PROVISIONAL), ), ("instrument.drum.snare", Some("family.drum-bright"), None), ("instrument.drum.clap", Some("family.drum-bright"), None), ("instrument.drum.hihat", Some("family.drum-bright"), None), ("instrument.drum.cymbal", Some("family.drum-bright"), None), ("instrument.percussion", None, None), ]; /// Printed whenever a projection drops rows, so the exclusion is never silent. pub(crate) const DROPPED_NOTE: &str = " Percussion has no honest family label: it measures 47 low / 120 bright and 13% coherence across 19 perceptual bins, and the only per-file register signal available is a feature the layer already scores on. Splitting it by centroid would grade the classifier against its own input. Dropped from train and test both; texture is its likely home once Phase 3 has material to check against."; /// Report label for a tag in this space. pub(crate) fn label_for(space: LabelSpace, tag: &str) -> &str { if space == LabelSpace::Instrument { return crate::labelled::label_for_tag(tag); } if tag == TOM_PROVISIONAL { return "tom-prov"; } FAMILIES .iter() .find(|f| f.tag == tag) .map_or(tag, |f| f.label) } /// Which families the corpus can say anything at all about, for the verdict. pub(crate) fn covered_families(present: &[String]) -> (Vec<&'static str>, Vec<&'static str>) { let covered: Vec<&'static str> = FAMILIES .iter() .filter(|f| present.iter().any(|p| p == f.tag)) .map(|f| f.label) .collect(); let uncovered: Vec<&'static str> = FAMILIES .iter() .filter(|f| !present.iter().any(|p| p == f.tag)) .map(|f| f.label) .collect(); (covered, uncovered) } #[cfg(test)] mod tests { use super::*; #[test] fn drums_project_onto_two_families() { assert_eq!( project(LabelSpace::Family, "instrument.drum.kick"), Some("family.low") ); assert_eq!( project(LabelSpace::Family, "instrument.drum.tom"), Some("family.low") ); for bright in [ "instrument.drum.snare", "instrument.drum.clap", "instrument.drum.hihat", "instrument.drum.cymbal", ] { assert_eq!( project(LabelSpace::Family, bright), Some("family.drum-bright"), "{bright}" ); } } #[test] fn percussion_is_dropped_not_guessed() { // The class the note calls genuinely both. Any Some() here would be a // ground-truth label invented from the classifier's own input. assert_eq!(project(LabelSpace::Family, "instrument.percussion"), None); assert_eq!( project(LabelSpace::FamilyTomSplit, "instrument.percussion"), None ); assert_eq!( project(LabelSpace::Instrument, "instrument.percussion"), Some("instrument.percussion") ); } #[test] fn the_tom_split_moves_only_tom() { assert_eq!( project(LabelSpace::FamilyTomSplit, "instrument.drum.tom"), Some(TOM_PROVISIONAL) ); assert_eq!( project(LabelSpace::FamilyTomSplit, "instrument.drum.kick"), Some("family.low") ); } #[test] fn instrument_space_is_the_identity_on_corpus_tags() { for (tag, _, _) in CORPUS { assert_eq!(project(LabelSpace::Instrument, tag), Some(*tag)); } } #[test] fn an_unknown_tag_projects_nowhere() { assert_eq!(project(LabelSpace::Family, "instrument.bass"), None); assert_eq!(project(LabelSpace::Instrument, "instrument.bass"), None); } #[test] fn every_corpus_family_is_a_real_family() { for (tag, family, _) in CORPUS { if let Some(f) = family { assert!( FAMILIES.iter().any(|x| x.tag == *f), "{tag} maps to {f}, which is not a family" ); } } } #[test] fn coverage_names_the_five_families_the_corpus_cannot_reach() { let present = vec!["family.low".to_string(), "family.drum-bright".to_string()]; let (covered, uncovered) = covered_families(&present); assert_eq!(covered, vec!["low", "drum-bright"]); assert_eq!( uncovered, vec!["bass", "tonal", "vocal", "texture", "music"] ); } #[test] fn labels_shorten_for_report_tables() { assert_eq!( label_for(LabelSpace::Family, "family.drum-bright"), "drum-bright" ); assert_eq!( label_for(LabelSpace::FamilyTomSplit, TOM_PROVISIONAL), "tom-prov" ); assert_eq!( label_for(LabelSpace::Instrument, "instrument.drum.kick"), "kick" ); } }