From d9fd5d8c9fcca4b1348cd3b4933201d9069b0bf7 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 05:17:14 +0100 Subject: [PATCH] fix: report renders empty-truth roles as n/a with spurious count Co-Authored-By: Claude Sonnet 5.5 --- src/bench/report.ts | 32 +++++++++++++++++++++----------- tests/bench/report.test.ts | 13 +++++++++++-- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/bench/report.ts b/src/bench/report.ts index c21819a..0770d6c 100644 --- a/src/bench/report.ts +++ b/src/bench/report.ts @@ -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"); } diff --git a/tests/bench/report.test.ts b/tests/bench/report.test.ts index 75ff698..b09e21d 100644 --- a/tests/bench/report.test.ts +++ b/tests/bench/report.test.ts @@ -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"); + }); });