Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 17 additions & 9 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
125 changes: 124 additions & 1 deletion src/analysis/rigid_grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<f32> {
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<f32>, 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
Expand Down
11 changes: 8 additions & 3 deletions src/core/preanalysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading