vocal-semantic-search / ui /src /components /CompoundCard.tsx
Nam Dao
Deploy: catalog, vectors, previews and ONNX export
edfebcc
Raw History Blame Contribute Delete
7.31 kB
import type { CompoundExample, Robustness, TermRow } from "../types";
const W = 660;
const H = 266;
// Left and bottom carry a tick column plus an axis title.
const PAD = { l: 64, r: 16, t: 16, b: 58 };
const AXIS = "rgba(9,15,21,.55)";
/** Recall against description coverage. CLAP is flat; it never read the text. */
function RecallChart({
points,
clap,
chance,
}: {
points: { setting: number; names: boolean; recall: number }[];
clap: number;
chance: number;
}) {
const max = Math.max(clap, ...points.map((p) => p.recall)) * 1.3;
const x = (i: number) => PAD.l + (i * (W - PAD.l - PAD.r)) / (points.length - 1);
const y = (v: number) => H - PAD.b - (v / max) * (H - PAD.t - PAD.b);
const bm = points.map((p, i) => `${i ? "L" : "M"}${x(i)},${y(p.recall)}`).join(" ");
return (
<figure style={{ margin: "18px 0 0" }}>
<svg viewBox={`0 0 ${W} ${H}`} className="scatter" role="img"
aria-label="recall against description coverage">
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
<g key={f}>
<line x1={PAD.l} x2={W - PAD.r} y1={y(max * f)} y2={y(max * f)}
stroke="#ebe8e4" strokeWidth={1} />
<text x={PAD.l - 8} y={y(max * f) + 3} textAnchor="end" fontSize="10"
fontFamily="Geist Mono, monospace" fill="rgba(9,15,21,.35)">
{(max * f).toFixed(2)}
</text>
</g>
))}
<line x1={PAD.l} x2={W - PAD.r} y1={y(chance)} y2={y(chance)}
stroke="rgba(9,15,21,.3)" strokeWidth={1} strokeDasharray="4 4" />
<text x={W - PAD.r} y={y(chance) - 5} textAnchor="end" fontSize="10"
fontFamily="Inter, sans-serif" fill="rgba(9,15,21,.4)">chance</text>
<path d={`M${x(0)},${y(clap)} L${x(points.length - 1)},${y(clap)}`}
stroke="#090f15" strokeWidth={2.5} fill="none" />
<path d={bm} stroke="#fa675b" strokeWidth={2.5} fill="none" />
{points.map((p, i) => (
<circle key={i} cx={x(i)} cy={y(p.recall)} r={4} fill="#fa675b" />
))}
<circle cx={x(0)} cy={y(clap)} r={4} fill="#090f15" />
<circle cx={x(points.length - 1)} cy={y(clap)} r={4} fill="#090f15" />
{points.map((p, i) => (
<text key={`${p.setting}-${p.names}`} x={x(i)} y={H - PAD.b + 20}
textAnchor="middle" fontSize="11" fontFamily="Inter, sans-serif"
fill="rgba(9,15,21,.5)">
{Math.round(p.setting * 100)}%{p.names ? "" : " βœ‚"}
</text>
))}
<text x={(PAD.l + W - PAD.r) / 2} y={H - 8} textAnchor="middle" fontSize="11"
fontFamily="Inter, sans-serif" fill={AXIS}>
description metadata remaining
</text>
<text transform={`translate(14 ${(PAD.t + H - PAD.b) / 2}) rotate(-90)`}
textAnchor="middle" fontSize="11" fontFamily="Inter, sans-serif"
fill={AXIS}>
recall β€” share of the 205 matches found
</text>
</svg>
<figcaption className="legend" style={{ marginTop: 10 }}>
<span style={{ width: "100%", color: "var(--ink-50)" }}>
βœ‚ marks the stages where names are deleted too, not just descriptions
</span>
<span>
<i className="swatch" style={{ background: "#090f15" }} />
CLAP
</span>
<span>
<i className="swatch" style={{ background: "#fa675b" }} />
BM25
</span>
</figcaption>
</figure>
);
}
/** P@10 per query term, against the prevalence of the label it asks for. */
function Terms({ rows }: { rows: TermRow[] }) {
return (
<div className="tablewrap">
<table>
<thead>
<tr>
<th>query term</th>
<th>ground-truth</th>
<th>CLAP</th>
<th>BM25</th>
<th>prevalence</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const joint = r.truth === "conjunction";
return (
<tr key={r.term}
style={{ opacity: r.truth ? 1 : 0.5,
fontWeight: joint ? 600 : undefined,
borderTop: joint ? "1px solid var(--line)" : undefined }}>
<td>{r.term}</td>
<td className={r.truth ? "mono" : undefined}>
{r.truth ?? "no label"}
</td>
<td className="num">
{r.clap === undefined ? "β€”" : r.clap.toFixed(2)}
</td>
<td className="num">
{r.bm25 === undefined ? "β€”" : r.bm25.toFixed(2)}
</td>
<td className="num">
{r.prevalence === undefined ? "β€”" : r.prevalence.toFixed(3)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function Examples({ rows }: { rows: CompoundExample[] }) {
return (
<div className="tablewrap">
<table>
<thead>
<tr>
<th></th>
<th>voice</th>
<th>gender</th>
<th>age</th>
<th>use case</th>
</tr>
</thead>
<tbody>
{rows.map((e) => (
<tr key={e.name} style={{ opacity: e.match ? 1 : 0.55 }}>
<td className={`lift ${e.match ? "up" : "down"}`}>{e.match ? "βœ“" : "βœ—"}</td>
<td>{e.name}</td>
<td>{e.gender}</td>
<td>{(e.age ?? "").replace(/_/g, " ")}</td>
<td>{(e.use_case ?? "").replace(/_/g, " ")}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function CompoundCard({ data }: { data: Robustness["compound"] }) {
if (!data) return null;
return (
<div className="card">
<h2>Comparison with BM25 under metadata ablation</h2>
<p className="sub">
Vector search (CLAP) is compared against text-based BM25 search. Query: β€œ{data.query}”.
BM25 degrades as descriptions are deleted first, then names.
</p>
<h2 style={{ marginTop: 26 }}>Precision@10, term by term</h2>
<p className="sub">
The conjunction is{" "}
{(data.clap.p10 / data.chance).toFixed(1)}Γ— chance.
</p>
<Terms rows={data.terms} />
<h2 style={{ marginTop: 26 }}>Recall as metadata is deleted</h2>
<RecallChart points={data.coverage} clap={data.clap.recall} chance={data.chance} />
<p className="note">
Vector search is stable even when metadata is sparse.
</p>
<h2 style={{ marginTop: 26 }}>What each returns</h2>
<p className="sub">CLAP</p>
<Examples rows={data.examples.clap} />
<p className="sub" style={{ marginTop: 18 }}>BM25, descriptions deleted</p>
<Examples rows={data.examples.bm25_names} />
<p className="note">
With descriptions gone, BM25 returns voices with <em>narrator</em> or{" "}
<em>audiobook</em> in their title.
</p>
<p className="sub" style={{ marginTop: 18 }}>
BM25, descriptions and names deleted
</p>
<Examples rows={data.examples.bm25_nothing} />
<p className="note">
Nothing left to match on: every score is zero.
</p>
</div>
);
}