Commit 09bace6a by PLN (Algolia)

feat(tide-table): finder recall harness + honest result (#7) — 0.27, below target

build_finder_eval.py measures whether the loop finder surfaces the windows PLN
actually cut, using the correlation engine's provenance map (#12) as ground truth
at scale. Metric v2: dedup GT into distinct sampled windows (PLN cuts many
overlapping loops per stem), give the finder N≥#distinct candidates so recall isn't
candidate-capped, and also report precision.

Result on the 8 most-cut stems: recall 0.27 (45/169 distinct windows), precision
0.24 — BELOW the 0.70 v1 target. Honest and diagnostic, not a pass:
- The eval picked the hardest stems by design (ranked by #cuts) — CHOP-HEAVY sources
  (14–42 distinct cuts each: Doors, Xxplosive, MC Fioti, Brubeck). PLN chops them into
  short, often sub-bar pieces; the finder targets 2/4/8-bar LOOPS, so it can't
  reproduce sub-bar chops.
- Vocal stems worst (Doors/vocals 0.06) vs rhythmic best (Gil Scott-Heron 0.56): the
  per-stem PLP beat grid is weak on sparse-onset vocals → poor candidate windows.
- Loop choice is taste-driven anyway (house rule: finder suggests, human picks).

Next steps surfaced (new task): (1) borrow the beat grid from drums/full-mix for all
stems; (2) add a sub-bar onset-driven "chop mode". The harness is the katana for both.
parent 853eeaf7
...@@ -3,3 +3,4 @@ __pycache__/ ...@@ -3,3 +3,4 @@ __pycache__/
# machine-local audition symlink → Dirt-Samples (recreated by build_unwrapped.py) # machine-local audition symlink → Dirt-Samples (recreated by build_unwrapped.py)
_samples _samples
_corpus _corpus
_provenance_stems.pkl
#!/usr/bin/env python3
"""build_finder_eval — does the finder surface the windows PLN actually kept? (task #7)
The correlation engine (#12) recovered, for 900+ loops, the exact source window PLN
cut (track, stem, offset, length). That's ground truth at scale — so the finder's
recall is now measurable, not a guess: run engine.loops.analyze_stem on a source
stem and check whether its ranked candidates include each GT window (start within
±1 bar, the tolerance loopAt already tolerates per A).
Bounded by --top-stems (analyze_stem is ~30-60 s on a full track) to the stems
carrying the most GT windows, so the eval is a one-shot minutes-long job, not hours.
python3 build_finder_eval.py [--top-stems 8] [--n 8] # → finder_eval.json
v1 targets (PHASE2_FINDINGS §5c): recall@8 ≥ 0.7, tempo within ±2% of GT.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import defaultdict
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
FOUNDRY = ROOT / "tools" / "foundry"
sys.path.insert(0, str(FOUNDRY))
from engine import loops as L # noqa: E402
from engine import grade as G # noqa: E402
HOME = Path.home()
PROV = HERE / "provenance.json"
OUT = HERE / "finder_eval.json"
AS_OF = os.environ.get("FINDER_EVAL_AS_OF", "")
GT_MERGE_SLACK = 1.0 # GT cuts within this many seconds = one distinct sampled window
STEM_BASES = [HOME / "Downloads/separated/htdemucs",
Path(os.environ.get("FOUNDRY_SAMPLES", HOME / "Work/Sound/Samples"))]
def stem_path_map() -> dict:
"""{ '<track>/<role>': path } across the separated/ locations (mirror #12)."""
out = {}
for base in STEM_BASES:
if not base.exists():
continue
wavs = base.glob("*/*.wav") if base.name == "htdemucs" else base.rglob("htdemucs/*/*.wav")
for w in wavs:
out.setdefault(f"{w.parent.name}/{w.stem}", w)
return out
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="measure loop-finder recall against provenance GT")
ap.add_argument("--top-stems", type=int, default=8, help="how many stems to analyze")
ap.add_argument("--n", type=int, default=8, help="recall@N — finder candidates per stem")
ap.add_argument("--bars", type=int, nargs="+", default=[2, 4, 8])
a = ap.parse_args(argv)
if not PROV.exists():
print("no provenance.json — run build_provenance.py first", file=sys.stderr)
return 1
prov = json.loads(PROV.read_text())
# GT windows grouped by source stem
gt = defaultdict(list)
for m in prov["matches"]:
gt[f"{m['track']}/{m['role']}"].append(m)
paths = stem_path_map()
# rank stems by #GT windows; keep the richest (bounds compute)
ranked = sorted(((sid, ms) for sid, ms in gt.items() if sid in paths),
key=lambda kv: -len(kv[1]))[: a.top_stems]
print(f"evaluating finder on {len(ranked)} stems "
f"({sum(len(ms) for _, ms in ranked)} GT windows)…", file=sys.stderr)
def tol_of(c):
return max(c.bars * 60.0 / max(c.bpm, 1), 0.6) # ~1 bar, floor 0.6 s
rows, cov_tot, dist_tot, prec_hit, prec_tot = [], 0, 0, 0, 0
for sid, ms in ranked:
y = G._mono(G.load_audio(paths[sid])[0]).astype(np.float32)
sr = L._sr_of(paths[sid])
# Dedup GT: PLN cuts many overlapping loops from one stem; collapse offsets
# within a bar into DISTINCT windows (the real sections he sampled). recall@N
# vs all raw cuts is ceiling-capped (8 candidates can't cover 42 cuts).
starts = sorted(m["offset_s"] for m in ms)
distinct = []
for s in starts:
if not distinct or s - distinct[-1] > GT_MERGE_SLACK:
distinct.append(s)
n = max(a.n, len(distinct)) # give the finder ≥ as many as he kept
cands = L.analyze_stem(y, sr, bars=tuple(a.bars), top_n=n)
covered = sum(1 for d in distinct
if any(abs(c.start_s - d) <= tol_of(c) for c in cands))
hit_c = sum(1 for c in cands
if any(abs(c.start_s - g) <= tol_of(c) for g in starts))
cov_tot += covered
dist_tot += len(distinct)
prec_hit += hit_c
prec_tot += len(cands)
rows.append({"stem": sid, "raw_cuts": len(ms), "distinct_windows": len(distinct),
"covered": covered, "n_candidates": len(cands), "cand_hits": hit_c,
"recall": round(covered / len(distinct), 3) if distinct else 0,
"precision": round(hit_c / len(cands), 3) if cands else 0})
print(f" {sid[:46]:46} cover {covered}/{len(distinct)} "
f"prec {hit_c}/{len(cands)}", file=sys.stderr)
recall = round(cov_tot / dist_tot, 4) if dist_tot else None
precision = round(prec_hit / prec_tot, 4) if prec_tot else None
report = {
"schema": "finder recall v2 (distinct-window, uncapped N)", "as_of": AS_OF,
"note": "GT dedup'd into distinct sampled windows; N = max(--n, #distinct) so "
"recall isn't capped by candidate count. precision = finder candidates "
"landing on any real cut.",
"n_stems": len(ranked), "min_candidates": a.n, "gt_merge_slack_s": GT_MERGE_SLACK,
"distinct_windows": dist_tot, "covered": cov_tot, "recall": recall,
"candidates": prec_tot, "candidate_hits": prec_hit, "precision": precision,
"target_recall": 0.70, "pass": (recall >= 0.70) if recall is not None else None,
"by_stem": rows,
}
OUT.write_text(json.dumps(report, indent=2))
print(f"✓ {OUT.name}: recall {recall} ({cov_tot}/{dist_tot} distinct windows) · "
f"precision {precision} over {len(ranked)} stems "
f"[target 0.70 → {'PASS' if report['pass'] else 'below'}]", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
{
"schema": "finder recall v2 (distinct-window, uncapped N)",
"as_of": "2026-06-23",
"note": "GT dedup'd into distinct sampled windows; N = max(--n, #distinct) so recall isn't capped by candidate count. precision = finder candidates landing on any real cut.",
"n_stems": 8,
"min_candidates": 8,
"gt_merge_slack_s": 1.0,
"distinct_windows": 169,
"covered": 45,
"recall": 0.2663,
"candidates": 169,
"candidate_hits": 41,
"precision": 0.2426,
"target_recall": 0.7,
"pass": false,
"by_stem": [
{
"stem": "Doors - Riders On The Storm/other",
"raw_cuts": 42,
"distinct_windows": 41,
"covered": 11,
"n_candidates": 41,
"cand_hits": 10,
"recall": 0.268,
"precision": 0.244
},
{
"stem": "Gil Scott Heron - The Revolution Will Not Be Televised [QnJFhuOWgXg]/vocals",
"raw_cuts": 34,
"distinct_windows": 34,
"covered": 19,
"n_candidates": 34,
"cand_hits": 16,
"recall": 0.559,
"precision": 0.471
},
{
"stem": "Xxplosive [WdlyIH2DX60]/vocals",
"raw_cuts": 30,
"distinct_windows": 22,
"covered": 6,
"n_candidates": 22,
"cand_hits": 6,
"recall": 0.273,
"precision": 0.273
},
{
"stem": "Doors - Riders On The Storm/vocals",
"raw_cuts": 17,
"distinct_windows": 16,
"covered": 1,
"n_candidates": 16,
"cand_hits": 1,
"recall": 0.062,
"precision": 0.062
},
{
"stem": "MC Fioti, Future, J Balvin, Stefflon Don, Juan Mag\u00e1n - Bum Bum Tam Tam [8JdC1NmhTVU]/vocals",
"raw_cuts": 16,
"distinct_windows": 14,
"covered": 1,
"n_candidates": 14,
"cand_hits": 1,
"recall": 0.071,
"precision": 0.071
},
{
"stem": "Linkin Park - Numb (Lyrics) [8P0vKLHbtMg]/vocals",
"raw_cuts": 15,
"distinct_windows": 14,
"covered": 3,
"n_candidates": 14,
"cand_hits": 3,
"recall": 0.214,
"precision": 0.214
},
{
"stem": "Dave Brubeck, The Dave Brubeck Quartet - Take Five (Audio) [-DHuW1h1wHw]/other",
"raw_cuts": 15,
"distinct_windows": 15,
"covered": 2,
"n_candidates": 15,
"cand_hits": 2,
"recall": 0.133,
"precision": 0.133
},
{
"stem": "Mason vs Princess Superstar - Perfect (Exceeder) [Official Music Video] [Saltburn Soundtrack] [cXTgrHruvoo]/bass",
"raw_cuts": 14,
"distinct_windows": 13,
"covered": 2,
"n_candidates": 13,
"cand_hits": 2,
"recall": 0.154,
"precision": 0.154
}
]
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment