File size: 3,316 Bytes
fe19082 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | """Bit-equal port of `quality-scorer/src/lib/scoring.js`.
`compute_report(raw)` is the single source of truth for both the offline ingest
pipeline and the live `/analyze` endpoint. Parity vs the JS twin is asserted by
`tests/test_scoring_parity.py`.
"""
from __future__ import annotations
import math
from .signals import SIGNALS, clamp, evaluate_signal
# Critical signals weight hardest; a broken track passing is the costly error.
WEIGHT: dict[str, float] = {
"silence": 1.0,
"clipping": 1.0,
"noise": 0.9,
"truncation": 0.85,
"channel": 0.7,
"duration": 0.6,
"dynamics": 0.4,
}
FAIL_PHRASE: dict[str, str] = {
"silence": "mostly dead air",
"clipping": "hard clipping",
"noise": "noise-dominated spectrum",
"truncation": "cut off mid-phrase",
"duration": "length out of range",
"channel": "collapsed stereo channel",
"dynamics": "no dynamic range",
}
def _js_round(x: float) -> int:
"""JavaScript `Math.round` semantics: half rounds toward +∞.
Python's built-in `round()` uses banker's rounding (half-to-even), which
diverges on .5 boundaries. Composite scores are derived from continuous
severity sums so .5 is rare, but parity demands the JS rule.
"""
return math.floor(x + 0.5)
def compute_report(raw: dict) -> dict:
"""Map raw signal values → the full Track-report shape consumed by the UI.
Verdict is precision-first: any CRITICAL signal failing → DROP, regardless
of the composite score. Dynamics can fail without forcing a drop.
"""
signals: list[dict] = []
for s in SIGNALS:
ev = evaluate_signal(s["id"], raw[s["id"]])
signals.append({
"id": s["id"],
"label": s["label"],
"short": s["short"],
"critical": s["critical"],
"threshold": s["threshold"],
"blurb": s["blurb"],
"value": raw[s["id"]],
**ev,
})
penalty = 0.0
for s in signals:
penalty += (s["severity"] ** 1.4) * WEIGHT.get(s["id"], 0.5) * 27
score = _js_round(clamp(100 - penalty, 0, 100))
failed = [s for s in signals if s["status"] == "fail"]
critical_fails = sorted(
(s for s in failed if s["critical"]),
key=lambda x: x["severity"],
reverse=True,
)
verdict = "drop" if critical_fails else "keep"
primary_fail = critical_fails[0]["id"] if verdict == "drop" else None
if verdict == "drop":
w = critical_fails[0]
extra = f" · +{len(critical_fails) - 1} more" if len(critical_fails) > 1 else ""
reason = (
f"Dropped — {FAIL_PHRASE[w['id']]} "
f"({w['label'].lower()} {w['display']}){extra}."
)
else:
warns = [s for s in signals if s["status"] == "warn"]
if warns:
plural = "s" if len(warns) > 1 else ""
reason = (
f"Kept — within bounds; {len(warns)} signal{plural} "
"flagged for review."
)
else:
reason = "Kept — all technical signals within bounds."
return {
"score": score,
"verdict": verdict,
"primaryFail": primary_fail,
"reason": reason,
"signals": signals,
"failModes": [s["id"] for s in failed],
}
|