max / audiofiles
6 files changed,
+871 insertions,
-253 deletions
| @@ -24,6 +24,7 @@ | |||
| 24 | 24 | cargo run --release -p audiofiles-bench # analysis pipeline | |
| 25 | 25 | cargo run --release -p audiofiles-bench -- ingest # import and query | |
| 26 | 26 | cargo run --release -p audiofiles-bench -- accuracy # bpm/key vs ground truth | |
| 27 | + | cargo run --release -p audiofiles-bench -- layer-eval # classifier layer, cross-validated | |
| 27 | 28 | ``` | |
| 28 | 29 | ||
| 29 | 30 | ## Workspace Architecture | |
| @@ -37,7 +38,7 @@ | |||
| 37 | 38 | | `audiofiles-app` | `crates/audiofiles-app/` | Standalone desktop app via eframe. System audio (cpal), drag-and-drop import, native drag-out to Finder/DAWs, system tray, CLI import, OTA updates. | | |
| 38 | 39 | | `audiofiles-sync` | `crates/audiofiles-sync/` | Cloud sync via SyncKit. Pushes/pulls sample metadata, tags, and VFS structure across devices. E2E encrypted. | | |
| 39 | 40 | | `audiofiles-rhai` | `crates/audiofiles-rhai/` | Rhai scripting engine for device export profiles. Transforms sample metadata and file layout for hardware samplers. | | |
| 40 | - | | `audiofiles-bench` | `crates/audiofiles-bench/` | Benchmark binary. Analysis-pipeline timing, vault ingest and query latency, and BPM/key accuracy against labeled corpora. Not shipped in the app. | | |
| 41 | + | | `audiofiles-bench` | `crates/audiofiles-bench/` | Benchmark binary. Analysis-pipeline timing, vault ingest and query latency, BPM/key accuracy against labeled corpora, and cross-validated per-class accuracy for the classifier layer. Not shipped in the app. | | |
| 41 | 42 | ||
| 42 | 43 | Dependency flow: `audiofiles-core` is the leaf -> `audiofiles-rhai` and `audiofiles-sync` depend on core -> `audiofiles-browser` depends on core, sync, and rhai -> `audiofiles-app` depends on browser and core. `audiofiles-bench` depends on core only. | |
| 43 | 44 |
| @@ -159,13 +159,44 @@ | |||
| 159 | 159 | candidate source for the pitched families; it is 16 kHz mono against the drums' 44.1 | |
| 160 | 160 | kHz, and five of the 35 features are Nyquist-bounded, so the two cannot simply be mixed | |
| 161 | 161 | without the classifier learning sample rate as a proxy for instrument. | |
| 162 | - | 2. **Nothing measures whether the layer is any good.** The accuracy bench scores BPM and | |
| 163 | - | key only; class accuracy went away with the retired sample-class label. A default | |
| 164 | - | classifier that is confidently wrong is worse than no default, so shipping needs a | |
| 165 | - | held-out measurement and a threshold agreed before the number is read. | |
| 162 | + | 2. **The layer does not clear its own ship gate.** It is measured now: | |
| 163 | + | `cargo run --release -p audiofiles-bench -- layer-eval` cross-validates the k-NN | |
| 164 | + | layer over the labelled corpus, five stratified folds, at the runtime `k` and the | |
| 165 | + | runtime thresholds. The gate lives in `layer_eval.rs` and was written before the | |
| 166 | + | first run. | |
| 166 | 167 | ||
| 167 | 168 | Both are tracked in the audiofiles GoingsOn project. | |
| 168 | 169 | ||
| 170 | + | ### What the measurement says | |
| 171 | + | ||
| 172 | + | First run, 1049 files across the seven drum classes, features v5: | |
| 173 | + | ||
| 174 | + | | | top-1 recall | precision @ 0.85 | recall @ 0.85 | recall @ 0.50 | | |
| 175 | + | |---|---|---|---|---| | |
| 176 | + | | kick | 93.7% | 93.7% | 65.4% | 92.7% | | |
| 177 | + | | tom | 89.8% | 100% | 74.4% | 89.0% | | |
| 178 | + | | snare | 76.6% | 97.8% | 47.9% | 69.7% | | |
| 179 | + | | hi-hat | 71.6% | 100% | 5.5% | 56.0% | | |
| 180 | + | | clap | 70.8% | 100% | 6.2% | 47.9% | | |
| 181 | + | | cymbal | 62.8% | 100% | 2.3% | 44.2% | | |
| 182 | + | | percussion | 46.7% | never fires | 0.0% | 28.1% | | |
| 183 | + | | macro | 73.1% | 84.5% | 28.8% | 61.1% | | |
| 184 | + | ||
| 185 | + | The gate fails, and it fails on recall alone. Every class the layer auto-applies it | |
| 186 | + | auto-applies correctly (macro precision 84.5%, four classes at 100%), but only kick, | |
| 187 | + | snare and tom reach the 0.85 auto threshold often enough to be useful, and percussion | |
| 188 | + | never reaches it at all. The k-NN is conservative rather than wrong. | |
| 189 | + | ||
| 190 | + | Read against the retired threshold tree, this is a different result: 73.1% macro top-1 | |
| 191 | + | against that tree's 33.4% strict, with no unreachable class. Instrument identity is | |
| 192 | + | more present in the 35-feature vector under k-NN than it was under thresholds. It is | |
| 193 | + | still not present enough for a default that writes tags unasked, which is the | |
| 194 | + | distinction the auto and review columns above draw. | |
| 195 | + | ||
| 196 | + | Percussion is the class to read first. It is a catch-all (cowbell, clave, maraca, | |
| 197 | + | bongo, conga) rather than one instrument, and the confusion matrix scatters it across | |
| 198 | + | every other row. A taxonomy fix, not a tuning problem. | |
| 199 | + | ||
| 169 | 200 | ## Feature vector | |
| 170 | 201 | ||
| 171 | 202 | 35 features: 9 scalar + 13 MFCC means + 13 MFCC variances. |
| @@ -5,10 +5,9 @@ | |||
| 5 | 5 | //! duplicating both in a second binary to keep the crate's name honest would cost | |
| 6 | 6 | //! more than the misnomer does. | |
| 7 | 7 | //! | |
| 8 | - | //! What it does: import every labelled file into a throwaway vault, analyse it so | |
| 9 | - | //! `sample_features` holds a vector, tag it from the folder it came in, then call | |
| 10 | - | //! [`build_export`] with `kind = "official"`. The exemplars in the resulting layer | |
| 11 | - | //! are (vector, tags) pairs and carry no audio and no hashes, which is the whole | |
| 8 | + | //! What it does: build the labelled scratch vault ([`crate::labelled`]) and export | |
| 9 | + | //! it with `kind = "official"`. The exemplars in the resulting layer are | |
| 10 | + | //! (vector, tags) pairs and carry no audio and no hashes, which is the whole | |
| 12 | 11 | //! reason a `.afcl` can ship at all. | |
| 13 | 12 | //! | |
| 14 | 13 | //! The output is a build artifact, not a test fixture. Generate it deliberately, | |
| @@ -16,44 +15,17 @@ | |||
| 16 | 15 | //! on `feat_version` and a layer built under an old one is rejected outright, so a | |
| 17 | 16 | //! stale layer is worse than no layer. | |
| 18 | 17 | ||
| 19 | - | use std::collections::BTreeMap; | |
| 20 | 18 | use std::path::{Path, PathBuf}; | |
| 21 | - | use std::time::Instant; | |
| 22 | 19 | ||
| 23 | 20 | use audiofiles_core::analysis::afcl::{ExportOptions, LayerKind, export_to_path}; | |
| 21 | + | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 24 | 22 | use audiofiles_core::analysis::features::FEATURE_VERSION; | |
| 25 | - | use audiofiles_core::analysis::{self, config::AnalysisConfig}; | |
| 26 | - | use audiofiles_core::db::Database; | |
| 27 | - | use audiofiles_core::starter_rules::DRUM_CLASSES; | |
| 28 | - | use audiofiles_core::store::SampleStore; | |
| 29 | - | use audiofiles_core::tags; | |
| 30 | 23 | ||
| 31 | - | /// Corpus subdirectory holding the labelled one-shots, one folder per class. | |
| 32 | - | const TRAINING_SUBDIR: &str = "training"; | |
| 24 | + | use crate::labelled; | |
| 33 | 25 | ||
| 34 | 26 | /// Corpus manifest naming the source datasets and their licences. | |
| 35 | 27 | const MANIFEST: &str = "MANIFEST.json"; | |
| 36 | 28 | ||
| 37 | - | /// Map a corpus folder name to its canonical tag. | |
| 38 | - | /// | |
| 39 | - | /// The folder names and [`DRUM_CLASSES`] are the same taxonomy, so this resolves | |
| 40 | - | /// against that table rather than carrying a second copy of it. The only mismatch | |
| 41 | - | /// is punctuation: the table labels the class `hi-hat` and the corpus folder is | |
| 42 | - | /// `hihat`, so both sides are compared with `-` and spaces removed. | |
| 43 | - | fn tag_for_folder(folder: &str) -> Option<&'static str> { | |
| 44 | - | fn squash(s: &str) -> String { | |
| 45 | - | s.chars() | |
| 46 | - | .filter(|c| !matches!(c, '-' | ' ' | '_')) | |
| 47 | - | .flat_map(char::to_lowercase) | |
| 48 | - | .collect() | |
| 49 | - | } | |
| 50 | - | let want = squash(folder); | |
| 51 | - | DRUM_CLASSES | |
| 52 | - | .iter() | |
| 53 | - | .find(|c| squash(c.label) == want) | |
| 54 | - | .map(|c| c.tag) | |
| 55 | - | } | |
| 56 | - | ||
| 57 | 29 | /// Read the corpus manifest into an attribution line for `license_note`. | |
| 58 | 30 | /// | |
| 59 | 31 | /// This is the CC-BY attribution, and it is the only place it travels: an `.afcl` | |
| @@ -153,54 +125,6 @@ | |||
| 153 | 125 | )) | |
| 154 | 126 | } | |
| 155 | 127 | ||
| 156 | - | /// Collect the labelled files as (path, tag) pairs, grouped for a stable report. | |
| 157 | - | fn collect_labelled(training: &Path) -> Result<BTreeMap<&'static str, Vec<PathBuf>>, String> { | |
| 158 | - | let entries = std::fs::read_dir(training) | |
| 159 | - | .map_err(|e| format!("cannot read {}: {e}", training.display()))?; | |
| 160 | - | ||
| 161 | - | let mut by_tag: BTreeMap<&'static str, Vec<PathBuf>> = BTreeMap::new(); | |
| 162 | - | let mut unmapped = Vec::new(); | |
| 163 | - | for entry in entries.flatten() { | |
| 164 | - | if !entry.path().is_dir() { | |
| 165 | - | continue; | |
| 166 | - | } | |
| 167 | - | let folder = entry.file_name().to_string_lossy().to_string(); | |
| 168 | - | let Some(tag) = tag_for_folder(&folder) else { | |
| 169 | - | unmapped.push(folder); | |
| 170 | - | continue; | |
| 171 | - | }; | |
| 172 | - | let mut files: Vec<PathBuf> = std::fs::read_dir(entry.path()) | |
| 173 | - | .map_err(|e| format!("cannot read {}: {e}", entry.path().display()))? | |
| 174 | - | .flatten() | |
| 175 | - | .map(|f| f.path()) | |
| 176 | - | .filter(|p| p.is_file()) | |
| 177 | - | .collect(); | |
| 178 | - | // Deterministic order so two runs over the same corpus produce the same | |
| 179 | - | // layer, which is what makes the checked-in artifact reviewable. | |
| 180 | - | files.sort(); | |
| 181 | - | by_tag.entry(tag).or_default().extend(files); | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | if !unmapped.is_empty() { | |
| 185 | - | // Loud rather than silent: a folder nobody mapped is a class silently | |
| 186 | - | // missing from the shipped layer, which reads downstream as the | |
| 187 | - | // classifier being bad at that class rather than never having seen it. | |
| 188 | - | unmapped.sort(); | |
| 189 | - | return Err(format!( | |
| 190 | - | "no tag mapping for corpus folder(s): {}. Add them to DRUM_CLASSES or move them out of {}", | |
| 191 | - | unmapped.join(", "), | |
| 192 | - | training.display() | |
| 193 | - | )); | |
| 194 | - | } | |
| 195 | - | if by_tag.is_empty() { | |
| 196 | - | return Err(format!( | |
| 197 | - | "no labelled class folders under {}", | |
| 198 | - | training.display() | |
| 199 | - | )); | |
| 200 | - | } | |
| 201 | - | Ok(by_tag) | |
| 202 | - | } | |
| 203 | - | ||
| 204 | 128 | /// Generate the official layer from `corpus` into `out`, using `vault` as scratch. | |
| 205 | 129 | pub(crate) fn run(corpus: &Path, vault: &Path, out: &Path, config: &AnalysisConfig) { | |
| 206 | 130 | println!("━━━ OFFICIAL .afcl GENERATION ━━━"); | |
| @@ -219,111 +143,14 @@ | |||
| 219 | 143 | } | |
| 220 | 144 | }; | |
| 221 | 145 | ||
| 222 | - | let by_tag = match collect_labelled(&corpus.join(TRAINING_SUBDIR)) { | |
| 223 | - | Ok(m) => m, | |
| 146 | + | let vault = match labelled::build_vault(corpus, vault, config) { | |
| 147 | + | Ok(v) => v, | |
| 224 | 148 | Err(e) => { | |
| 225 | 149 | eprintln!("corpus: {e}"); | |
| 226 | 150 | std::process::exit(1); | |
| 227 | 151 | } | |
| 228 | 152 | }; | |
| 229 | - | ||
| 230 | - | let total: usize = by_tag.values().map(Vec::len).sum(); | |
| 231 | - | println!( | |
| 232 | - | " {} labelled file(s) across {} class(es):", | |
| 233 | - | total, | |
| 234 | - | by_tag.len() | |
| 235 | - | ); | |
| 236 | - | for (tag, files) in &by_tag { | |
| 237 | - | println!(" {:<28} {:>5}", tag, files.len()); | |
| 238 | - | } | |
| 239 | - | println!(); | |
| 240 | - | ||
| 241 | - | // A fresh vault every run. The layer is a pure function of the corpus, and | |
| 242 | - | // leftovers from a previous run would silently widen it. | |
| 243 | - | if vault.exists() | |
| 244 | - | && let Err(e) = std::fs::remove_dir_all(vault) | |
| 245 | - | { | |
| 246 | - | eprintln!("could not clear scratch vault {}: {e}", vault.display()); | |
| 247 | - | std::process::exit(1); | |
| 248 | - | } | |
| 249 | - | let samples_dir = vault.join("samples"); | |
| 250 | - | if let Err(e) = std::fs::create_dir_all(&samples_dir) { | |
| 251 | - | eprintln!("could not create {}: {e}", samples_dir.display()); | |
| 252 | - | std::process::exit(1); | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | let db = match Database::open(vault.join("audiofiles.db")) { | |
| 256 | - | Ok(db) => db, | |
| 257 | - | Err(e) => { | |
| 258 | - | eprintln!("Database::open failed: {e}"); | |
| 259 | - | std::process::exit(1); | |
| 260 | - | } | |
| 261 | - | }; | |
| 262 | - | let store = match SampleStore::new(&samples_dir) { | |
| 263 | - | Ok(s) => s, | |
| 264 | - | Err(e) => { | |
| 265 | - | eprintln!("SampleStore::new failed: {e}"); | |
| 266 | - | std::process::exit(1); | |
| 267 | - | } | |
| 268 | - | }; | |
| 269 | - | ||
| 270 | - | // Import and tag. No VFS nodes: nothing here runs a UI query, and the export | |
| 271 | - | // reads `sample_features` joined to `tags`, neither of which needs one. | |
| 272 | - | let start = Instant::now(); | |
| 273 | - | let mut to_analyze: Vec<(String, PathBuf)> = Vec::with_capacity(total); | |
| 274 | - | let mut import_failures = 0usize; | |
| 275 | - | for (tag, files) in &by_tag { | |
| 276 | - | for path in files { | |
| 277 | - | match store.import(path, &db) { | |
| 278 | - | Ok(hash) => { | |
| 279 | - | if let Err(e) = tags::add_tag(&db, &hash, tag) { | |
| 280 | - | eprintln!(" tag {tag} on {}: {e}", path.display()); | |
| 281 | - | import_failures += 1; | |
| 282 | - | continue; | |
| 283 | - | } | |
| 284 | - | to_analyze.push((hash, path.clone())); | |
| 285 | - | } | |
| 286 | - | Err(e) => { | |
| 287 | - | eprintln!(" import {}: {e}", path.display()); | |
| 288 | - | import_failures += 1; | |
| 289 | - | } | |
| 290 | - | } | |
| 291 | - | } | |
| 292 | - | } | |
| 293 | - | println!( | |
| 294 | - | " imported {} file(s) in {:.1}s{}", | |
| 295 | - | to_analyze.len(), | |
| 296 | - | start.elapsed().as_secs_f64(), | |
| 297 | - | if import_failures > 0 { | |
| 298 | - | format!(", {import_failures} failed") | |
| 299 | - | } else { | |
| 300 | - | String::new() | |
| 301 | - | } | |
| 302 | - | ); | |
| 303 | - | ||
| 304 | - | // Analyse everything. The ingest benchmark caps this because analysis is the | |
| 305 | - | // expensive stage and it only needs a sample; here every vector is the | |
| 306 | - | // payload, so there is no budget to apply. | |
| 307 | - | let start = Instant::now(); | |
| 308 | - | let results: Vec<_> = to_analyze | |
| 309 | - | .iter() | |
| 310 | - | .filter_map(|(hash, path)| analysis::analyze_sample(hash, path, config).ok()) | |
| 311 | - | .collect(); | |
| 312 | - | let analyzed = results.len(); | |
| 313 | - | if let Err(e) = analysis::save_analysis_batch(&db, &results) { | |
| 314 | - | eprintln!("save_analysis_batch failed: {e}"); | |
| 315 | - | std::process::exit(1); | |
| 316 | - | } | |
| 317 | - | println!( | |
| 318 | - | " analysed {} file(s) in {:.1}s{}", | |
| 319 | - | analyzed, | |
| 320 | - | start.elapsed().as_secs_f64(), | |
| 321 | - | if analyzed < to_analyze.len() { | |
| 322 | - | format!(", {} failed to analyse", to_analyze.len() - analyzed) | |
| 323 | - | } else { | |
| 324 | - | String::new() | |
| 325 | - | } | |
| 326 | - | ); | |
| 153 | + | let analyzed = vault.analyzed; | |
| 327 | 154 | ||
| 328 | 155 | // Exemplars only. Rules and policy are deliberately excluded: the app already | |
| 329 | 156 | // ships `starter_rules` in the binary, so exporting them here would put a | |
| @@ -332,7 +159,7 @@ | |||
| 332 | 159 | name: "Official drum classifier".to_string(), | |
| 333 | 160 | description: format!( | |
| 334 | 161 | "Bundled default layer. {analyzed} exemplars across {} classes, built from the labelled corpus.", | |
| 335 | - | by_tag.len() | |
| 162 | + | vault.by_tag.len() | |
| 336 | 163 | ), | |
| 337 | 164 | license_note: note, | |
| 338 | 165 | kind: LayerKind::Official, | |
| @@ -347,7 +174,7 @@ | |||
| 347 | 174 | eprintln!("could not create {}: {e}", parent.display()); | |
| 348 | 175 | std::process::exit(1); | |
| 349 | 176 | } | |
| 350 | - | if let Err(e) = export_to_path(&db, out, &opts) { | |
| 177 | + | if let Err(e) = export_to_path(&vault.db, out, &opts) { | |
| 351 | 178 | eprintln!("export failed: {e}"); | |
| 352 | 179 | std::process::exit(1); | |
| 353 | 180 | } | |
| @@ -374,44 +201,6 @@ | |||
| 374 | 201 | mod tests { | |
| 375 | 202 | use super::*; | |
| 376 | 203 | ||
| 377 | - | #[test] | |
| 378 | - | fn folder_names_map_to_canonical_tags() { | |
| 379 | - | assert_eq!(tag_for_folder("kick"), Some("instrument.drum.kick")); | |
| 380 | - | assert_eq!(tag_for_folder("snare"), Some("instrument.drum.snare")); | |
| 381 | - | assert_eq!(tag_for_folder("cymbal"), Some("instrument.drum.cymbal")); | |
| 382 | - | assert_eq!(tag_for_folder("clap"), Some("instrument.drum.clap")); | |
| 383 | - | assert_eq!(tag_for_folder("tom"), Some("instrument.drum.tom")); | |
| 384 | - | assert_eq!(tag_for_folder("percussion"), Some("instrument.percussion")); | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | #[test] | |
| 388 | - | fn hihat_folder_matches_the_hi_hat_label() { | |
| 389 | - | // The one place the corpus and DRUM_CLASSES disagree on spelling. | |
| 390 | - | assert_eq!(tag_for_folder("hihat"), Some("instrument.drum.hihat")); | |
| 391 | - | assert_eq!(tag_for_folder("hi-hat"), Some("instrument.drum.hihat")); | |
| 392 | - | assert_eq!(tag_for_folder("Hi Hat"), Some("instrument.drum.hihat")); | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | #[test] | |
| 396 | - | fn unknown_folder_has_no_tag() { | |
| 397 | - | assert_eq!(tag_for_folder("bass"), None); | |
| 398 | - | assert_eq!(tag_for_folder(""), None); | |
| 399 | - | } | |
| 400 | - | ||
| 401 | - | #[test] | |
| 402 | - | fn every_drum_class_is_reachable_from_some_folder_name() { | |
| 403 | - | // Guards the mapping against a DRUM_CLASSES entry whose label stops | |
| 404 | - | // resolving; without this, a renamed label silently drops a class. | |
| 405 | - | for class in DRUM_CLASSES { | |
| 406 | - | assert_eq!( | |
| 407 | - | tag_for_folder(class.label), | |
| 408 | - | Some(class.tag), | |
| 409 | - | "class {} no longer resolves from its own label", | |
| 410 | - | class.label | |
| 411 | - | ); | |
| 412 | - | } | |
| 413 | - | } | |
| 414 | - | ||
| 415 | 204 | #[test] | |
| 416 | 205 | fn license_note_requires_a_manifest() { | |
| 417 | 206 | let dir = tempfile::tempdir().unwrap(); | |
| @@ -512,31 +301,4 @@ | |||
| 512 | 301 | let err = license_note(dir.path()).unwrap_err(); | |
| 513 | 302 | assert!(err.contains("training_source"), "{err}"); | |
| 514 | 303 | } | |
| 515 | - | ||
| 516 | - | #[test] | |
| 517 | - | fn collect_labelled_rejects_an_unmapped_folder() { | |
| 518 | - | let dir = tempfile::tempdir().unwrap(); | |
| 519 | - | let training = dir.path().join(TRAINING_SUBDIR); | |
| 520 | - | std::fs::create_dir_all(training.join("kick")).unwrap(); | |
| 521 | - | std::fs::create_dir_all(training.join("didgeridoo")).unwrap(); | |
| 522 | - | let err = collect_labelled(&training).unwrap_err(); | |
| 523 | - | assert!(err.contains("didgeridoo"), "{err}"); | |
| 524 | - | } | |
| 525 | - | ||
| 526 | - | #[test] | |
| 527 | - | fn collect_labelled_groups_files_under_their_tag() { | |
| 528 | - | let dir = tempfile::tempdir().unwrap(); | |
| 529 | - | let training = dir.path().join(TRAINING_SUBDIR); | |
| 530 | - | std::fs::create_dir_all(training.join("kick")).unwrap(); | |
| 531 | - | std::fs::create_dir_all(training.join("snare")).unwrap(); | |
| 532 | - | std::fs::write(training.join("kick/b.wav"), b"x").unwrap(); | |
| 533 | - | std::fs::write(training.join("kick/a.wav"), b"x").unwrap(); | |
| 534 | - | std::fs::write(training.join("snare/c.wav"), b"x").unwrap(); | |
| 535 | - | ||
| 536 | - | let got = collect_labelled(&training).unwrap(); | |
| 537 | - | assert_eq!(got["instrument.drum.kick"].len(), 2); | |
| 538 | - | assert_eq!(got["instrument.drum.snare"].len(), 1); | |
| 539 | - | // Sorted, so the artifact is reproducible across runs. | |
| 540 | - | assert!(got["instrument.drum.kick"][0].ends_with("a.wav")); | |
| 541 | - | } | |
| 542 | 304 | } |
| @@ -12,17 +12,24 @@ | |||
| 12 | 12 | //! `cargo run --release -p audiofiles-bench -- accuracy` bpm/key vs ground truth | |
| 13 | 13 | //! `cargo run --release -p audiofiles-bench -- layout` blob layout migration | |
| 14 | 14 | //! `cargo run --release -p audiofiles-bench -- afcl` build the official layer | |
| 15 | + | //! `cargo run --release -p audiofiles-bench -- layer-eval` cross-validate that layer | |
| 15 | 16 | //! | |
| 16 | 17 | //! Two modes are not measurements. `layout` is a checker: it fabricates flat | |
| 17 | 18 | //! vaults, sweeps them, and exits non-zero if any scenario fails. `afcl` is a | |
| 18 | 19 | //! generator: it turns the labelled corpus into the bundled official `.afcl`. | |
| 19 | 20 | //! Both live here because corpus walking and scratch-vault fabrication do. | |
| 20 | 21 | //! | |
| 22 | + | //! `layer-eval` is the meter for what `afcl` generates: stratified k-fold | |
| 23 | + | //! cross-validation of the k-NN layer over the same corpus, per class, against a | |
| 24 | + | //! ship gate written down before the first run. It shares `afcl`'s corpus import | |
| 25 | + | //! (see `labelled`), which is why it lives beside it. | |
| 26 | + | //! | |
| 21 | 27 | //! Env: `AF_BENCH_CORPUS` (corpus root, default `<repo>/samples`), | |
| 22 | 28 | //! `AF_BENCH_VAULT` (scratch vault for ingest and layout), `AF_BENCH_FSL10K` | |
| 23 | 29 | //! (FSL10K root for accuracy), `AF_BENCH_BATCH`, `AF_BENCH_LIMIT`, | |
| 24 | 30 | //! `AF_BENCH_ANALYZE`, `AF_BENCH_LAYOUT_N`, `AF_BENCH_JSON` (machine-readable | |
| 25 | 31 | //! output path), `AF_AFCL_OUT` (where `afcl` writes the layer), | |
| 32 | + | //! `AF_BENCH_EVAL_FOLDS` (cross-validation folds for `layer-eval`), | |
| 26 | 33 | //! `AF_BENCH_STAGES` (files per per-stage probe during `ingest`, 0 = off). | |
| 27 | 34 | //! | |
| 28 | 35 | //! Section 1 times the analysis stages per file, against the corpus and no | |
| @@ -39,6 +46,8 @@ | |||
| 39 | 46 | mod accuracy; | |
| 40 | 47 | mod afcl_gen; | |
| 41 | 48 | mod ingest; | |
| 49 | + | mod labelled; | |
| 50 | + | mod layer_eval; | |
| 42 | 51 | mod layout; | |
| 43 | 52 | mod report; | |
| 44 | 53 | mod storage; | |
| @@ -326,6 +335,23 @@ | |||
| 326 | 335 | return; | |
| 327 | 336 | } | |
| 328 | 337 | ||
| 338 | + | if args.first().map(String::as_str) == Some("layer-eval") { | |
| 339 | + | // Its own scratch vault, distinct from `afcl`'s: the two modes rebuild | |
| 340 | + | // the same corpus and running one must not delete the other's vault out | |
| 341 | + | // from under a comparison. | |
| 342 | + | let vault = std::env::var("AF_BENCH_VAULT").map_or_else( | |
| 343 | + | |_| std::env::temp_dir().join("af-layer-eval"), | |
| 344 | + | PathBuf::from, | |
| 345 | + | ); | |
| 346 | + | layer_eval::run( | |
| 347 | + | &samples_dir, | |
| 348 | + | &vault, | |
| 349 | + | &full_pipeline_config(), | |
| 350 | + | layer_eval::folds_from_env(), | |
| 351 | + | ); | |
| 352 | + | return; | |
| 353 | + | } | |
| 354 | + | ||
| 329 | 355 | if args.first().map(String::as_str) == Some("accuracy") { | |
| 330 | 356 | let Ok(root) = std::env::var("AF_BENCH_FSL10K") else { | |
| 331 | 357 | eprintln!("set AF_BENCH_FSL10K to the extracted FSL10K root"); |
| @@ -1,0 +1,298 @@ | |||
| 1 | + | //! The labelled corpus: folder-to-tag mapping, and the scratch vault built from it. | |
| 2 | + | //! | |
| 3 | + | //! Two modes need the same thing before they can do anything: every file under | |
| 4 | + | //! `training/` imported, analysed, and tagged from the folder it came in, so | |
| 5 | + | //! `sample_features` holds a vector next to a label. `afcl` exports that state as | |
| 6 | + | //! a layer; `layer-eval` cross-validates over it. The step is expensive enough | |
| 7 | + | //! (decode plus full analysis per file) that it is worth sharing, and subtle | |
| 8 | + | //! enough (a fresh vault every run, a fatal unmapped folder) that two copies | |
| 9 | + | //! would drift. | |
| 10 | + | ||
| 11 | + | use std::collections::BTreeMap; | |
| 12 | + | use std::path::{Path, PathBuf}; | |
| 13 | + | use std::time::Instant; | |
| 14 | + | ||
| 15 | + | use audiofiles_core::analysis::{self, config::AnalysisConfig}; | |
| 16 | + | use audiofiles_core::db::Database; | |
| 17 | + | use audiofiles_core::starter_rules::DRUM_CLASSES; | |
| 18 | + | use audiofiles_core::store::SampleStore; | |
| 19 | + | use audiofiles_core::tags; | |
| 20 | + | use rayon::prelude::*; | |
| 21 | + | ||
| 22 | + | /// Corpus subdirectory holding the labelled one-shots, one folder per class. | |
| 23 | + | pub(crate) const TRAINING_SUBDIR: &str = "training"; | |
| 24 | + | ||
| 25 | + | /// Map a corpus folder name to its canonical tag. | |
| 26 | + | /// | |
| 27 | + | /// The folder names and [`DRUM_CLASSES`] are the same taxonomy, so this resolves | |
| 28 | + | /// against that table rather than carrying a second copy of it. The only mismatch | |
| 29 | + | /// is punctuation: the table labels the class `hi-hat` and the corpus folder is | |
| 30 | + | /// `hihat`, so both sides are compared with `-` and spaces removed. | |
| 31 | + | pub(crate) fn tag_for_folder(folder: &str) -> Option<&'static str> { | |
| 32 | + | fn squash(s: &str) -> String { | |
| 33 | + | s.chars() | |
| 34 | + | .filter(|c| !matches!(c, '-' | ' ' | '_')) | |
| 35 | + | .flat_map(char::to_lowercase) | |
| 36 | + | .collect() | |
| 37 | + | } | |
| 38 | + | let want = squash(folder); | |
| 39 | + | DRUM_CLASSES | |
| 40 | + | .iter() | |
| 41 | + | .find(|c| squash(c.label) == want) | |
| 42 | + | .map(|c| c.tag) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// The short class label for a canonical tag (`instrument.drum.kick` -> `kick`). | |
| 46 | + | /// | |
| 47 | + | /// Report tables are unreadable at full tag width once there is a column per | |
| 48 | + | /// class, and the confusion matrix is square in the number of classes. | |
| 49 | + | pub(crate) fn label_for_tag(tag: &str) -> &str { | |
| 50 | + | DRUM_CLASSES | |
| 51 | + | .iter() | |
| 52 | + | .find(|c| c.tag == tag) | |
| 53 | + | .map_or(tag, |c| c.label) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Collect the labelled files as (path, tag) pairs, grouped for a stable report. | |
| 57 | + | pub(crate) fn collect_labelled( | |
| 58 | + | training: &Path, | |
| 59 | + | ) -> Result<BTreeMap<&'static str, Vec<PathBuf>>, String> { | |
| 60 | + | let entries = std::fs::read_dir(training) | |
| 61 | + | .map_err(|e| format!("cannot read {}: {e}", training.display()))?; | |
| 62 | + | ||
| 63 | + | let mut by_tag: BTreeMap<&'static str, Vec<PathBuf>> = BTreeMap::new(); | |
| 64 | + | let mut unmapped = Vec::new(); | |
| 65 | + | for entry in entries.flatten() { | |
| 66 | + | if !entry.path().is_dir() { | |
| 67 | + | continue; | |
| 68 | + | } | |
| 69 | + | let folder = entry.file_name().to_string_lossy().to_string(); | |
| 70 | + | let Some(tag) = tag_for_folder(&folder) else { | |
| 71 | + | unmapped.push(folder); | |
| 72 | + | continue; | |
| 73 | + | }; | |
| 74 | + | let mut files: Vec<PathBuf> = std::fs::read_dir(entry.path()) | |
| 75 | + | .map_err(|e| format!("cannot read {}: {e}", entry.path().display()))? | |
| 76 | + | .flatten() | |
| 77 | + | .map(|f| f.path()) | |
| 78 | + | .filter(|p| p.is_file()) | |
| 79 | + | .collect(); | |
| 80 | + | // Deterministic order so two runs over the same corpus produce the same | |
| 81 | + | // layer, which is what makes the checked-in artifact reviewable. | |
| 82 | + | files.sort(); | |
| 83 | + | by_tag.entry(tag).or_default().extend(files); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | if !unmapped.is_empty() { | |
| 87 | + | // Loud rather than silent: a folder nobody mapped is a class silently | |
| 88 | + | // missing from the shipped layer, which reads downstream as the | |
| 89 | + | // classifier being bad at that class rather than never having seen it. | |
| 90 | + | unmapped.sort(); | |
| 91 | + | return Err(format!( | |
| 92 | + | "no tag mapping for corpus folder(s): {}. Add them to DRUM_CLASSES or move them out of {}", | |
| 93 | + | unmapped.join(", "), | |
| 94 | + | training.display() | |
| 95 | + | )); | |
| 96 | + | } | |
| 97 | + | if by_tag.is_empty() { | |
| 98 | + | return Err(format!( | |
| 99 | + | "no labelled class folders under {}", | |
| 100 | + | training.display() | |
| 101 | + | )); | |
| 102 | + | } | |
| 103 | + | Ok(by_tag) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// A scratch vault holding the analysed, tagged corpus. | |
| 107 | + | pub(crate) struct LabelledVault { | |
| 108 | + | pub(crate) db: Database, | |
| 109 | + | /// Files per class, as found on disk. | |
| 110 | + | pub(crate) by_tag: BTreeMap<&'static str, Vec<PathBuf>>, | |
| 111 | + | /// Files that produced a feature vector. | |
| 112 | + | pub(crate) analyzed: usize, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Import, analyse and tag every labelled file into a fresh vault at `vault`. | |
| 116 | + | /// | |
| 117 | + | /// Prints its own progress: both callers are long-running terminal modes and | |
| 118 | + | /// this is the slow part of each. | |
| 119 | + | pub(crate) fn build_vault( | |
| 120 | + | corpus: &Path, | |
| 121 | + | vault: &Path, | |
| 122 | + | config: &AnalysisConfig, | |
| 123 | + | ) -> Result<LabelledVault, String> { | |
| 124 | + | let by_tag = collect_labelled(&corpus.join(TRAINING_SUBDIR))?; | |
| 125 | + | ||
| 126 | + | let total: usize = by_tag.values().map(Vec::len).sum(); | |
| 127 | + | println!( | |
| 128 | + | " {} labelled file(s) across {} class(es):", | |
| 129 | + | total, | |
| 130 | + | by_tag.len() | |
| 131 | + | ); | |
| 132 | + | for (tag, files) in &by_tag { | |
| 133 | + | println!(" {:<28} {:>5}", tag, files.len()); | |
| 134 | + | } | |
| 135 | + | println!(); | |
| 136 | + | ||
| 137 | + | // A fresh vault every run. What is built from it is a pure function of the | |
| 138 | + | // corpus, and leftovers from a previous run would silently widen it. | |
| 139 | + | if vault.exists() { | |
| 140 | + | std::fs::remove_dir_all(vault) | |
| 141 | + | .map_err(|e| format!("could not clear scratch vault {}: {e}", vault.display()))?; | |
| 142 | + | } | |
| 143 | + | let samples_dir = vault.join("samples"); | |
| 144 | + | std::fs::create_dir_all(&samples_dir) | |
| 145 | + | .map_err(|e| format!("could not create {}: {e}", samples_dir.display()))?; | |
| 146 | + | ||
| 147 | + | let db = Database::open(vault.join("audiofiles.db")) | |
| 148 | + | .map_err(|e| format!("Database::open failed: {e}"))?; | |
| 149 | + | let store = | |
| 150 | + | SampleStore::new(&samples_dir).map_err(|e| format!("SampleStore::new failed: {e}"))?; | |
| 151 | + | ||
| 152 | + | // Import and tag. No VFS nodes: nothing here runs a UI query, and neither | |
| 153 | + | // the export nor the eval needs one; both read `sample_features` joined to | |
| 154 | + | // `tags`. | |
| 155 | + | let start = Instant::now(); | |
| 156 | + | let mut to_analyze: Vec<(String, PathBuf)> = Vec::with_capacity(total); | |
| 157 | + | let mut import_failures = 0usize; | |
| 158 | + | for (tag, files) in &by_tag { | |
| 159 | + | for path in files { | |
| 160 | + | match store.import(path, &db) { | |
| 161 | + | Ok(hash) => { | |
| 162 | + | if let Err(e) = tags::add_tag(&db, &hash, tag) { | |
| 163 | + | eprintln!(" tag {tag} on {}: {e}", path.display()); | |
| 164 | + | import_failures += 1; | |
| 165 | + | continue; | |
| 166 | + | } | |
| 167 | + | to_analyze.push((hash, path.clone())); | |
| 168 | + | } | |
| 169 | + | Err(e) => { | |
| 170 | + | eprintln!(" import {}: {e}", path.display()); | |
| 171 | + | import_failures += 1; | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | println!( | |
| 177 | + | " imported {} file(s) in {:.1}s{}", | |
| 178 | + | to_analyze.len(), | |
| 179 | + | start.elapsed().as_secs_f64(), | |
| 180 | + | if import_failures > 0 { | |
| 181 | + | format!(", {import_failures} failed") | |
| 182 | + | } else { | |
| 183 | + | String::new() | |
| 184 | + | } | |
| 185 | + | ); | |
| 186 | + | ||
| 187 | + | // Analyse everything. The ingest benchmark caps this because analysis is the | |
| 188 | + | // expensive stage and it only needs a sample; here every vector is the | |
| 189 | + | // payload, so there is no budget to apply. | |
| 190 | + | // | |
| 191 | + | // Parallel, but still deterministic: `analyze_sample` is a pure function of | |
| 192 | + | // the file, and a rayon `collect` into a Vec restores input order, so the | |
| 193 | + | // batch written to the DB is the same sequence a serial run would write. | |
| 194 | + | // That matters because the exported layer is a checked-in artifact. | |
| 195 | + | let start = Instant::now(); | |
| 196 | + | let results: Vec<_> = to_analyze | |
| 197 | + | .par_iter() | |
| 198 | + | .filter_map(|(hash, path)| analysis::analyze_sample(hash, path, config).ok()) | |
| 199 | + | .collect(); | |
| 200 | + | let analyzed = results.len(); | |
| 201 | + | analysis::save_analysis_batch(&db, &results) | |
| 202 | + | .map_err(|e| format!("save_analysis_batch failed: {e}"))?; | |
| 203 | + | println!( | |
| 204 | + | " analysed {} file(s) in {:.1}s{}", | |
| 205 | + | analyzed, | |
| 206 | + | start.elapsed().as_secs_f64(), | |
| 207 | + | if analyzed < to_analyze.len() { | |
| 208 | + | format!(", {} failed to analyse", to_analyze.len() - analyzed) | |
| 209 | + | } else { | |
| 210 | + | String::new() | |
| 211 | + | } | |
| 212 | + | ); | |
| 213 | + | ||
| 214 | + | Ok(LabelledVault { | |
| 215 | + | db, | |
| 216 | + | by_tag, | |
| 217 | + | analyzed, | |
| 218 | + | }) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[cfg(test)] | |
| 222 | + | mod tests { | |
| 223 | + | use super::*; | |
| 224 | + | ||
| 225 | + | #[test] | |
| 226 | + | fn folder_names_map_to_canonical_tags() { | |
| 227 | + | assert_eq!(tag_for_folder("kick"), Some("instrument.drum.kick")); | |
| 228 | + | assert_eq!(tag_for_folder("snare"), Some("instrument.drum.snare")); | |
| 229 | + | assert_eq!(tag_for_folder("cymbal"), Some("instrument.drum.cymbal")); | |
| 230 | + | assert_eq!(tag_for_folder("clap"), Some("instrument.drum.clap")); | |
| 231 | + | assert_eq!(tag_for_folder("tom"), Some("instrument.drum.tom")); | |
| 232 | + | assert_eq!(tag_for_folder("percussion"), Some("instrument.percussion")); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | #[test] | |
| 236 | + | fn hihat_folder_matches_the_hi_hat_label() { | |
| 237 | + | // The one place the corpus and DRUM_CLASSES disagree on spelling. | |
| 238 | + | assert_eq!(tag_for_folder("hihat"), Some("instrument.drum.hihat")); | |
| 239 | + | assert_eq!(tag_for_folder("hi-hat"), Some("instrument.drum.hihat")); | |
| 240 | + | assert_eq!(tag_for_folder("Hi Hat"), Some("instrument.drum.hihat")); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | #[test] | |
| 244 | + | fn unknown_folder_has_no_tag() { | |
| 245 | + | assert_eq!(tag_for_folder("bass"), None); | |
| 246 | + | assert_eq!(tag_for_folder(""), None); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[test] | |
| 250 | + | fn every_drum_class_is_reachable_from_some_folder_name() { | |
| 251 | + | // Guards the mapping against a DRUM_CLASSES entry whose label stops | |
| 252 | + | // resolving; without this, a renamed label silently drops a class. | |
| 253 | + | for class in DRUM_CLASSES { | |
| 254 | + | assert_eq!( | |
| 255 | + | tag_for_folder(class.label), | |
| 256 | + | Some(class.tag), | |
| 257 | + | "class {} no longer resolves from its own label", | |
| 258 | + | class.label | |
| 259 | + | ); | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[test] | |
| 264 | + | fn labels_round_trip_from_their_tag() { | |
| 265 | + | for class in DRUM_CLASSES { | |
| 266 | + | assert_eq!(label_for_tag(class.tag), class.label); | |
| 267 | + | } | |
| 268 | + | // An unknown tag prints as itself rather than vanishing. | |
| 269 | + | assert_eq!(label_for_tag("instrument.bass"), "instrument.bass"); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn collect_labelled_rejects_an_unmapped_folder() { | |
| 274 | + | let dir = tempfile::tempdir().unwrap(); | |
| 275 | + | let training = dir.path().join(TRAINING_SUBDIR); | |
| 276 | + | std::fs::create_dir_all(training.join("kick")).unwrap(); | |
| 277 | + | std::fs::create_dir_all(training.join("didgeridoo")).unwrap(); | |
| 278 | + | let err = collect_labelled(&training).unwrap_err(); | |
| 279 | + | assert!(err.contains("didgeridoo"), "{err}"); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn collect_labelled_groups_files_under_their_tag() { | |
| 284 | + | let dir = tempfile::tempdir().unwrap(); | |
| 285 | + | let training = dir.path().join(TRAINING_SUBDIR); | |
| 286 | + | std::fs::create_dir_all(training.join("kick")).unwrap(); | |
| 287 | + | std::fs::create_dir_all(training.join("snare")).unwrap(); | |
| 288 | + | std::fs::write(training.join("kick/b.wav"), b"x").unwrap(); | |
| 289 | + | std::fs::write(training.join("kick/a.wav"), b"x").unwrap(); | |
| 290 | + | std::fs::write(training.join("snare/c.wav"), b"x").unwrap(); | |
| 291 | + | ||
| 292 | + | let got = collect_labelled(&training).unwrap(); | |
| 293 | + | assert_eq!(got["instrument.drum.kick"].len(), 2); | |
| 294 | + | assert_eq!(got["instrument.drum.snare"].len(), 1); | |
| 295 | + | // Sorted, so the artifact is reproducible across runs. | |
| 296 | + | assert!(got["instrument.drum.kick"][0].ends_with("a.wav")); | |
| 297 | + | } | |
| 298 | + | } |
| @@ -1,0 +1,855 @@ | |||
| 1 | + | //! Cross-validated evaluation of the exemplar k-NN layer over the labelled corpus. | |
| 2 | + | //! | |
| 3 | + | //! This is the meter the bundled `.afcl` never had. `afcl_gen` builds a layer; | |
| 4 | + | //! nothing said whether that layer answers correctly, so "tuned enough to ship" | |
| 5 | + | //! had no number behind it and the layer sits behind an off-by-default feature. | |
| 6 | + | //! | |
| 7 | + | //! What it measures: stratified k-fold cross-validation of [`exemplar`] over the | |
| 8 | + | //! same corpus the layer is built from, at the same `k` and the same thresholds | |
| 9 | + | //! the app uses at runtime. Each fold builds a real [`ExemplarIndex`] from the | |
| 10 | + | //! other folds and scores this fold's samples against it, so no sample is ever a | |
| 11 | + | //! neighbour of itself and the standardization params are fitted on training data | |
| 12 | + | //! alone. | |
| 13 | + | //! | |
| 14 | + | //! Why it is not one accuracy figure: the retired threshold classifier scored | |
| 15 | + | //! 33.4% strict with two of its seven classes unreachable by any rule, and a | |
| 16 | + | //! single number is exactly what hid that. Everything here is per class. A class | |
| 17 | + | //! the layer never predicts shows up as a zero row in the confusion matrix and a | |
| 18 | + | //! zero recall, both of which a headline average would smooth away. | |
| 19 | + | //! | |
| 20 | + | //! The ship gate ([`GATE`]) is written down in this file rather than decided | |
| 21 | + | //! after reading a run. That ordering is the whole point: a threshold chosen once | |
| 22 | + | //! the number is on screen is a threshold the number chose. | |
| 23 | + | //! | |
| 24 | + | //! Usage: `cargo run --release -p audiofiles-bench -- layer-eval` | |
| 25 | + | //! Env: `AF_BENCH_CORPUS`, `AF_BENCH_VAULT`, `AF_BENCH_EVAL_FOLDS` (default 5), | |
| 26 | + | //! `AF_BENCH_JSON`. | |
| 27 | + | ||
| 28 | + | use std::collections::{BTreeMap, BTreeSet, HashMap}; | |
| 29 | + | use std::path::Path; | |
| 30 | + | ||
| 31 | + | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 32 | + | use audiofiles_core::analysis::exemplar::{ | |
| 33 | + | self, DEFAULT_AUTO_THRESHOLD, DEFAULT_K, DEFAULT_REVIEW_THRESHOLD, | |
| 34 | + | }; | |
| 35 | + | use audiofiles_core::analysis::features::{FEATURE_VERSION, NUM_FEATURES}; | |
| 36 | + | use audiofiles_core::db::Database; | |
| 37 | + | ||
| 38 | + | use crate::labelled::{self, label_for_tag}; | |
| 39 | + | use crate::report::Report; | |
| 40 | + | ||
| 41 | + | /// Default fold count. | |
| 42 | + | /// | |
| 43 | + | /// Five over ~1000 files leaves every training fold within a fifth of the size of | |
| 44 | + | /// the layer that actually ships, so the measured neighbourhood density is close | |
| 45 | + | /// to the real one. Fewer folds would train on visibly less data than ships and | |
| 46 | + | /// understate the layer; many more would leave the smallest class (clap, 48 | |
| 47 | + | /// files) with single-digit test sets whose per-class recall moves in 10% steps. | |
| 48 | + | const DEFAULT_FOLDS: usize = 5; | |
| 49 | + | ||
| 50 | + | /// The ship gate, decided before the first run. | |
| 51 | + | /// | |
| 52 | + | /// A bundled default that is confidently wrong is worse than no default, so the | |
| 53 | + | /// binding criterion is precision at the auto-apply threshold: that is the score | |
| 54 | + | /// at which the layer writes a tag into someone's library without being asked. | |
| 55 | + | /// Recall is graded far more loosely, because a tag the layer declines to apply | |
| 56 | + | /// costs a user nothing they did not already have. | |
| 57 | + | /// | |
| 58 | + | /// The zero-prediction check is separate from recall on purpose. A class the | |
| 59 | + | /// layer never reaches is a different failure from a class it reaches badly: it | |
| 60 | + | /// means the shipped taxonomy promises something the data cannot deliver, and it | |
| 61 | + | /// is what the retired threshold tree did to clap and tom. | |
| 62 | + | struct Gate { | |
| 63 | + | /// Per-class precision at [`DEFAULT_AUTO_THRESHOLD`], over classes the layer | |
| 64 | + | /// predicts at all. A wrong auto-applied tag is the harm this layer can do. | |
| 65 | + | auto_precision: f64, | |
| 66 | + | /// Per-class recall at [`DEFAULT_AUTO_THRESHOLD`]. Deliberately low: a | |
| 67 | + | /// default that fires on a third of a class is still a head start. | |
| 68 | + | auto_recall: f64, | |
| 69 | + | /// Macro-averaged top-1 recall. Below this the feature space is not | |
| 70 | + | /// separating these classes at all, whatever the thresholds do. | |
| 71 | + | top1_macro_recall: f64, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | const GATE: Gate = Gate { | |
| 75 | + | auto_precision: 0.80, | |
| 76 | + | auto_recall: 0.35, | |
| 77 | + | top1_macro_recall: 0.60, | |
| 78 | + | }; | |
| 79 | + | ||
| 80 | + | /// One corpus sample: its vector, its labels, and the class it is scored against. | |
| 81 | + | struct Row { | |
| 82 | + | hash: String, | |
| 83 | + | vector: Vec<f64>, | |
| 84 | + | tags: Vec<String>, | |
| 85 | + | /// The single class this row is ground truth for, or `None` when the corpus | |
| 86 | + | /// gave it more than one. Content-addressed import collapses a file that | |
| 87 | + | /// appears in two class folders into one row carrying both tags; its true | |
| 88 | + | /// class is undecidable, so it trains but is never tested. | |
| 89 | + | truth: Option<String>, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Per-class counts at one score threshold, over the whole cross-validation. | |
| 93 | + | #[derive(Default, Clone, Copy)] | |
| 94 | + | struct Counts { | |
| 95 | + | tp: usize, | |
| 96 | + | fp: usize, | |
| 97 | + | fn_: usize, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl Counts { | |
| 101 | + | fn precision(self) -> Option<f64> { | |
| 102 | + | let predicted = self.tp + self.fp; | |
| 103 | + | (predicted > 0).then(|| self.tp as f64 / predicted as f64) | |
| 104 | + | } | |
| 105 | + | fn recall(self) -> Option<f64> { | |
| 106 | + | let actual = self.tp + self.fn_; | |
| 107 | + | (actual > 0).then(|| self.tp as f64 / actual as f64) | |
| 108 | + | } | |
| 109 | + | fn f1(self) -> Option<f64> { | |
| 110 | + | let (p, r) = (self.precision()?, self.recall()?); | |
| 111 | + | (p + r > 0.0).then(|| 2.0 * p * r / (p + r)) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | fn pct(v: Option<f64>) -> String { | |
| 116 | + | v.map_or_else(|| "-".to_string(), |x| format!("{:.1}%", x * 100.0)) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Read the analysed corpus back out of the scratch vault as scoreable rows. | |
| 120 | + | fn load_rows(db: &Database) -> Result<Vec<Row>, String> { | |
| 121 | + | let conn = db.conn(); | |
| 122 | + | ||
| 123 | + | let mut tags_by_hash: HashMap<String, Vec<String>> = HashMap::new(); | |
| 124 | + | { | |
| 125 | + | let mut stmt = conn | |
| 126 | + | .prepare("SELECT sample_hash, tag FROM tags") | |
| 127 | + | .map_err(|e| format!("tags query: {e}"))?; | |
| 128 | + | let rows = stmt | |
| 129 | + | .query_map([], |row| { | |
| 130 | + | Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) | |
| 131 | + | }) | |
| 132 | + | .map_err(|e| format!("tags query: {e}"))?; | |
| 133 | + | for r in rows { | |
| 134 | + | let (hash, tag) = r.map_err(|e| format!("tags row: {e}"))?; | |
| 135 | + | tags_by_hash.entry(hash).or_default().push(tag); | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | let mut out = Vec::new(); | |
| 140 | + | let mut stmt = conn | |
| 141 | + | .prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1 ORDER BY hash") | |
| 142 | + | .map_err(|e| format!("features query: {e}"))?; | |
| 143 | + | let rows = stmt | |
| 144 | + | .query_map([FEATURE_VERSION], |row| { | |
| 145 | + | Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) | |
| 146 | + | }) | |
| 147 | + | .map_err(|e| format!("features query: {e}"))?; | |
| 148 | + | for r in rows { | |
| 149 | + | let (hash, json) = r.map_err(|e| format!("features row: {e}"))?; | |
| 150 | + | let Some(mut tags) = tags_by_hash.remove(&hash) else { | |
| 151 | + | continue; | |
| 152 | + | }; | |
| 153 | + | tags.sort(); | |
| 154 | + | tags.dedup(); | |
| 155 | + | let vector: Vec<f64> = | |
| 156 | + | serde_json::from_str(&json).map_err(|e| format!("vector for {hash}: {e}"))?; | |
| 157 | + | // The index drops these too (`is_usable_vector`), so counting them as | |
| 158 | + | // testable would score the layer on samples it never sees. | |
| 159 | + | if vector.len() != NUM_FEATURES || !vector.iter().all(|x| x.is_finite()) { | |
| 160 | + | continue; | |
| 161 | + | } | |
| 162 | + | let truth = (tags.len() == 1).then(|| tags[0].clone()); | |
| 163 | + | out.push(Row { | |
| 164 | + | hash, | |
| 165 | + | vector, | |
| 166 | + | tags, | |
| 167 | + | truth, | |
| 168 | + | }); | |
| 169 | + | } | |
| 170 | + | Ok(out) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// Assign each row a fold, stratified by class. | |
| 174 | + | /// | |
| 175 | + | /// Round-robin down each class's hash-sorted list rather than a shuffle: the | |
| 176 | + | /// content hash is already an arbitrary order with respect to the source pack a | |
| 177 | + | /// file came from, so this needs no RNG and two runs over the same corpus produce | |
| 178 | + | /// the same folds. Ambiguous rows get no fold; they train everywhere. | |
| 179 | + | fn assign_folds(rows: &[Row], folds: usize) -> Vec<Option<usize>> { | |
| 180 | + | let mut seen_per_class: HashMap<&str, usize> = HashMap::new(); | |
| 181 | + | rows.iter() | |
| 182 | + | .map(|r| { | |
| 183 | + | let truth = r.truth.as_deref()?; | |
| 184 | + | let n = seen_per_class.entry(truth).or_default(); | |
| 185 | + | let fold = *n % folds; | |
| 186 | + | *n += 1; | |
| 187 | + | Some(fold) | |
| 188 | + | }) | |
| 189 | + | .collect() | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Build an in-memory vault holding just the training rows, so the index under | |
| 193 | + | /// test is built by the same `build_index` the app calls. | |
| 194 | + | /// | |
| 195 | + | /// Every exemplar here is `local` (weight 1.0) where the shipped ones arrive from | |
| 196 | + | /// an imported layer at a lower weight. That does not change a score: the weight | |
| 197 | + | /// is a constant multiplier inside a sum that is then divided by its own total, | |
| 198 | + | /// so a uniform weight cancels. It would matter only against a mixed index of | |
| 199 | + | /// user labels plus the layer, which is a different measurement (how the layer | |
| 200 | + | /// behaves beside a user's own data) and not what a ship gate turns on. | |
| 201 | + | fn training_db(rows: &[&Row]) -> Result<Database, String> { | |
| 202 | + | let db = Database::open_in_memory().map_err(|e| format!("open_in_memory: {e}"))?; | |
| 203 | + | db.transaction(|_tx| { | |
| 204 | + | for row in rows { | |
| 205 | + | db.conn().execute( | |
| 206 | + | "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 207 | + | VALUES (?1, ?1, 'wav', 1, 0, 0)", | |
| 208 | + | [&row.hash], | |
| 209 | + | )?; | |
| 210 | + | let json = serde_json::to_string(&row.vector).unwrap_or_default(); | |
| 211 | + | db.conn().execute( | |
| 212 | + | "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)", | |
| 213 | + | rusqlite::params![&row.hash, FEATURE_VERSION, json], | |
| 214 | + | )?; | |
| 215 | + | } | |
| 216 | + | Ok(()) | |
| 217 | + | }) | |
| 218 | + | .map_err(|e| format!("seeding the fold index: {e}"))?; | |
| 219 | + | // Tags outside the transaction: `add_tag` is the public path and manages its | |
| 220 | + | // own writes, and this is an in-memory DB where the fsync cost it avoids does | |
| 221 | + | // not exist. | |
| 222 | + | for row in rows { | |
| 223 | + | for tag in &row.tags { | |
| 224 | + | audiofiles_core::tags::add_tag(&db, &row.hash, tag) | |
| 225 | + | .map_err(|e| format!("tagging the fold index: {e}"))?; | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | Ok(db) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// What one test sample produced. | |
| 232 | + | struct Prediction { | |
| 233 | + | truth: String, | |
| 234 | + | /// Highest-scoring tag, or `None` when the index returned nothing. | |
| 235 | + | top1: Option<String>, | |
| 236 | + | /// Score per class, for threshold sweeps. | |
| 237 | + | scores: BTreeMap<String, f64>, | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | pub(crate) fn run(corpus: &Path, vault: &Path, config: &AnalysisConfig, folds: usize) { | |
| 241 | + | println!("━━━ CLASSIFIER LAYER EVALUATION ━━━"); | |
| 242 | + | println!(); | |
| 243 | + | println!(" corpus {}", corpus.display()); | |
| 244 | + | println!(" scratch {}", vault.display()); | |
| 245 | + | println!(" features v{FEATURE_VERSION}"); | |
| 246 | + | println!(" k {DEFAULT_K} (runtime default)"); | |
| 247 | + | println!(" folds {folds}, stratified by class"); | |
| 248 | + | println!(); | |
| 249 | + | println!(" Gate, fixed before this run:"); | |
| 250 | + | println!( | |
| 251 | + | " per-class precision at the auto threshold ({DEFAULT_AUTO_THRESHOLD:.2}) >= {:.0}%", | |
| 252 | + | GATE.auto_precision * 100.0 | |
| 253 | + | ); | |
| 254 | + | println!( | |
| 255 | + | " per-class recall at the auto threshold >= {:.0}%", | |
| 256 | + | GATE.auto_recall * 100.0 | |
| 257 | + | ); | |
| 258 | + | println!( | |
| 259 | + | " macro-averaged top-1 recall >= {:.0}%", | |
| 260 | + | GATE.top1_macro_recall * 100.0 | |
| 261 | + | ); | |
| 262 | + | println!(" no class the layer never predicts"); | |
| 263 | + | println!(); | |
| 264 | + | ||
| 265 | + | let built = match labelled::build_vault(corpus, vault, config) { | |
| 266 | + | Ok(v) => v, | |
| 267 | + | Err(e) => { | |
| 268 | + | eprintln!("corpus: {e}"); | |
| 269 | + | std::process::exit(1); | |
| 270 | + | } | |
| 271 | + | }; | |
| 272 | + | ||
| 273 | + | let rows = match load_rows(&built.db) { | |
| 274 | + | Ok(r) => r, | |
| 275 | + | Err(e) => { | |
| 276 | + | eprintln!("reading the vault back: {e}"); | |
| 277 | + | std::process::exit(1); | |
| 278 | + | } | |
| 279 | + | }; | |
| 280 | + | let ambiguous = rows.iter().filter(|r| r.truth.is_none()).count(); | |
| 281 | + | let testable = rows.len() - ambiguous; | |
| 282 | + | if testable == 0 { | |
| 283 | + | eprintln!("no single-labelled samples to test"); | |
| 284 | + | std::process::exit(1); | |
| 285 | + | } | |
| 286 | + | println!(); | |
| 287 | + | println!(" {} scoreable row(s) in the vault", rows.len()); | |
| 288 | + | if ambiguous > 0 { | |
| 289 | + | // Not a silent drop: the same file under two class folders is a corpus | |
| 290 | + | // problem, and the count is how anyone notices it grew. | |
| 291 | + | println!( | |
| 292 | + | " {ambiguous} carry more than one class tag (duplicate audio across folders);\n \ | |
| 293 | + | they train in every fold and are never tested" | |
| 294 | + | ); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | let classes: BTreeSet<String> = rows.iter().filter_map(|r| r.truth.clone()).collect(); | |
| 298 | + | let classes: Vec<String> = classes.into_iter().collect(); | |
| 299 | + | let fold_of = assign_folds(&rows, folds); | |
| 300 | + | ||
| 301 | + | // Cross-validation | |
| 302 | + | let mut predictions: Vec<Prediction> = Vec::with_capacity(testable); | |
| 303 | + | for fold in 0..folds { | |
| 304 | + | let train: Vec<&Row> = rows | |
| 305 | + | .iter() | |
| 306 | + | .zip(&fold_of) | |
| 307 | + | .filter(|(_, f)| **f != Some(fold)) | |
| 308 | + | .map(|(r, _)| r) | |
| 309 | + | .collect(); | |
| 310 | + | let test: Vec<&Row> = rows | |
| 311 | + | .iter() | |
| 312 | + | .zip(&fold_of) | |
| 313 | + | .filter(|(_, f)| **f == Some(fold)) | |
| 314 | + | .map(|(r, _)| r) | |
| 315 | + | .collect(); | |
| 316 | + | ||
| 317 | + | let db = match training_db(&train) { | |
| 318 | + | Ok(db) => db, | |
| 319 | + | Err(e) => { | |
| 320 | + | eprintln!("fold {fold}: {e}"); | |
| 321 | + | std::process::exit(1); | |
| 322 | + | } | |
| 323 | + | }; | |
| 324 | + | let index = match exemplar::build_index(&db) { | |
| 325 | + | Ok(i) => i, | |
| 326 | + | Err(e) => { | |
| 327 | + | eprintln!("fold {fold}: build_index: {e}"); | |
| 328 | + | std::process::exit(1); | |
| 329 | + | } | |
| 330 | + | }; | |
| 331 | + | println!( | |
| 332 | + | " fold {fold}: {} exemplars, {} held out", | |
| 333 | + | index.len(), | |
| 334 | + | test.len() | |
| 335 | + | ); | |
| 336 | + | ||
| 337 | + | for row in test { | |
| 338 | + | // No `exclude_hash`: the row is not in this index at all, which is | |
| 339 | + | // the property the fold split exists to give. | |
| 340 | + | let scored = index.score(&row.vector, DEFAULT_K, None); | |
| 341 | + | let top1 = scored.first().map(|s| s.tag.clone()); | |
| 342 | + | let scores = scored.into_iter().map(|s| (s.tag, s.score)).collect(); | |
| 343 | + | predictions.push(Prediction { | |
| 344 | + | truth: row.truth.clone().unwrap_or_default(), | |
| 345 | + | top1, | |
| 346 | + | scores, | |
| 347 | + | }); | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | println!(); | |
| 351 | + | ||
| 352 | + | let mut report = Report::new("layer-eval"); | |
| 353 | + | report.set("folds", folds); | |
| 354 | + | report.set("k", DEFAULT_K); | |
| 355 | + | report.set("feat_version", FEATURE_VERSION); | |
| 356 | + | report.set("exemplars_total", rows.len()); | |
| 357 | + | report.set("tested", predictions.len()); | |
| 358 | + | report.set("ambiguous_excluded", ambiguous); | |
| 359 | + | ||
| 360 | + | let top1 = print_confusion(&predictions, &classes, &mut report); | |
| 361 | + | let auto = print_threshold_table( | |
| 362 | + | &predictions, | |
| 363 | + | &classes, | |
| 364 | + | DEFAULT_AUTO_THRESHOLD, | |
| 365 | + | "auto-apply", | |
| 366 | + | "auto", | |
| 367 | + | &mut report, | |
| 368 | + | ); | |
| 369 | + | let _review = print_threshold_table( | |
| 370 | + | &predictions, | |
| 371 | + | &classes, | |
| 372 | + | DEFAULT_REVIEW_THRESHOLD, | |
| 373 | + | "review", | |
| 374 | + | "review", | |
| 375 | + | &mut report, | |
| 376 | + | ); | |
| 377 | + | ||
| 378 | + | print_verdict(&classes, &top1, &auto, &mut report); | |
| 379 | + | report.write(); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | /// Top-1 confusion matrix, and per-class top-1 recall. Returns the per-class counts. | |
| 383 | + | fn print_confusion( | |
| 384 | + | predictions: &[Prediction], | |
| 385 | + | classes: &[String], | |
| 386 | + | report: &mut Report, | |
| 387 | + | ) -> BTreeMap<String, Counts> { | |
| 388 | + | println!("━━━ TOP-1 CONFUSION ━━━"); | |
| 389 | + | println!(); | |
| 390 | + | println!(" Rows are the corpus label, columns the highest-scoring tag."); | |
| 391 | + | println!(" `(none)` is a sample the index scored nothing for at all."); | |
| 392 | + | println!(); | |
| 393 | + | ||
| 394 | + | let width = classes | |
| 395 | + | .iter() | |
| 396 | + | .map(|c| label_for_tag(c).len().max(5)) | |
| 397 | + | .collect::<Vec<_>>(); | |
| 398 | + | ||
| 399 | + | print!(" {:<12}", "true \\ pred"); | |
| 400 | + | for (c, w) in classes.iter().zip(&width) { | |
| 401 | + | print!(" {:>w$}", label_for_tag(c), w = w); | |
| 402 | + | } | |
| 403 | + | println!(" {:>6} {:>8}", "(none)", "recall"); | |
| 404 | + | println!( | |
| 405 | + | " {}", | |
| 406 | + | "─".repeat(12 + width.iter().map(|w| w + 1).sum::<usize>() + 16) | |
| 407 | + | ); | |
| 408 | + | ||
| 409 | + | let mut counts: BTreeMap<String, Counts> = BTreeMap::new(); | |
| 410 | + | let mut never_predicted: Vec<&str> = Vec::new(); | |
| 411 | + | ||
| 412 | + | for truth in classes { | |
| 413 | + | let mine: Vec<&Prediction> = predictions.iter().filter(|p| &p.truth == truth).collect(); | |
| 414 | + | print!(" {:<12}", label_for_tag(truth)); | |
| 415 | + | let mut correct = 0usize; | |
| 416 | + | for (pred, w) in classes.iter().zip(&width) { | |
| 417 | + | let n = mine | |
| 418 | + | .iter() | |
| 419 | + | .filter(|p| p.top1.as_deref() == Some(pred.as_str())) | |
| 420 | + | .count(); | |
| 421 | + | if pred == truth { | |
| 422 | + | correct = n; | |
| 423 | + | } | |
| 424 | + | print!(" {n:>w$}"); | |
| 425 | + | } | |
| 426 | + | let none = mine.iter().filter(|p| p.top1.is_none()).count(); | |
| 427 | + | let recall = if mine.is_empty() { | |
| 428 | + | None | |
| 429 | + | } else { | |
| 430 | + | Some(correct as f64 / mine.len() as f64) | |
| 431 | + | }; | |
| 432 | + | println!(" {:>6} {:>8}", none, pct(recall)); | |
| 433 | + | ||
| 434 | + | // Precision needs the whole column, so it is counted here rather than | |
| 435 | + | // inside the row loop. | |
| 436 | + | let predicted_as = predictions | |
| 437 | + | .iter() | |
| 438 | + | .filter(|p| p.top1.as_deref() == Some(truth.as_str())) | |
| 439 | + | .count(); | |
| 440 | + | if predicted_as == 0 { | |
| 441 | + | never_predicted.push(label_for_tag(truth)); | |
| 442 | + | } | |
| 443 | + | counts.insert( | |
| 444 | + | truth.clone(), | |
| 445 | + | Counts { | |
| 446 | + | tp: correct, | |
| 447 | + | fp: predicted_as - correct, | |
| 448 | + | fn_: mine.len() - correct, | |
| 449 | + | }, | |
| 450 | + | ); | |
| 451 | + | } | |
| 452 | + | println!(); | |
| 453 | + | ||
| 454 | + | let macro_recall = macro_average(classes, &counts, Counts::recall); | |
| 455 | + | let micro = counts.values().map(|c| c.tp).sum::<usize>() as f64 / predictions.len() as f64; | |
| 456 | + | println!(" macro-averaged recall {}", pct(macro_recall)); | |
| 457 | + | println!(" overall top-1 accuracy {}", pct(Some(micro))); | |
| 458 | + | println!(); | |
| 459 | + | println!(" The macro figure is the one that matters: it weights the 48-file"); | |
| 460 | + | println!(" clap class the same as the 246-file tom class, so a layer that"); | |
| 461 | + | println!(" gets the big classes right and ignores a small one cannot hide."); | |
| 462 | + | if never_predicted.is_empty() { | |
| 463 | + | println!(" Every class is predicted at least once."); | |
| 464 | + | } else { | |
| 465 | + | println!(); | |
| 466 | + | println!( | |
| 467 | + | " NEVER PREDICTED: {}. This class is unreachable, not merely weak.", | |
| 468 | + | never_predicted.join(", ") | |
| 469 | + | ); | |
| 470 | + | } | |
| 471 | + | println!(); | |
| 472 | + | ||
| 473 | + | if let Some(m) = macro_recall { | |
| 474 | + | report.set("top1_macro_recall", (m * 10000.0).round() / 10000.0); | |
| 475 | + | } | |
| 476 | + | report.set("top1_accuracy", (micro * 10000.0).round() / 10000.0); | |
| 477 | + | report.set("never_predicted", never_predicted.len()); | |
| 478 | + | for (tag, c) in &counts { | |
| 479 | + | let label = label_for_tag(tag); | |
| 480 | + | if let Some(r) = c.recall() { | |
| 481 | + | report.set( | |
| 482 | + | &format!("top1_{label}_recall"), | |
| 483 | + | (r * 10000.0).round() / 10000.0, | |
| 484 | + | ); | |
| 485 | + | } | |
| 486 | + | if let Some(p) = c.precision() { | |
| 487 | + | report.set( | |
| 488 | + | &format!("top1_{label}_precision"), | |
| 489 | + | (p * 10000.0).round() / 10000.0, | |
| 490 | + | ); | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | counts | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | /// Per-class precision/recall/F1 at one score threshold, scored multi-label: | |
| 497 | + | /// every tag at or above the threshold is a prediction, so a sample can be right | |
| 498 | + | /// about one class and wrong about another in the same breath. That is how the | |
| 499 | + | /// layer behaves at runtime, and a top-1 view would not show a layer that applies | |
| 500 | + | /// three tags to everything. |
Lines truncated