From 70fc29ef803c6a0de2cacd85be916388d9d81ea3 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Fri, 7 Aug 2026 07:02:50 +0800 Subject: [PATCH] feat: tracked-beat corroboration adopts rigid grids on syncopated material ROADMAP Stage 10: the three rigid-grid non-adopters (MSBWY, Hot Stuff, Somebody To Love) sat at phase_lock 0.109-0.181 against the 0.3 gate because swung bassline energy under 150 Hz scores rival phases at scattered subdivision offsets. Both roadmap disambiguation candidates were prototyped first and failed: slot exclusion (rivals are not at one excludable slot) and onset-sharpness weighting (made Hot Stuff worse - disco bass is as punchy as the kick). What landed: a below-threshold fit adopts when >= 60% of the DP tracker's beats - an INDEPENDENT estimator - land on the rigid grid within 25 ms. The measured populations cluster at <= 0.28 (tempo-ramp control, which phase_lock alone would wrongly trust at 0.77; Somebody's genuine estimator disagreement) and >= 0.90 (corroborated); 0.6 splits the gap. The 0.3 gate and smeared sanity floor are untouched. Corpus: MSBWY and Hot Stuff adopt (beat F 0.95/0.93 -> 1.00, downbeat F -> 1.0, sub-ms offsets); 33rd Rate Revs X downbeat F 0 -> 1.0; 13/16 harness rows byte-identical, zero regressions. New unit tests: a syncopated fixture (kick + beater click + swung bass stabs) that adopts via corroboration with the phase on the kicks, and a drifting-tracker control that must not. PREANALYSIS_VERSION/MIN_COMPATIBLE -> 9/9 per the CLAUDE.md analysis version policy (beat positions change on the corroborated class). Co-Authored-By: Claude Fable 5 --- LEARNINGS.md | 33 ++++++++++ ROADMAP.md | 26 +++++--- src/analysis/rigid_grid.rs | 125 ++++++++++++++++++++++++++++++++++++- src/core/preanalysis.rs | 11 +++- 4 files changed, 182 insertions(+), 13 deletions(-) diff --git a/LEARNINGS.md b/LEARNINGS.md index 941b6d7..e85a601 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -281,3 +281,36 @@ in PR #37; the core seam item in PR #38 (`c767181`/`629d6d6` line). gentle ride.** Optional items (fractional correlation reference, strength-aware protection, modulation_hold wiring) remain evidence-gated: each lands only if it moves a gate. + +## Stage 10 — Tracked-Beat Corroboration for Rigid Grids (2026-08-07) + +The three rigid-grid non-adopters (MSBWY, Hot Stuff, Somebody To Love — +syncopated disco, lock 0.109–0.181 vs the 0.3 gate) resisted both +roadmap disambiguation candidates, prototyped and measured before the +fix landed: + +- **Slot exclusion failed**: rival-phase landscapes showed the + competitors at *swung* subdivision offsets (Hot Stuff peaks at + 6–10/32 period, MSBWY at 25/32), not one excludable anti-phase slot; + stacking exclusion zones until specific tracks pass is + threshold-lowering in disguise. +- **Onset-sharpness weighting failed**: squared-envelope emphasis helped + two tracks but made Hot Stuff WORSE (0.109 → 0.072) — disco bass is as + punchy as the kick in the kick band. +- **What worked: corroboration by an independent estimator.** The DP + tracker (full-band novelty, not the kick-band phase circle) + independently lands ≥ 90% of its beats on the rigid grid for the truly + rigid tracks (MSBWY 0.98, Hot Stuff 0.90) and collapses on genuinely + non-rigid material (tempo-ramp control 0.25 — which phase_lock alone + would wrongly trust at 0.77 — and Somebody 0.28, real estimator + disagreement). Adoption gate: lock ≥ 0.3 OR agreement ≥ 0.6 (mid-gap + between the measured ≤ 0.28 and ≥ 0.90 clusters), sanity floor + unchanged. + +Corpus results: MSBWY/Hot Stuff beat F 0.95/0.93 → **1.00**, downbeat F +→ 1.0, offsets sub-ms; 33rd Rate Revs X downbeat F 0 → 1.0; 13/16 +harness rows byte-identical; ramp/jitter unit controls green. +PREANALYSIS_VERSION → 9/9 (first application of the CLAUDE.md policy). +Lessons: two agreeing independent estimators beat one reshaped metric — +and when a fixture needs a beater click before the tracker behaves like +it does on real kicks, the fixture was the problem, not the tracker. diff --git a/ROADMAP.md b/ROADMAP.md index 3c44e08..f9a5dc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,12 +159,19 @@ intervals draws instead). synthetic tempo-ramp fixture, at least one live-drummer recording); explicit acc2/F-measure floors on the non-EDM subset in CI; the QM Vamp baseline column (output-only — qm-dsp is GPL, never source). -- **Offbeat-bass disambiguation for the rigid fit**: the low phase_lock on - disco-bass tracks comes from sub-150 Hz bassline onsets scoring competing - phases. Candidates: weight the kick envelope by onset sharpness, or test - whether the competitor phase sits specifically at a half/quarter-period - offset before counting it against decisiveness. Do **not** lower the 0.3 - gate to chase these tracks (recorded lesson). +- ~~Offbeat-bass disambiguation~~ **done 2026-08-07** (branch + `feat/rigid-grid-offbeat-disambiguation`): both roadmap candidates + (slot exclusion, onset-sharpness weighting) were prototyped and failed + on the class — swung rivals sit at scattered subdivision phases. + Landed instead: **tracked-beat corroboration** — a below-threshold fit + adopts when ≥ 60% of the DP tracker's beats (an independent estimator) + land on the rigid grid within 25 ms. Corpus: MSBWY and Hot Stuff now + adopt (beat F 0.95/0.93 → 1.00, downbeat F → 1.0, offsets sub-ms); + 33rd Rate Revs X downbeat F 0 → 1.0; Somebody To Love honestly stays + out (agreement 0.28 — genuine estimator disagreement, the "annotated + phase-indecisive" bucket); a tempo-ramp control that phase_lock alone + would wrongly trust at 0.77 is rejected at 0.25. The 0.3 gate itself + is untouched. PREANALYSIS_VERSION → 9/9 per the CLAUDE.md policy. - ~~Artifact invalidation policy~~ **done 2026-08-06**: versions bumped to 8/8 so ambiguous v4–v7 sidecars regenerate; policy written into the `src/core/preanalysis.rs` constant docs and RELEASE_CHECKLIST.md (which @@ -177,9 +184,10 @@ intervals draws instead). floors enforced in CI (proposed acc2 ≥ 90%, beat F ≥ 0.85). - Tempo-ramp fixture and live-drummer recording tracked within tolerance; no row where the QM baseline wins by more than noise. -- The three known non-adopters either adopt correctly under the - disambiguation work or are annotated as genuinely phase-indecisive with - the desktop showing an honest low-confidence grid. +- ~~The three known non-adopters~~ two of three adopt with sub-ms offsets + (2026-08-07); Somebody To Love is measured as genuine estimator + disagreement — remaining: annotate it as phase-indecisive and verify + the desktop shows an honest low-confidence grid for it. - Version-bump policy documented and applied on the next analysis change. ## [ ] Stage 12: Robustness Hardening — No-Panic Surface, Fuzzing, Soak diff --git a/src/analysis/rigid_grid.rs b/src/analysis/rigid_grid.rs index 011e78c..3fd922c 100644 --- a/src/analysis/rigid_grid.rs +++ b/src/analysis/rigid_grid.rs @@ -43,6 +43,25 @@ const MIN_BEATS: usize = 16; /// Minimum phase decisiveness to adopt the rigid grid — the annotator's /// own "trust without ear-verification" threshold. const MIN_PHASE_LOCK: f32 = 0.3; +/// Secondary adoption path (ROADMAP Stage 10): a fit whose phase lock is +/// below the threshold still adopts when the DP-tracked beats — an +/// INDEPENDENT estimator (full-band novelty + dynamic programming, not +/// the kick-band phase circle) — land on the rigid grid at this rate. +/// Heavily syncopated disco puts swung bassline energy at scattered +/// subdivision phases, deflating the lock metric on exactly the material +/// where both estimators agree the grid is right (corpus: MSBWY lock +/// 0.112 / agreement 0.98, Hot Stuff 0.109 / 0.90 — while a tempo-ramp +/// control that phase_lock alone would wrongly trust at 0.77 measures +/// 0.25, and Somebody To Love's genuine estimator disagreement measures +/// 0.28). Both slot-exclusion and onset-sharpness disambiguation were +/// prototyped first and failed on this class (LEARNINGS.md). The +/// measured populations cluster at ≤ 0.28 (non-rigid / disagreement) +/// and ≥ 0.90 (corroborated); the threshold splits the gap with margin +/// on both sides rather than sitting on either cluster. +const CORROBORATION_MIN_AGREEMENT: f64 = 0.6; +/// Tolerance for a tracked beat to count as landing on the rigid grid. +/// Same figure as the smear radius: one vinyl-tight beat placement. +const CORROBORATION_TOL_SECS: f64 = SMEAR_RADIUS_SECS; /// Sanity floor: under a timing-tolerant (smeared) objective the rigid /// grid must reach at least this fraction of the tracked beats' score, /// so a decisive-but-wrong fit (e.g. seeded off an octave-wrong tempo on @@ -194,7 +213,27 @@ pub fn refine_grid_rigid(samples: &[f32], sample_rate: u32, grid: BeatGrid) -> ( return (grid, false); }; if fit.phase_lock < MIN_PHASE_LOCK { - return (grid, false); + // Corroborated adoption: the tracked beats are an independent + // estimator; when ≥ 90% of them land on the rigid grid within a + // beat-placement tolerance, low phase lock reflects syncopated + // subdivision energy, not grid ambiguity. Genuinely non-rigid + // material (ramps, drift) fails this hard — the tracked cursor + // walks off a constant grid. + let sr = sample_rate as f64; + let period = 60.0 / fit.bpm; + let hits = grid + .beats + .iter() + .filter(|&&b| { + let t = b / sr; + let k = ((t - fit.phase_secs) / period).round(); + (t - (fit.phase_secs + k * period)).abs() <= CORROBORATION_TOL_SECS + }) + .count(); + let agreement = hits as f64 / grid.beats.len() as f64; + if agreement < CORROBORATION_MIN_AGREEMENT { + return (grid, false); + } } // Sanity floor under a timing-tolerant objective: smear the onset @@ -477,6 +516,90 @@ mod tests { assert!(!grid.downbeats.is_empty()); } + /// Kick train with strong swung bass stabs at ~3/16 and ~5/16 of each + /// beat — the syncopated-disco profile that deflates `phase_lock` + /// below the adoption threshold (subdivision energy rivals the kick + /// at scattered phases) while the DP tracker still locks the kicks. + fn syncopated_train(bpm: f64, seconds: f64) -> Vec { + let len = (SR as f64 * seconds) as usize; + let mut out = vec![0.0f32; len]; + let period = 60.0 * SR as f64 / bpm; + let thump = |out: &mut Vec, at: usize, amp: f64, hz: f64, decay: f64| { + for i in 0..3000.min(len.saturating_sub(at)) { + let t = i as f64 / SR as f64; + out[at + i] += + (amp * (-t * decay).exp() * (2.0 * std::f64::consts::PI * hz * t).sin()) as f32; + } + }; + let mut pos = 0.0f64; + while (pos as usize) < len { + // Kick: low-band thump plus the broadband beater click real + // kicks carry (and synthetic pure-sine "kicks" lack) — the + // full-band tracker keys on the click, the kick-band fit on + // the thump. + thump(&mut out, pos as usize, 0.9, 60.0, 40.0); + thump(&mut out, pos as usize, 0.5, 3000.0, 400.0); + // Swung bass stabs: off-grid subdivisions, kick-band register, + // nearly kick-strength. + thump(&mut out, (pos + 0.19 * period) as usize, 0.4, 70.0, 30.0); + thump(&mut out, (pos + 0.31 * period) as usize, 0.35, 80.0, 30.0); + pos += period; + } + out + } + + #[test] + fn refine_adopts_via_tracked_beat_corroboration() { + let samples = syncopated_train(122.0, 30.0); + let tracked = detect_beats(&samples, SR); + let fit = fit_rigid_grid(&samples, SR, tracked.bpm).expect("fit"); + // Fixture must actually exercise the corroboration path: the lock + // metric alone would reject this material. + assert!( + fit.phase_lock < MIN_PHASE_LOCK, + "fixture no longer deflates phase_lock ({}) — the corroboration \ + path is untested", + fit.phase_lock + ); + let (grid, adopted) = refine_grid_rigid(&samples, SR, tracked); + assert!( + adopted, + "corroborated syncopated material should adopt (lock {})", + fit.phase_lock + ); + // And the adopted phase must be the KICKS, not a bass subdivision. + let period = 60.0 * SR as f64 / grid.bpm; + let first = grid.beats[grid.beats.len() / 2]; + let frac = (first / period).fract(); + let dist = frac.min(1.0 - frac); + assert!( + dist < 0.05, + "adopted grid sits {dist:.3} periods off the kick train" + ); + } + + #[test] + fn refine_rejects_corroboration_when_tracker_disagrees() { + // Same syncopated audio, but a tracked grid whose beats DRIFT off + // any constant grid (the wandering-tracker case): the independent + // estimators disagree, so low lock must stay unadopted. + let samples = syncopated_train(122.0, 30.0); + let tracked = detect_beats(&samples, SR); + let mut drifting = tracked.clone(); + let period = 60.0 * SR as f64 / drifting.bpm; + let n = drifting.beats.len().max(1) as f64; + for (i, b) in drifting.beats.iter_mut().enumerate() { + // Linear drift sweeping one full period across the track, so + // the tracked beats visit every phase of the rigid grid. + *b += period * i as f64 / n; + } + let (_grid, adopted) = refine_grid_rigid(&samples, SR, drifting); + assert!( + !adopted, + "drifting tracked beats must not corroborate a rigid fit" + ); + } + #[test] fn refine_keeps_tracked_grid_on_tempo_ramp() { // 120 → 132 BPM ramp: no rigid grid explains the kicks better diff --git a/src/core/preanalysis.rs b/src/core/preanalysis.rs index 0e3cf17..bbcc5ed 100644 --- a/src/core/preanalysis.rs +++ b/src/core/preanalysis.rs @@ -26,17 +26,22 @@ use std::path::Path; /// ambiguous: they may carry either the old wandering grids or the new /// rigid ones. Forcing regeneration disambiguates. /// +/// v9: no schema change — corroborated rigid-grid adoption (ROADMAP +/// Stage 10) changes beat positions on syncopated low-phase-lock tracks +/// (rigid grids now ship where wandering DP grids did), so v8 artifacts +/// for that class are stale. +/// /// The bump-when policy for these two constants lives in CLAUDE.md /// ("Analysis Version Policy") and is checked at release time via /// RELEASE_CHECKLIST.md. -pub const PREANALYSIS_VERSION: u32 = 8; +pub const PREANALYSIS_VERSION: u32 = 9; /// Oldest schema version whose *analysis results* match the current /// detector. Artifacts below this fail /// [`PreAnalysisArtifact::matches_source`], so cached sidecars regenerate: /// pre-v4 carried the window-start bias; v4–v7 predate (or are ambiguous -/// about) the rigid-grid beat fit. -const MIN_COMPATIBLE_VERSION: u32 = 8; +/// about) the rigid-grid beat fit; v8 predates corroborated adoption. +const MIN_COMPATIBLE_VERSION: u32 = 9; fn default_artifact_version() -> u32 { 1