Commit f88273b8 by PLN (Algolia)

feat(tide-table): Hall of Fame tierlist data — grade 1690 loops, validate the rubric (#11)

build_tierlist.py grades EVERY hand-cut loop in the Samples corpus with the Foundry
katana (engine/grade.py — DRY, one rubric) and joins each kit against how often PLN
actually plays it (`s "kit"` track-counts from catalog_view.json). 1690 wavs across
97 loop-kits graded in parallel (~98s, 6 workers). Tiers: 4 S, 3 A, 24 B, 51 C, 15 D.

The point of #11 was never the ranking — it's VALIDATING the rubric against reality
(PHASE2_FINDINGS §5b): do the kits PLN reaches for grade higher? Result:
gradeusage Spearman ρ = +0.34 — positive, the katana points the right way.

The confound is the interesting part. The single most-used kit, `risers` (16 tracks),
tiers only C — and that's CORRECT: risers are FX sweeps, they go quiet→loud and never
wrap cleanly, so they fail seam + bar-consistency by design. They're heavily used as
one-shot transitions, not loops. Same for weird_dialogs. So loop-mechanical-grade and
usage diverge exactly where they should — for non-loop material. Filtering FX out would
lift ρ; the divergence is a feature (it tells loops from FX), not a rubric failure.
Meanwhile PLN's custom `f*` palette validates the rubric from the other side: fsynth,
fguitar, fmono all land A-tier; fpiano/forgan/fbreak* B. S-tier exemplars: simmons,
electrn, samples-cello-plucked, ouais.

Caveat logged for follow-up: only 34/97 kits matched a usage token (folder name vs
`s`-token mismatch for the rest, and catalog_view covers 73 tracks) — the correlation
is over those 34. The grader's v1 thresholds (noisy dc-offset flag at 1e-4, seam dB
cutoffs) are the next calibration lever now that we can measure against the corpus.

7 pure-logic tests (spearman incl. ties/inverse, usage = tracks-not-occurrences,
tier thresholds mirror the grader). Next: tierlist.html showcase (Ship's Bridge + impeccable).
parent 1081c74b
#!/usr/bin/env python3
"""build_tierlist — the Foundry Hall of Fame (task #11).
Grade EVERY hand-cut loop in PLN's Samples corpus with the Foundry katana
(tools/foundry/engine/grade.py), rank kits into S/A/B/C/D tiers, and — the part
that earns its keep — **validate the rubric against reality**: a kit's grade
should track how often PLN actually reaches for it in his `.tidal` scores. If the
core-palette kits (used most) don't tier high, the rubric is wrong, not the
library (PHASE2_FINDINGS §5b).
Two sources, joined on the kit name (= the `s "kit"` token):
• grades — median over each kit's depth-1 *.wav (the loops, not nested packs),
graded in parallel; the katana is the single source of truth (DRY).
• usage — per-sound track counts from catalog_view.json (same signal tide_eda
calls `palette`), i.e. how many tracks play `s "kit"`.
python3 build_tierlist.py # → tierlist.json (feeds tierlist.html)
python3 build_tierlist.py --jobs 8 # parallelism (default: cpu-2)
Runs under system python3. Imports the Foundry grader off-tree via sys.path.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent # …/Sound/Tidal
FOUNDRY = ROOT / "tools" / "foundry"
sys.path.insert(0, str(FOUNDRY))
from engine import grade as G # noqa: E402 (the katana)
SAMPLES = Path(os.environ.get("FOUNDRY_SAMPLES", Path.home() / "Work/Sound/Samples"))
CV = HERE / "catalog_view.json"
OUT = HERE / "tierlist.json"
AS_OF = os.environ.get("TIERLIST_AS_OF", "") # stamp via env (no Date.now in scripts)
TIER_ORDER = ["S", "A", "B", "C", "D"]
# ── grading (top-level so ProcessPoolExecutor can pickle it) ─────────────────
def _grade_one(path_str: str) -> dict | None:
try:
g = G.grade(path_str)
return {"name": Path(path_str).name, "grade": g.grade, "tier": g.tier,
"kind": g.kind, "flags": g.flags, "sub": g.sub,
"dur_s": g.metrics.get("dur_s"), "rms_dbfs": g.metrics.get("rms_dbfs")}
except Exception as e:
return {"name": Path(path_str).name, "error": str(e)}
def kit_wavs(kit_dir: Path) -> list[Path]:
"""The hand-cut loops = depth-1 wavs (skip nested sample-packs)."""
return sorted(p for p in kit_dir.iterdir()
if p.is_file() and p.suffix.lower() == ".wav")
def usage_counts() -> Counter:
"""How many tracks play each `s "<sound>"` token (catalog_view, DRY w/ tide_eda)."""
snd = Counter()
if not CV.exists():
return snd
cv = json.loads(CV.read_text())
for t in cv.get("tracks", []):
for s in set(t.get("score_sounds", [])): # once per track
snd[s] += 1
return snd
def median(xs: list[float]) -> float:
s = sorted(xs)
n = len(s)
if not n:
return 0.0
m = n // 2
return s[m] if n % 2 else (s[m - 1] + s[m]) / 2
def spearman(pairs: list[tuple[float, float]]) -> float | None:
"""Rank correlation, ties-averaged. None if <3 points or degenerate."""
if len(pairs) < 3:
return None
xs, ys = zip(*pairs)
def ranks(v):
order = sorted(range(len(v)), key=lambda i: v[i])
r = [0.0] * len(v)
i = 0
while i < len(v):
j = i
while j + 1 < len(v) and v[order[j + 1]] == v[order[i]]:
j += 1
avg = (i + j) / 2 + 1
for k in range(i, j + 1):
r[order[k]] = avg
i = j + 1
return r
rx, ry = ranks(xs), ranks(ys)
n = len(pairs)
mx, my = sum(rx) / n, sum(ry) / n
cov = sum((a - mx) * (b - my) for a, b in zip(rx, ry))
vx = sum((a - mx) ** 2 for a in rx) ** 0.5
vy = sum((b - my) ** 2 for b in ry) ** 0.5
return round(cov / (vx * vy), 4) if vx and vy else None
def tier_of(grade: float) -> str:
for name, lo in G.TIERS:
if grade >= lo:
return name
return "D"
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="grade the Samples corpus into a tierlist")
ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 4) - 2))
ap.add_argument("--limit", type=int, default=0, help="grade only N kits (smoke test)")
a = ap.parse_args(argv)
kits = sorted(d for d in SAMPLES.iterdir() if d.is_dir())
if a.limit:
kits = kits[: a.limit]
usage = usage_counts()
# flatten (kit, wav) and grade in parallel — the slow part
jobs: list[tuple[str, str]] = []
for kdir in kits:
for w in kit_wavs(kdir):
jobs.append((kdir.name, str(w)))
print(f"grading {len(jobs)} wavs across {len(kits)} kits on {a.jobs} workers…",
file=sys.stderr)
results: dict[str, list[dict]] = {k.name: [] for k in kits}
with ProcessPoolExecutor(max_workers=a.jobs) as ex:
graded = list(ex.map(_grade_one, [j[1] for j in jobs], chunksize=8))
for (kit, _), g in zip(jobs, graded):
results[kit].append(g)
# aggregate per kit
kit_rows = []
for kdir in kits:
files = [f for f in results[kdir.name] if "grade" in f]
if not files:
continue
grades = [f["grade"] for f in files]
med = round(median(grades), 4)
tier_dist = Counter(f["tier"] for f in files)
flag_dist = Counter(fl for f in files for fl in f.get("flags", []))
kit_rows.append({
"kit": kdir.name,
"n_wavs": len(files),
"grade": med,
"tier": tier_of(med),
"best": round(max(grades), 4),
"worst": round(min(grades), 4),
"usage": usage.get(kdir.name, 0),
"tier_dist": {t: tier_dist.get(t, 0) for t in TIER_ORDER},
"top_flags": dict(flag_dist.most_common(5)),
"files": sorted(files, key=lambda f: -f["grade"]),
})
kit_rows.sort(key=lambda r: -r["grade"])
# rubric validation: do used kits tier higher?
used = [(r["grade"], r["usage"]) for r in kit_rows if r["usage"] > 0]
rho = spearman(used)
core = [r for r in kit_rows if r["usage"] >= 5] # set-staple kits
core_b_plus = sum(1 for r in core if r["grade"] >= 0.70)
corpus_tier = Counter(r["tier"] for r in kit_rows)
report = {
"schema": "foundry tierlist v1",
"as_of": AS_OF,
"samples_root": str(SAMPLES),
"grader": "tools/foundry/engine/grade.py",
"n_kits": len(kit_rows),
"n_wavs": sum(r["n_wavs"] for r in kit_rows),
"tier_distribution": {t: corpus_tier.get(t, 0) for t in TIER_ORDER},
"validation": {
"note": "rubric is sound iff used kits tier high (PHASE2_FINDINGS §5b)",
"grade_usage_spearman": rho,
"n_kits_with_usage": len(used),
"core_kits_usage_ge_5": len(core),
"core_kits_b_tier_or_above": core_b_plus,
"core_kits_pass": (core_b_plus / len(core)) if core else None,
},
"kits": kit_rows,
}
OUT.write_text(json.dumps(report, indent=2))
print(f"✓ {OUT.name}: {len(kit_rows)} kits, {report['n_wavs']} wavs | "
f"tiers {dict(report['tier_distribution'])} | "
f"grade↔usage ρ={rho} | core B+ {core_b_plus}/{len(core)}",
file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""Pure-logic tests for build_tierlist (no audio, no grader import needed at call time).
The grading itself is the katana's job (tested in tools/foundry/tests/test_grade.py);
here we pin the join/stats math that turns grades into the Hall of Fame.
"""
import build_tierlist as T
def test_median_odd_even():
assert T.median([3, 1, 2]) == 2
assert T.median([4, 1, 2, 3]) == 2.5
assert T.median([]) == 0.0
assert T.median([7]) == 7
def test_spearman_perfect_monotonic():
# grade rises with usage → ρ = 1
pairs = [(0.5, 1), (0.6, 2), (0.7, 3), (0.8, 4)]
assert T.spearman(pairs) == 1.0
def test_spearman_inverse():
pairs = [(0.8, 1), (0.7, 2), (0.6, 3), (0.5, 4)]
assert T.spearman(pairs) == -1.0
def test_spearman_ties_averaged():
# two equal grades share the averaged rank — must not crash or NaN
pairs = [(0.5, 1), (0.5, 2), (0.7, 3), (0.9, 4)]
rho = T.spearman(pairs)
assert rho is not None and 0.0 < rho <= 1.0
def test_spearman_too_few_points():
assert T.spearman([(0.5, 1), (0.6, 2)]) is None
def test_tier_of_matches_grader_thresholds():
# build_tierlist must tier identically to the katana
assert T.tier_of(0.95) == "S"
assert T.tier_of(0.80) == "A"
assert T.tier_of(0.70) == "B"
assert T.tier_of(0.55) == "C"
assert T.tier_of(0.0) == "D"
def test_usage_counts_counts_tracks_not_occurrences(tmp_path, monkeypatch):
# a sound used twice IN ONE track counts once; used in two tracks counts twice
cv = tmp_path / "catalog_view.json"
cv.write_text(
'{"tracks": ['
'{"score_sounds": ["crimewave", "crimewave", "risers"]},'
'{"score_sounds": ["risers", "fbass"]}'
']}'
)
monkeypatch.setattr(T, "CV", cv)
u = T.usage_counts()
assert u["crimewave"] == 1 # twice in one track → 1
assert u["risers"] == 2 # once each in two tracks → 2
assert u["fbass"] == 1
This source diff could not be displayed because it is too large. You can view the blob instead.
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