Skip to main content

max / audiofiles

Strip historical narrative from documentation Remove what a doc used to say, when it changed, the incidents that justified a rule, finished migration narration, and counts and versions that rot. State the rules in the present tense instead. Keep every instruction, prohibition and threshold, and keep the measurements that make a rule actionable. Public-facing docs keep their explanatory voice.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-08-31 01:51 UTC
Signed with PGP, not checked
Commit: df77aee9cb9579db1b21e8165aedbd86d6a19b70
Parent: cad308c
58 files changed, +395 insertions, -713 deletions
M CONTRIBUTING.md +1 -1
@@ -199,7 +199,7 @@
199 199
200 200 Every synced table has triggers that insert into `sync_changelog` on INSERT/UPDATE/DELETE. A `sync_state` row (`applying_remote = '1'`) suppresses triggers during pull operations to prevent recursion.
201 201
202 - Per migration M018 (2026-06-02), `sync_changelog.row_id` is hashed via `hash_row_id(row_id_salt, canonical_key)` for sensitive tables (samples, audio_analysis, tags, collection_members) so the server never sees raw sample hashes or tag strings. The salt is generated per device, stored in `sync_state`, never synced. DELETE triggers also emit the canonical PK in the encrypted `data` field, which `resolve::apply_delete` reads to reconstruct WHERE clauses without parsing the (now-opaque) row_id. When adding a new synced table, follow the same pattern: wrap row_id in `hash_row_id(...)` if it carries user content, and emit the canonical PK into `data` for DELETE.
202 + Per migration M018, `sync_changelog.row_id` is hashed via `hash_row_id(row_id_salt, canonical_key)` for sensitive tables (samples, audio_analysis, tags, collection_members) so the server never sees raw sample hashes or tag strings. The salt is generated per device, stored in `sync_state`, never synced. DELETE triggers also emit the canonical PK in the encrypted `data` field, which `resolve::apply_delete` reads to reconstruct WHERE clauses without parsing the (now-opaque) row_id. When adding a new synced table, follow the same pattern: wrap row_id in `hash_row_id(...)` if it carries user content, and emit the canonical PK into `data` for DELETE.
203 203
204 204 ### rusqlite + async
205 205
@@ -43,21 +43,13 @@
43 43
44 44 Only against another run on the same machine, the same drive, and the **same
45 45 corpus contents**. All three are part of the measurement, and only the first two
46 - are recorded in the file.
46 + are recorded in the file. Adding files to the corpus changes the weighting, so a
47 + throughput delta across two different corpora is not a code result.
47 48
48 - The corpus one has already bitten. The 2026-07-29 baselines here read 2,241 files
49 - where the earlier figure in wiki `af-benchmarks` read 1,761, because the four
50 - 120-file format arms added for the per-format decode section landed inside
51 - `samples/` and the ingest walk picks them up. Aggregate throughput came out 168
52 - files/s against the older 204, which looks like a 17% regression and is not one:
53 - it is a different set of files, weighted differently. An A/B of the same corpus
54 - across the two commits showed the change cost nothing (see below).
49 + Rules:
55 50
56 - Rules that follow from that:
57 -
58 - - **Take a baseline with the machine and drive idle.** Import is I/O bound. A run
59 - taken while a dataset was downloading to the same drive read 40.7 files/s
60 - against 204 for the same corpus, with no code change.
51 + - **Take a baseline with the machine and drive idle.** Import is I/O bound, and
52 + competing I/O on the same drive can cost most of the throughput on its own.
61 53 - **Use a fresh vault path.** Pointing at an existing scratch vault measures
62 54 dedup, not import, because the store finds the blobs already there.
63 55 - **Do not compare across corpora.** If the corpus changed, re-measure both arms
@@ -70,23 +62,13 @@
70 62
71 63 | file | what |
72 64 |---|---|
73 - | `ingest-2026-07-29-785fbeb.json` | Pre-fanout control: flat blob store root. |
74 - | `ingest-2026-07-29-1258f9e.json` | Was the baseline. Now a pre-`1a0edd0` control. |
65 + | `ingest-2026-07-29-785fbeb.json` | Control: flat blob store root, no shard fanout. |
66 + | `ingest-2026-07-29-1258f9e.json` | Control: sharded store, pre-`1a0edd0` write path. |
75 67
76 - Taken back to back, machine and drive idle, identical corpus and batch size, a
77 - fresh vault each. They come out 168.3 against 168.4 files/s and 106.5 MB/s each,
78 - so sharding the blob directory costs nothing measurable at this size. That is the
79 - expected result rather than a disappointing one: at 2,241 blobs a flat directory
80 - was never the problem. The layout change is for the 289k-file case, where the flat
81 - root lost about 90% of its throughput.
82 -
83 - **Neither file is a comparison target any more, and there is no ingest baseline
84 - until someone takes one.** `1a0edd0` changed the import write path underneath
85 - them: the per-file directory fsync became one per shard directory at the end of
86 - the run, and the streaming hash/copy buffers went from 8 KiB to 256 KiB. Both
87 - make import faster, so a run that beats 168.4 files/s says nothing about the code
88 - it is testing until it has a post-`1a0edd0` baseline to sit against. Same trap as
89 - the corpus one above, from the other direction: there, the file set moved under a
90 - fixed measurement; here, the measured code moved under a fixed file set. Take the
91 - new baseline the way the rules above say, machine and drive idle, and this section
92 - can be rewritten around it.
68 + **Neither file is a comparison target, and there is no ingest baseline until
69 + someone takes one.** `1a0edd0` changed the import write path underneath them: the
70 + per-file directory fsync became one per shard directory at the end of the run, and
71 + the streaming hash/copy buffers went from 8 KiB to 256 KiB. Both make import
72 + faster, so a run that beats these numbers says nothing about the code it is
73 + testing until there is a post-`1a0edd0` baseline to sit against. Take the new
74 + baseline the way the rules above say, machine and drive idle.
@@ -51,7 +51,7 @@
51 51
52 52 ## Database Schema
53 53
54 - SQLite with 19 versioned migrations:
54 + SQLite, with versioned inline migrations:
55 55
56 56 | Table | Purpose |
57 57 |-------|---------|
@@ -81,7 +81,7 @@
81 81 - **BPM**: Onset-based tempo estimation using spectral flux and autocorrelation.
82 82 - **Key**: Musical key detection from chroma features.
83 83 - **MFCC**: Mel-Frequency Cepstral Coefficients computed from existing STFT magnitudes (26-band mel filterbank, log energy, DCT-II, 13 coefficients). Aggregated as mean + variance across frames (26 features total).
84 - - **Classification**: Deterministic DSP only, and multi-label. `analysis/features.rs` assembles the 35-feature vector (9 spectral/waveform + 26 MFCC), persists it to `sample_features`, and the layered tag pipeline reads it (rules, exemplar k-NN, optional trained head, `.afcl` layers). The single-label `SampleClass` and the threshold tree behind it were removed: 33.4% strict accuracy with two classes unreachable, and the features carry family structure rather than instrument identity. Nothing model-derived ships in the binary today; the accepted plan is to bundle an official `.afcl` layer built from a CC-BY 4.0 corpus, with attribution carried in the manifest. See `ml_classifier.md`.
84 + - **Classification**: Deterministic DSP only, and multi-label. `analysis/features.rs` assembles the 35-feature vector (9 spectral/waveform + 26 MFCC), persists it to `sample_features`, and the layered tag pipeline reads it (rules, exemplar k-NN, optional trained head, `.afcl` layers). There is no single-label class. Nothing model-derived ships in the binary. See `ml_classifier.md`.
85 85 - **Loop detection**: Identifies whether a sample is a seamless loop.
86 86 - **Fingerprinting**: Computes an amplitude envelope fingerprint for near-duplicate detection across the library.
87 87 - **Starter rules**: `core/src/starter_rules.rs` seeds Layer A with filename keyword rules (disabled until enabled), including the guard rules that suppress an ambiguous layered-hit name. See `ml_classifier.md`.
@@ -1,6 +1,6 @@
1 1 # audiofiles Database Schema
2 2
3 - SQLite schema reference. 23 inline migrations. Migrations are embedded as Rust string constants in `crates/audiofiles-core/src/db.rs` and applied via `PRAGMA user_version` tracking, not separate SQL files.
3 + SQLite schema reference. Migrations are embedded as Rust string constants in `crates/audiofiles-core/src/db.rs` and applied via `PRAGMA user_version` tracking, not separate SQL files.
4 4
5 5 ## Domain Map
6 6
@@ -68,10 +68,7 @@
68 68 Indexes: `bpm`, `musical_key`, `duration`, `spectral_centroid`, `spectral_flatness`,
69 69 `attack_time`. The last three back the measured browse axes (M036).
70 70
71 - `classification` (M003) and `classification_confidence` (M011) held the single
72 - sample-class label. Both are gone, along with the classifier that produced them; see
73 - `ml_classifier.md`. The columns were removed from the migration bodies that added them
74 - rather than dropped in a later migration, so no database creates them.
71 + There is no per-sample class column; see `ml_classifier.md`.
75 72
76 73 ### sample_features
77 74 The 35-element feature vector (9 scalar + 26 MFCC) per sample. Migration 020. Foundation
@@ -143,7 +140,7 @@
143 140 ## Organization
144 141
145 142 ### tags
146 - Flat dot-namespaced tags on samples. Migration 002 replaced the original key-value `tags` table with this simpler model.
143 + Flat dot-namespaced tags on samples. Migration 002.
147 144
148 145 | Column | Type | Notes |
149 146 |--------|------|-------|
@@ -213,7 +210,7 @@
213 210
214 211 PK: `(collection_id, sample_hash)`.
215 212
216 - `collections.filter_json` (added in M015): when non-NULL, the collection is a dynamic / saved search; when NULL, it's a manual collection populated via `collection_members`. The standalone `smart_folders` table from M001 was dropped in M015 and migrated into this column.
213 + `collections.filter_json` (M015): when non-NULL, the collection is a dynamic / saved search; when NULL, it's a manual collection populated via `collection_members`.
217 214
218 215 ---
219 216
@@ -282,7 +279,7 @@
282 279 - **Inline migrations:** All schema DDL is embedded as Rust `const` strings in `db.rs`, applied transactionally via `PRAGMA user_version`, no external SQL files
283 280 - **VFS abstraction:** Virtual file systems decouple organization from disk layout; one sample can appear in multiple VFS trees via `vfs_nodes.sample_hash`
284 281 - **Self-referential tree:** `vfs_nodes.parent_id` references `vfs_nodes.id` for arbitrary directory nesting
285 - - **Dot-namespaced tags:** Flat `tag` strings with dot convention (e.g., `genre.techno`) replace the original key-value tag model (migration 002)
282 + - **Dot-namespaced tags:** Flat `tag` strings with a dot convention (e.g., `genre.techno`), one row per applied tag
286 283 - **Sync guard triggers:** All sync triggers check `applying_remote != '1'` to prevent echo loops
287 284 - **Sync-excluded keys:** `user_config` sync triggers skip keys matching `sync_%` to avoid syncing sync-internal state
288 285 - **Cloud-only samples:** `samples.cloud_only` flag allows local blob eviction while keeping metadata and cloud copy
@@ -73,7 +73,7 @@
73 73 - Add/remove samples from any VFS
74 74
75 75 ### Dynamic Collections
76 - - A collection with a non-NULL `filter_json` is a saved search (the old "smart folder" feature, merged into the collections table in migration M015)
76 + - A collection with a non-NULL `filter_json` is a saved search
77 77 - Sidebar section with collapsible list
78 78 - Click to apply saved filter instantly
79 79
@@ -1,23 +1,16 @@
1 - # Sample deletion: tombstone design (proposal)
1 + # Sample deletion: tombstone design
2 2
3 - **Status:** Phases 1-4 implemented (2026-06-19). Schema + read-path filter (Phase 1), tombstone/undelete/purge operations (Phase 2), the Trash section in Settings (Phase 3), and the startup sweep (Phase 4) are live. Phase 5 (pull-side "deleted on another device" notification) remains.
3 + **Status:** Phases 1-4 are live. Phase 5 (pull-side "deleted on another device" notification) remains.
4 4
5 - **Author:** Max / Claude (audit session)
6 -
7 - **Scope:** the multi-device sync semantics of deleting a sample. Single-device delete is already correct (placement-only via `vfs_nodes` delete; sample row untouched). This doc covers the open question: what happens when a `samples` row deletion needs to propagate.
5 + **Scope:** the multi-device sync semantics of deleting a sample. Single-device delete is placement-only (a `vfs_nodes` delete; the sample row is untouched). This doc covers what happens when a `samples` row deletion needs to propagate.
8 6
9 7 ---
10 8
11 - ## Problem
9 + ## Why soft delete
12 10
13 - Today, `apply_remote_changes` in `crates/audiofiles-sync/src/service/resolve.rs` applies a remote `DELETE samples WHERE hash=X` directly. SQLite enforces `ON DELETE CASCADE` on `vfs_nodes.sample_hash`, `tags.sample_hash`, and `collection_members.sample_hash` at the engine level (not via triggers), so `applying_remote='1'` does not suppress it. The receiving device loses every placement, every tag, and every collection membership of that sample, silently, without confirmation.
11 + SQLite enforces `ON DELETE CASCADE` on `vfs_nodes.sample_hash`, `tags.sample_hash`, and `collection_members.sample_hash` at the engine level rather than via triggers, so `applying_remote='1'` does not suppress it. A hard `DELETE samples WHERE hash=X` applied from a remote change therefore takes every placement, tag, and collection membership of that sample on the receiving device, silently.
14 12
15 - The user's mental model for an audiofiles library is "my library, accessible from any of my devices, hand-picked launch cohort" (launch plan, 2026-06-01). The current behavior matches the implementation of "global library, but delete on one device wipes everything on every device with no record." Two specific surprises:
16 -
17 - - **Device A deletes a sample they no longer want** → Device B (which had organized that sample into 3 collections and tagged it across 12 placements) loses all that work without warning.
18 - - **Device A runs an automated cleanup** (today: VFS-delete sweep; tomorrow: the new "Cleanup orphans" menu without the `applying_remote` guard we just added) → all of A's placements gone → push → B's placements gone too.
19 -
20 - The path forward is **soft-delete with a tombstone column**, replicating across devices, with a recovery window before hard delete. This document is the design contract before implementation.
13 + The model is **soft delete with a tombstone column**, replicating across devices, with a recovery window before hard delete. A user expects "I deleted this" to mean gone from their library on every device, so a delete has to propagate; the tombstone is what makes the propagation recoverable.
21 14
22 15 ---
23 16
@@ -25,7 +18,6 @@
25 18
26 19 - **Cross-device "is this sample still in use anywhere?" awareness.** That requires a server-side reference count, which the current sync model (encrypted blob storage + opaque changelog) explicitly does not have. Each device sees only its own references plus the tombstone state.
27 20 - **Per-VFS or per-collection delete isolation.** Removing a sample from one collection is already handled by `collection_members` delete (no sample deletion involved). Same for VFS placements.
28 - - **Reversing already-shipped purges.** Any `samples` row already deleted before this design lands is gone for good: the sync layer has no way to ask other devices "did you keep a copy."
29 21 - **Server-side garbage collection of orphaned blobs.** Blob lifecycle on the cloud is a separate concern; this design only changes the DB row lifecycle.
30 22
31 23 ---
@@ -79,7 +71,7 @@
79 71 2. Surfaces a "Sample deleted on another device: NAME (12 placements affected)" notification.
80 72 3. Optionally provides an "Undelete on this device" button that re-sets `deleted_at` to NULL locally (creating a temporary divergence until reconciled).
81 73
82 - For the local-only case (the "Cleanup orphans" menu we just shipped): same UPDATE, but trigger-suppressed via `applying_remote='1'`. Other devices never learn about it.
74 + For the local-only case (the "Cleanup orphans" menu): same UPDATE, but trigger-suppressed via `applying_remote='1'`. Other devices never learn about it.
83 75
84 76 ### Sweep
85 77
@@ -114,7 +106,7 @@
114 106
115 107 ### `cloud_only` interaction
116 108
117 - Today, `cloud_only` marks samples whose local blob has been evicted but exist in cloud storage. With tombstones:
109 + `cloud_only` marks samples whose local blob has been evicted but which exist in cloud storage. With tombstones:
118 110
119 111 - Tombstoned samples are NOT automatically marked `cloud_only`. The blob stays on disk during the retention window so undelete is instant.
120 112 - After the sweep hard-deletes the `samples` row, the blob is removed from disk too (existing `SampleStore::remove` path).
@@ -151,19 +143,17 @@
151 143
152 144 ---
153 145
154 - ## Rollout phasing
146 + ## Implementation state
155 147
156 - **Phase 1: M019 + read-path filter** (DONE). Schema landed, `WHERE deleted_at IS NULL` filter across all read sites. Tombstones don't surface in UI yet; behavior change is invisible to users (everything is still NULL).
148 + **M019 + read-path filter** (live). `WHERE deleted_at IS NULL` across all read sites.
157 149
158 - **Phase 2: delete & undelete operations** (DONE, 2026-06-19). `tombstone_sample`, `undelete_sample`, and the unfiltered-extension fix to `SampleStore::remove` (so hard-delete/purge works on tombstoned rows) all live in `core::store`. Note: the cleanup-orphans menu still hard-deletes immediately (orphan = no recovery value), as designed, it does not route through the tombstone path.
150 + **Delete and undelete operations** (live). `tombstone_sample`, `undelete_sample`, and an unfiltered-extension lookup in `SampleStore::remove` (so hard-delete/purge works on tombstoned rows) live in `core::store`. The cleanup-orphans menu hard-deletes immediately by design (an orphan has no recovery value) and does not route through the tombstone path.
159 151
160 - **Phase 3: Trash UI** (DONE, 2026-06-19). Trash section in Settings (`ui/settings_panel.rs::draw_trash_section`) lists tombstoned samples with Restore + Delete-permanently (behind a per-row confirm) affordances. Backed by `Backend::{list_tombstoned, undelete_sample, purge_sample}`.
152 + **Trash UI** (live). Trash section in Settings (`ui/settings_panel.rs::draw_trash_section`) lists tombstoned samples with Restore and Delete-permanently (behind a per-row confirm) affordances. Backed by `Backend::{list_tombstoned, undelete_sample, purge_sample}`.
161 153
162 - **Phase 4: sweep** (DONE, 2026-06-19). `SampleStore::sweep_expired_tombstones` runs once in `DirectBackend::new` at startup, hard-deleting past-retention tombstones. The `samples` DELETE is not sync-suppressed, so it propagates; receiving devices CASCADE their own data (which, by symmetry, has also been tombstoned for >= retention).
154 + **Sweep** (live). `SampleStore::sweep_expired_tombstones` runs once in `DirectBackend::new` at startup, hard-deleting past-retention tombstones. The `samples` DELETE is not sync-suppressed, so it propagates; receiving devices CASCADE their own data, which by symmetry has also been tombstoned for at least the retention window.
163 155
164 - **Phase 5: sync notification** (TODO). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device, see Trash to recover."
165 -
166 - Phases 1-2 are required for correctness. Phases 3-5 are UX polish that can land in any order after Phase 2.
156 + **Sync notification** (TODO). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device, see Trash to recover."
167 157
168 158 ---
169 159
@@ -177,16 +167,6 @@
177 167
178 168 ---
179 169
180 - ## Why not E (per-device orphan management)
181 -
182 - The "each device manages its own purges; samples DELETE never propagates as global destroy" model is lighter (no schema change), but creates an awkward asymmetry: "delete on Device A doesn't propagate to Device B." This breaks the stated product model ("my library, accessible from any of my devices"): users expect "I deleted this" to mean it's gone from their library, not just from this device. E only works for a federation model (collaborator A and B each curate their own subset of a shared blob pool) which isn't what audiofiles is.
183 -
184 - ## Why not G (confirmation-only with current CASCADE)
185 -
186 - G doesn't fix the sync-pull surprise. A confirm dialog on local delete is helpful but doesn't help Device B when Device A purges. The sync-pull cascade still wipes B's placements without B ever seeing a dialog. G is at best an additional safety net under either E or F, not a substitute.
187 -
188 - ---
189 -
190 170 ## Acceptance criteria
191 171
192 172 This design is implemented when:
@@ -195,5 +175,5 @@
195 175 - Sync push of the tombstone replicates the `deleted_at` field; receiving device marks the same sample as tombstoned without losing placements.
196 176 - A user can browse the Trash view, see their tombstoned samples, and either undelete or delete permanently.
197 177 - A 30-day-old tombstone is automatically hard-deleted on next app startup.
198 - - The "Cleanup orphans" menu (already shipped 2026-06-02) is local-only and continues to work; its eventual replacement uses the tombstone path with immediate hard-delete (no retention for things you never placed).
178 + - The "Cleanup orphans" menu is local-only; its eventual replacement uses the tombstone path with immediate hard-delete (no retention for things you never placed).
199 179 - No existing tests fail; the new behavior is covered by tests per the Test plan section.
@@ -1,6 +1,6 @@
1 1 # audiofiles Design System
2 2
3 - **Status:** Charter (phase-0 deliverable, 2026-05-19). Defines the canonical primitive set for the egui UI. Implementations follow during consolidation against the internal UX audit's divergence map.
3 + **Status:** Charter. Defines the canonical primitive set for the egui UI.
4 4
5 5 **Scope:** `crates/audiofiles-browser/src/ui/`. Native egui dispatch only. This app has no web layer.
6 6
@@ -103,7 +103,7 @@
103 103
104 104 The values come from the [`makeover-geometry`](https://makenot.work/git/max/makeover-geometry) crate, which is where Balanced Breakfast and GoingsOn get the same vocabulary. Each is a ratio of a base unit rather than a pixel count, resolved against a surface whose quantum is one physical pixel, so the layout is honest at any display density.
105 105
106 - **Spacing is not themeable, and this is the one hard rule.** A theme overrides colour; geometry is invariant, which is exactly why it belongs to a shared crate rather than to this app. The old `[spacing]` TOML section is gone, along with the `section_spacing`, `grid_row_spacing`, `item_spacing_x/y` and `button_padding_x/y` keys. Corner radius survives under a `[geometry]` section because the crate carries spacing only.
106 + **Spacing is not themeable, and this is the one hard rule.** A theme overrides colour; geometry is invariant, which is why it belongs to a shared crate rather than to this app. There is no `[spacing]` TOML section. Corner radius is themeable under `[geometry]`, because the crate carries spacing only.
107 107
108 108 ### Typography
109 109
@@ -116,9 +116,9 @@
116 116 .with_face(FontFace::new("RecursiveMono", ["RecursiveMonoLnrSt-Bold.ttf"]).weight("700")),
117 117 )
118 118
119 - **The layer is read here rather than emitted.** MNW and GoingsOn declare an override in a build script and it reaches the page as generated CSS. There is no stylesheet anywhere in this path: egui loads faces itself, so `setup_fonts` reads the declaration back through `Typography::faces` and registers the file under the family name the stack spells. That is what `makeover` 2.10.0 added, and audiofiles is the first renderer on the far side of it.
119 + **The layer is read here rather than emitted.** MNW and GoingsOn declare an override in a build script and it reaches the page as generated CSS. There is no stylesheet anywhere in this path: egui loads faces itself, so `setup_fonts` reads the declaration back through `Typography::faces` and registers the file under the family name the stack spells.
120 120
121 - The gain is that the family name is stated once. `setup_fonts` registers the face under it, `toolbar.rs` asks egui for it back, and neither spells it — a mismatch used to be two string literals agreeing by hand, and it fails silently by falling back to Hack.
121 + The family name is therefore stated once. `setup_fonts` registers the face under it and `toolbar.rs` asks egui for it back, neither spelling it. Two literals agreeing by hand would fail silently by falling back to Hack.
122 122
123 123 ### Stroke / radius
124 124
@@ -130,10 +130,10 @@
130 130 | `border_default` | 1.0 px | Hovered/active widget border, window stroke. |
131 131 | `focus_ring` | 1.5 px `action` | Focus outline on text fields. |
132 132
133 - One radius became two because Platinum wants square containers and push buttons
134 - that still keep a touch of a corner. Both stay overridable under `[geometry]`.
135 - A theme that still sets the old single `rounding` key gets it as the control
136 - radius and square containers, which is the closest reading of what it asked for.
133 + There are two radii because Platinum wants square containers and push buttons
134 + that keep a touch of a corner. Both are overridable under `[geometry]`. A theme
135 + that sets a single `rounding` key gets it as the control radius, with square
136 + containers.
137 137
138 138 **Square is the default and `radius_control()` is opt-in.** `apply_theme` sets
139 139 every one of egui's five widget states to the container radius, because those
@@ -291,7 +291,7 @@
291 291
292 292 ## Panel shapes
293 293
294 - These are de-facto today; the charter codifies them so the surface audits (Phase 1+) can rely on them.
294 + The charter codifies these so surface audits can rely on them.
295 295
296 296 | Shape | Egui type | Conventions |
297 297 |----------------------|----------------------------|-----------------------------------------------------------------------|
@@ -6,11 +6,7 @@
6 6 There is no neural network and no training step at build time.
7 7
8 8 One system does this, the tag pipeline: multi-label, provenance-tracked, per-library.
9 -
10 - There used to be a second one alongside it, a single `SampleClass` label per sample from a
11 - hardcoded threshold tree, feeding the filter panel, theme colours, the `{class}` rename
12 - token, and export resolution. It is gone, tree and column both. Why, and what took over,
13 - is below.
9 + There is no single per-sample class label.
14 10
15 11 ## Per-sample analysis
16 12
@@ -35,8 +31,7 @@
35 31 The expensive BPM/key/loop stages are gated on cheap raw features: BPM and loop detection
36 32 run only for clips long enough to carry tempo, and key detection only for clips that are
37 33 long enough and not noise-like (high spectral flatness). The gate skips the clearly
38 - pointless cases and otherwise runs. It was keyed off the sample class once, which could
39 - skip wrongly on a misread label; there is no label now, and nothing to misread.
34 + pointless cases and otherwise runs.
40 35
41 36 ## The tag pipeline
42 37
@@ -74,44 +69,17 @@
74 69 at library scale: "are these 340 all kicks?" is one question, where the same 340 rows
75 70 one sample at a time is 340 questions nobody finishes.
76 71
77 - ## The threshold tree is gone, and so is the class column
78 -
79 - `classify_full()` assigned a `SampleClass` from a priority-ordered tree of about 40
80 - hand-set thresholds, first match wins. Measured against 1,049 labelled drum samples it was
81 - 33.4% accurate on exact class match, and two of the seven drum classes, `Clap` and `Tom`,
82 - could not be emitted by any rule, so a sample of either was wrong every time.
83 -
84 - Tuning it could not have worked. Roughly a quarter of the corpus was unreachable by
85 - construction, so a sweep over 40 parameters would have fitted noise against a target it
86 - could not hit. The measurements are in the wiki note `af-browse-axes`; the short version is
87 - that instrument identity is not present in these features and coarse family structure is.
88 - One unfitted threshold on the spectral centroid separates low drums from bright drums at
89 - 92.4%, against 33.4% for seven-way identity with 40 tuned constants. Users also mostly do
90 - not ask for the specific name: across 9,493 Freesound sounds, 60.5% carry a family word
91 - and 13.5% a specific instrument.
92 -
93 - So the label was not replaced, it was dropped, along with `SampleClass` and the
94 - `audio_analysis.classification` / `.classification_confidence` columns. What took over:
72 + ## How samples are identified without a class label
95 73
96 74 - **Browsing** by continuous measured axes (register, length, tonal vs noisy, attack).
97 - Nothing to misclassify, no thresholds to tune.
75 + Nothing to misclassify, no thresholds to tune. Measurements: wiki note `af-browse-axes`.
98 76 - **Instrument names** from filename rules (Layer A over `RuleField::Name`) plus the
99 77 user's own tags. `starter_rules.rs` ships the vocabulary as a seedable pack, off
100 - until enabled; see "The starter filename pack" below. Our own ground truth came from filenames: `corpus.py` labelled 1,049 of
101 - 1,074 one-shots that way, 97.7%, on the same files the tree scored 33.4% on. Real packs
102 - name their files `Kick.wav`.
103 - - **"More like this"** by the existing k-NN over the 35-feature vector.
78 + until enabled; see "The starter filename pack" below. Real packs name their files
79 + `Kick.wav`, and filename labelling scores 97.7% against a hand-checked one-shot corpus.
80 + - **"More like this"** by the k-NN over the 35-feature vector.
104 81
105 - What went with the column: the `{class}` rename token (a saved pattern containing it now
106 - fails to parse rather than resolving to an empty string), the `classification` key in the
107 - export sidecar, the class filter checkboxes, the class column and its sort, the per-class
108 - badge and colour table, and the class-keyed tag-suggestion table with its dismissal store.
109 - Schema side: the columns came out of the migration bodies that added them (M003, M011),
110 - along with the index (M004, M028) and the changelog-trigger payload keys, so no database
111 - creates them and none needs to drop them. There is no data migration because there is no
112 - deployed data: a pre-existing dev vault keeps two dead columns until it is rebuilt, and a
113 - saved search that filtered on the class silently loses that criterion (`SearchFilter` is
114 - `#[serde(default)]`, so the stored key deserializes into nothing).
82 + There is no `{class}` rename token: a saved pattern containing it fails to parse.
115 83
116 84 ## The starter filename pack
117 85
@@ -134,31 +102,15 @@
134 102 also present. `RemoveTag` suppresses an earlier add in the same pass. Only the drum
135 103 classes guard each other; "Bass Guitar Loop" being both bass and guitar is correct.
136 104
137 - The two-letter abbreviations from `corpus.py` (` bd`, `_sd`, ` hh`) are deliberately
138 - not in the pack. They were safe against one specific set of drum-machine packs, where
139 - surrounding whitespace disambiguated them, and are false-positive bait against a
140 - library this has never seen.
141 -
142 - Unmeasured, and the honest gap: how the pack degrades on a badly named library. That
143 - is exactly where the k-NN layer should carry the load instead.
105 + Do not add the two-letter abbreviations from `corpus.py` (` bd`, `_sd`, ` hh`) to the
106 + pack. They are safe only where surrounding whitespace disambiguates them, and are
107 + false-positive bait against an unknown library.
144 108
145 109 ## What ships in the binary, and under what licence
146 110
147 - No model and no third-party data. Every number the classifier uses is either computed
148 - from the user's audio at analysis time or written by hand as a threshold.
149 -
150 - That was the position until 2026-07-29, when it was deliberately reversed to allow a
151 - bundled official classifier layer: a `kind = "official"` `.afcl` built from a CC-BY drum
152 - corpus, embedded in the binary and imported on first run. It was never enabled in a
153 - shipped build, and on 2026-08-08 it was retired outright, so the original position holds
154 - again. Stating that plainly rather than letting it be inferred, because the reversal was
155 - written down here and the return should be too.
156 -
157 - Why it was retired, in one line: measured against a user's own labels, the layer changed
158 - no answers and moved accuracy by no points once that user had tagged about 88 samples. It
159 - was a cold-start feature, and the app already gives a new library structure from zero
160 - labels through the measured browse axes. The full argument and the measurements are in the
161 - wiki notes `af-likeness-web` and `af-classifier-pipeline`.
111 + No model and no third-party data, and nothing is bundled. Every number the classifier
112 + uses is either computed from the user's audio at analysis time or written by hand as a
113 + threshold.
162 114
163 115 What that leaves, and it is the whole of it:
164 116
@@ -166,14 +118,13 @@
166 118 on real packs.
167 119 - **The exemplar k-NN** matches against the user's own labels, feeding auto-apply for
168 120 their own tagging and a review queue for anything else.
169 - - **Imported `.afcl` layers** still work in both directions. Export yours, import someone
121 + - **Imported `.afcl` layers** work in both directions. Export yours, import someone
170 122 else's; they arrive disabled, weighted below your own labels, and suggest rather than
171 - apply. Nothing is bundled.
123 + apply.
172 124 - **Browse axes** need no labels at all.
173 125
174 - `audiofiles-bench` keeps two meters over the k-NN, `layer-eval` for whether an answer is
175 - right and `layer-stability` for whether it is the same answer next week. They outlived the
176 - layer they were built for because they measure the substrate the app still runs on.
126 + `audiofiles-bench` keeps two meters over the k-NN: `layer-eval` for whether an answer is
127 + right, `layer-stability` for whether it is the same answer next week.
177 128
178 129 ## Feature vector
179 130
@@ -91,7 +91,7 @@
91 91
92 92 **Database location:** `~/.config/audiofiles/audiofiles.db` (platform-dependent)
93 93
94 - **19 inline migrations** tracked via `PRAGMA user_version`. Run automatically on app startup.
94 + Migrations are tracked via `PRAGMA user_version` and run automatically on app startup.
95 95
96 96 | Symptom | Cause | Fix |
97 97 |---------|-------|-----|