fix: report renders empty-truth roles as n/a with spurious count

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 05:17:14 +01:00
co-authored by Claude Sonnet 5.5
parent 42d88a2401
commit d9fd5d8c9f
2 changed files with 32 additions and 13 deletions
+21 -11
View File
@@ -29,19 +29,25 @@ function stat(xs: (number | null | undefined)[], d = 2): string {
return `${fmt(median(v), d)} (${fmt(Math.min(...v), d)}–${fmt(Math.max(...v), d)})`;
}
type Col = { head: string; get: (s: SongScore) => number | null; d?: number };
const drum = (c: "kick" | "snare" | "hihat") => (s: SongScore) => s.drumsPerClass?.[c].f1 ?? null;
type M = { tp: number; fp: number; fn: number; f1: number | null } | null | undefined;
/** F1 only when the truth has notes for the role; otherwise n/a (null). */
const f1OrNA = (m: M): number | null => (m && m.tp + m.fn > 0 ? m.f1 : null);
/** Spurious count (fp) when the truth is empty for the role. */
const spurOf = (m: M): number => (m && m.tp + m.fn === 0 ? m.fp : 0);
type Col = { head: string; get: (s: SongScore) => number | null; spur?: (s: SongScore) => number; d?: number };
const roleCol = (head: string, pick: (s: SongScore) => M): Col => ({ head, get: (s) => f1OrNA(pick(s)), spur: (s) => spurOf(pick(s)) });
const drum = (head: string, c: "kick" | "snare" | "hihat") => roleCol(head, (s) => s.drumsPerClass?.[c]);
const COLS: Col[] = [
{ head: "Guitar merged F1", get: (s) => s.guitarMerged.f1 },
{ head: "Guitar octave errors", get: (s) => s.guitarMerged.octaveErrors, d: 0 },
{ head: "Guitar octave errors", get: (s) => s.guitarMerged.octaveErrors, d: 1 },
{ head: "Spurious rate", get: (s) => s.spuriousRate },
{ head: "Part-split F1 (t1d)", get: (s) => s.partSplit?.f1 ?? null },
{ head: "Vocals F1", get: (s) => s.perRole.vocals?.f1 ?? null },
{ head: "Bass F1", get: (s) => s.perRole.bass?.f1 ?? null },
{ head: "Drums kick F1", get: drum("kick") },
{ head: "Drums snare F1", get: drum("snare") },
{ head: "Drums hihat F1", get: drum("hihat") },
{ head: "Keys F1", get: (s) => s.perRole.keys?.f1 ?? null },
roleCol("Vocals F1", (s) => s.perRole.vocals),
roleCol("Bass F1", (s) => s.perRole.bass),
drum("Drums kick F1", "kick"),
drum("Drums snare F1", "snare"),
drum("Drums hihat F1", "hihat"),
roleCol("Keys F1", (s) => s.perRole.keys),
{ head: "Rhythm onBeat", get: (s) => s.rhythm?.onBeat ?? null },
{ head: "Rhythm durationOk", get: (s) => s.rhythm?.durationOk ?? null },
{ head: "Tempo factor", get: (s) => s.rhythm?.factor ?? null },
@@ -49,6 +55,10 @@ const COLS: Col[] = [
{ head: "String agreement", get: (s) => s.stringAgreement },
];
const withSpur = (cell: string, c: Col, scores: SongScore[]) => {
const n = c.spur ? scores.reduce((a, x) => a + c.spur!(x), 0) : 0;
return cell === NA && n > 0 ? `${NA} (${n} spurious)` : cell;
};
const row = (cells: string[]) => `| ${cells.join(" | ")} |`;
const table = (head: string[], rows: string[][]) =>
[row(head), row(head.map(() => "---")), ...rows.map(row)].join("\n");
@@ -74,7 +84,7 @@ export function renderReport(run: BenchRun): string {
const ms = rs.filter((r) => r.mode === m.name);
const guitarCell = `${stat(ms.map((r) => r.score.guitarMerged.f1))} ${ms.every((r) => r.score.guitarMerged.pass) ? "✓" : "✗"}`;
return [m.name, String(ms.length), guitarCell,
...COLS.slice(1).map((c) => stat(ms.map((r) => c.get(r.score)), c.d ?? 2))];
...COLS.slice(1).map((c) => withSpur(stat(ms.map((r) => c.get(r.score)), c.d ?? 2), c, ms.map((r) => r.score)))];
});
out.push(table(["Mode", "Songs", ...COLS.map((c) => c.head)], rows), "");
const full = rs.filter((r) => r.mode === "full");
@@ -88,6 +98,6 @@ export function renderReport(run: BenchRun): string {
const sorted = [...run.results].sort((a, b) => a.tier.localeCompare(b.tier) || a.seed - b.seed || a.mode.localeCompare(b.mode));
out.push(table(["Tier", "Seed", "Mode", "Guitar pass", ...COLS.map((c) => c.head), "Seconds"],
sorted.map((r) => [r.tier, String(r.seed), r.mode, r.score.guitarMerged.pass ? "✓" : "✗",
...COLS.map((c) => fmt(c.get(r.score), c.d ?? 2)), r.seconds.toFixed(1)])), "");
...COLS.map((c) => withSpur(fmt(c.get(r.score), c.d ?? 2), c, [r.score])), r.seconds.toFixed(1)])), "");
return out.join("\n");
}
+11 -2
View File
@@ -31,8 +31,9 @@ const mk = (g: number): BenchRun => ({
describe("renderReport", () => {
test("column order, n/a, caveat", () => {
const md = renderReport(mk(0.9));
expect(md.indexOf("Guitar")).toBeLessThan(md.indexOf("Vocals"));
expect(md.indexOf("Vocals")).toBeLessThan(md.indexOf("Bass"));
const header = md.split("\n").find((l) => l.startsWith("| Mode"))!;
expect(header.indexOf("Guitar merged F1")).toBeLessThan(header.indexOf("Vocals F1"));
expect(header.indexOf("Vocals F1")).toBeLessThan(header.indexOf("Bass F1"));
expect(md).toContain("n/a");
expect(md).toContain("Voice Oohs");
expect(md).toContain("fx");
@@ -43,4 +44,12 @@ describe("renderReport", () => {
expect(bad).toContain("FAIL");
expect(bad).not.toContain("Tier t1a: PASS");
});
test("role with empty truth renders n/a with spurious count", () => {
const run = mk(0.9);
const s = run.results[0]!.score;
s.perRole.bass = { ...mr(0), tp: 0, fp: 3, fn: 0 };
const md = renderReport({ ...run, results: [run.results[0]!] });
expect(md).toContain("n/a (3 spurious)");
expect(md).not.toContain("0.00 (0.00");
});
});