// --------------------------------------------------------------------------- // One dated observation about a PAIR. No server imports (see lib/note-types.ts // for the break that rule exists to prevent). // // WHY THIS IS NOT verdicts.json. They look similar and they are different // claims: // // verdicts.json a STANDING JUDGEMENT about one file. "wide-nokit is a // keep, because the kit was fighting the vocal." Considered, // written once, and the thing the tree is copied with. // compares.json ONE DATED OBSERVATION about a pair. "on 2026-08-17, blind, // I preferred B." Cheap, repeatable, and only meaningful in // aggregate. // // Merging them would let five casual A/B clicks silently overwrite a considered // keep that had a reason attached. The UI may OFFER "apply the winner as keep" // as an explicit second click; it must never derive one from the other. // // Append-only. A trial is evidence about a moment, and evidence is not edited. // --------------------------------------------------------------------------- export type CompareChoice = "left" | "right" | "tie"; export type Compare = { at: string; cut: string; /** Song-relative rels, in the order they were SHOWN. */ left: string; right: string; winner: CompareChoice; /** * Were the labels hidden when the call was made? * * Recorded per row rather than per file, because a sighted comparison and a * blind one are not the same evidence and a file that mixes them without * saying which is worse than one that holds only sighted calls. */ blind: boolean; note?: string; }; export type CompareDoc = { version: 1; compares: Compare[] }; export const emptyCompares = (): CompareDoc => ({ version: 1, compares: [] }); export const isChoice = (v: string): v is CompareChoice => v === "left" || v === "right" || v === "tie"; export type Tally = { rel: string; wins: number; losses: number; ties: number; blind: number }; /** Win/loss/tie per file, most-compared first. `cut` narrows to one slot. */ export function tally(doc: CompareDoc, cut?: string): Tally[] { const rows = new Map(); const get = (rel: string): Tally => { const hit = rows.get(rel); if (hit) return hit; const fresh: Tally = { rel, wins: 0, losses: 0, ties: 0, blind: 0 }; rows.set(rel, fresh); return fresh; }; for (const c of doc.compares) { if (cut && c.cut !== cut) continue; const l = get(c.left); const r = get(c.right); if (c.winner === "tie") { l.ties += 1; r.ties += 1; } else if (c.winner === "left") { l.wins += 1; r.losses += 1; } else { r.wins += 1; l.losses += 1; } if (c.blind) { l.blind += 1; r.blind += 1; } } return [...rows.values()].sort( (a, b) => b.wins + b.losses + b.ties - (a.wins + a.losses + a.ties) || a.rel.localeCompare(b.rel), ); } /** How two named files have done against each other, in either presented order. */ export function headToHead(doc: CompareDoc, a: string, b: string): { a: number; b: number; ties: number } { let wa = 0; let wb = 0; let ties = 0; for (const c of doc.compares) { const pair = (c.left === a && c.right === b) || (c.left === b && c.right === a); if (!pair) continue; if (c.winner === "tie") ties += 1; else { const won = c.winner === "left" ? c.left : c.right; if (won === a) wa += 1; else wb += 1; } } return { a: wa, b: wb, ties }; } /** "3–1, 1 tie (all blind)" — the one line the chip shows. */ export function summarise(h: { a: number; b: number; ties: number }): string { return `${h.a}–${h.b}${h.ties ? `, ${h.ties} tie${h.ties > 1 ? "s" : ""}` : ""}`; }