Commit cd3caa7c by PLN (Algolia)

feat(douanier): /grade endpoint — the Foundry loop grader as a building block (#39)

Exposes the Foundry's mechanical loop-quality grader (the "katana") on
/audio/v1/grade: upload a loop/one-shot → composite 0..1 + S/A/B/C/D tier,
per-rule sub-scores (seam click, zero-crossing cleanliness, DC, bar
self-consistency, level, bass mono-compat) + human-readable flags. scope `grade`,
cached, torch-free. Directly serves "iterate on sampling quality".

engines/grade.py is a BYTE-FOR-BYTE vendored copy of tools/foundry/engine/grade.py
(self-contained — numpy/soundfile/pydantic/librosa, no Foundry/torch — because the
container holds only armada/api/). It's kept identical on purpose, and
tests/test_grade_endpoint.py is a DRIFT GUARD: it imports the Foundry canonical
standalone and asserts the vendored copy grades identically (grade/tier/sub +
WEIGHTS/THRESH) on a synth signal — a future Foundry tweak that isn't re-vendored
fails CI here, not silently in prod (parsers-over-copy, applied to a vendored copy).

Validated in the torch-free container (healthz engines now emotion/features/
samples/grade/separate; a sine tone grades D with a correct seam-click flag) +
43/43 tests. Note: this is the CPU-doable slice of #31's /loops /grade /correlate
— the finder (/loops) and corpus correlation (/correlate) remain.
parent 9eb26236
...@@ -55,6 +55,7 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path): ...@@ -55,6 +55,7 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path):
- `POST /analyze/emotion``multipart` audio → `{valence, arousal, top…}` (scope `emotion`) - `POST /analyze/emotion``multipart` audio → `{valence, arousal, top…}` (scope `emotion`)
- `POST /features` — audio → the ~35-dim feature stack (spectral/MFCC/chroma/key/envelope + rhythm); `?rhythm=` (scope `features`) - `POST /features` — audio → the ~35-dim feature stack (spectral/MFCC/chroma/key/envelope + rhythm); `?rhythm=` (scope `features`)
- `POST /analyze/samples` — one-shot/loop → per-sample EDA + MEASURED role (percs|bass|melodic|tops|atmos); `?name=` only disambiguates breaks/drums (scope `samples`) - `POST /analyze/samples` — one-shot/loop → per-sample EDA + MEASURED role (percs|bass|melodic|tops|atmos); `?name=` only disambiguates breaks/drums (scope `samples`)
- `POST /grade` — loop/one-shot → the Foundry's mechanical quality grade (0..1 + S/A/B/C/D tier, per-rule sub-scores + flags); `?role=` hint (scope `grade`)
- `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`) - `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`)
All three analyze routes are **torch-free** (librosa) and **content-addressed All three analyze routes are **torch-free** (librosa) and **content-addressed
......
...@@ -19,6 +19,7 @@ import auth ...@@ -19,6 +19,7 @@ import auth
import cache import cache
import config import config
from engines import ears, feats from engines import ears, feats
from engines import grade as grade_eng # VENDORED copy of the Foundry grader (see engines/grade.py)
app = FastAPI( app = FastAPI(
title="Douanier — the `audio` sub-API of the nech.pl platform", title="Douanier — the `audio` sub-API of the nech.pl platform",
...@@ -101,6 +102,7 @@ def healthz(): ...@@ -101,6 +102,7 @@ def healthz():
"emotion": {"selected": config.EMOTION_ENGINE, "available": ok, "hint": hint}, "emotion": {"selected": config.EMOTION_ENGINE, "available": ok, "hint": hint},
"features": {"available": ok, "hint": "light feature stack (librosa, no torch)"}, "features": {"available": ok, "hint": "light feature stack (librosa, no torch)"},
"samples": {"available": ok, "hint": "measured sample EDA + role (librosa, no torch)"}, "samples": {"available": ok, "hint": "measured sample EDA + role (librosa, no torch)"},
"grade": {"available": True, "hint": "Foundry mechanical loop grader (no torch)"},
"separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"}, "separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"},
}, },
} }
...@@ -211,6 +213,20 @@ async def analyze_samples(response: Response, file: UploadFile = File(...), name ...@@ -211,6 +213,20 @@ async def analyze_samples(response: Response, file: UploadFile = File(...), name
return {"filename": file.filename, "content_id": cid, **result} return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/grade")
async def grade(response: Response, file: UploadFile = File(...), role: str = "",
_p: auth.Principal = Depends(require_scope("grade"))):
"""Upload a loop/one-shot → the Foundry's MECHANICAL quality grade: composite
0..1 + S/A/B/C/D tier, per-rule sub-scores (seam click, zero-crossing
cleanliness, DC, bar self-consistency, level, bass mono-compat) + flags.
`role` ('bass'|'drums'|…) is a hint. WAV/FLAC preferred. Torch-free, cached."""
data = await _read_upload(file)
params = {"role": role or None, "v": 1}
cid, result = _cached("grade", params, data, file.filename,
lambda p: grade_eng.grade(p, role=role or None).model_dump(), response)
return {"filename": file.filename, "content_id": cid, "grade": result}
# ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ── # ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ──
@v1.post("/separate") @v1.post("/separate")
async def separate(_p: auth.Principal = Depends(require_scope("separate"))): async def separate(_p: auth.Principal = Depends(require_scope("separate"))):
......
"""/grade endpoint + the VENDORING DRIFT GUARD (#39).
engines/grade.py is a vendored copy of tools/foundry/engine/grade.py (the
container has no Foundry). This locks the endpoint contract AND mechanically
proves the copy still grades IDENTICALLY to the canonical — so a future edit to
the Foundry grader that isn't re-vendored fails CI here, not silently in prod.
cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/test_grade_endpoint.py -q
"""
import importlib.util
import io
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
from fastapi.testclient import TestClient
import app as APP
import config
from engines import grade as vendored
client = TestClient(APP.app)
GW = {"X-Tenant": "hexa", "X-Scopes": "grade"}
def _wav_bytes(freqs=(110, 220), secs=1.5, sr=44100):
t = np.linspace(0, secs, int(sr * secs), endpoint=False)
y = (0.3 * sum(np.sin(2 * np.pi * f * t) for f in freqs) / len(freqs)).astype("float32")
buf = io.BytesIO()
sf.write(buf, y, sr, format="WAV")
return buf.getvalue()
def test_grade_endpoint_and_cache():
files = {"file": ("loop.wav", _wav_bytes(), "audio/wav")}
r1 = client.post("/grade", files=files, headers=GW)
assert r1.status_code == 200
assert r1.headers["X-Douanier-Cache"] == "miss"
g = r1.json()["grade"]
assert g["tier"] in {"S", "A", "B", "C", "D"}
assert 0.0 <= g["grade"] <= 1.0
assert "seam" in g["sub"] and "rms_dbfs" in g["metrics"]
r2 = client.post("/grade", files={"file": ("loop.wav", _wav_bytes(), "audio/wav")}, headers=GW)
assert r2.headers["X-Douanier-Cache"] == "hit"
def test_grade_scope_enforced():
r = client.post("/grade", files={"file": ("c.wav", _wav_bytes(), "audio/wav")},
headers={"X-Tenant": "hexa", "X-Scopes": "features"})
assert r.status_code == 403
def _load_foundry_grade():
"""Import the canonical Foundry grade.py standalone (it has no sibling imports)."""
p = Path(config.FOUNDRY) / "engine" / "grade.py"
if not p.exists():
return None
spec = importlib.util.spec_from_file_location("_foundry_grade", p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_vendored_copy_has_not_drifted():
canonical = _load_foundry_grade()
if canonical is None:
pytest.skip("Foundry not on disk — drift guard runs in-repo only")
sr = 44100
rng = np.random.default_rng(0)
t = np.linspace(0, 2.0, sr * 2, endpoint=False)
y = (0.3 * np.sin(2 * np.pi * 110 * t) + 0.02 * rng.standard_normal(t.size)).astype("float64")
gv = vendored.grade_array(y, sr)
gc = canonical.grade_array(y, sr)
assert gv.grade == gc.grade, "vendored grade.py drifted from the Foundry canonical — re-vendor"
assert gv.tier == gc.tier
assert gv.sub == gc.sub
# the tunables must match too (weights/thresholds are where drift usually hides)
assert vendored.WEIGHTS == canonical.WEIGHTS
assert vendored.THRESH == canonical.THRESH
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