Spaces:
Sleeping
Sleeping
ngoan Claude Opus 4.8 (1M context) commited on
Commit ·
d70f132
1
Parent(s): 73d498e
MedASR Bench: full platform + Hugging Face Docker Space packaging
Browse filesMulti-dataset leaderboard UI (Next.js 16, Tailwind v4) plus R0 release
packaging: output:'standalone', Dockerfile (node:22-alpine, uid 1000,
port 3000), .dockerignore, and HF Space README front-matter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +17 -0
- Dockerfile +39 -0
- README.md +36 -26
- SKILL.md +231 -0
- next.config.ts +4 -1
- package-lock.json +457 -4
- package.json +7 -2
- scripts/sync-results.mjs +142 -0
- src/app/compare/loading.tsx +100 -0
- src/app/compare/page.tsx +101 -0
- src/app/datasets/[slug]/page.tsx +548 -0
- src/app/datasets/loading.tsx +43 -0
- src/app/datasets/page.tsx +274 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +811 -14
- src/app/icon.svg +12 -0
- src/app/layout.tsx +116 -9
- src/app/loading.tsx +59 -0
- src/app/methodology/page.tsx +304 -0
- src/app/models/[slug]/loading.tsx +83 -0
- src/app/models/[slug]/page.tsx +230 -0
- src/app/page.tsx +174 -56
- src/app/template.tsx +16 -0
- src/components/SiteNav.tsx +137 -0
- src/components/compare/EfficiencySection.tsx +244 -0
- src/components/compare/HeadToHead.tsx +439 -0
- src/components/compare/ParetoChart.tsx +350 -0
- src/components/compare/ParetoSection.tsx +126 -0
- src/components/compare/compare-utils.ts +268 -0
- src/components/docs/CautionCallout.tsx +45 -0
- src/components/docs/HourBar.tsx +43 -0
- src/components/docs/ManifestCard.tsx +79 -0
- src/components/docs/MetricDefCard.tsx +77 -0
- src/components/docs/OnboardingCriteria.tsx +76 -0
- src/components/docs/PipelineSteps.tsx +48 -0
- src/components/docs/ProvenanceLadder.tsx +82 -0
- src/components/docs/RoadmapStrip.tsx +81 -0
- src/components/docs/SplitMiniBar.tsx +74 -0
- src/components/docs/SplitsTable.tsx +138 -0
- src/components/docs/StatTile.tsx +42 -0
- src/components/docs/TopicBars.tsx +47 -0
- src/components/docs/TracksTable.tsx +140 -0
- src/components/leaderboard/HeroStats.tsx +127 -0
- src/components/leaderboard/LeaderboardTable.tsx +1013 -0
- src/components/model/DatasetScope.tsx +53 -0
- src/components/model/FlagNotes.tsx +79 -0
- src/components/model/FrontierThumb.tsx +163 -0
- src/components/model/GeneralizationStrip.tsx +136 -0
- src/components/model/MetricPanel.tsx +160 -0
- src/components/model/ModelHeader.tsx +66 -0
.dockerignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
out
|
| 4 |
+
build
|
| 5 |
+
.git
|
| 6 |
+
.gitignore
|
| 7 |
+
Dockerfile
|
| 8 |
+
.dockerignore
|
| 9 |
+
README.md
|
| 10 |
+
.DS_Store
|
| 11 |
+
*.pem
|
| 12 |
+
.env*
|
| 13 |
+
npm-debug.log*
|
| 14 |
+
.vercel
|
| 15 |
+
coverage
|
| 16 |
+
.idea
|
| 17 |
+
.vscode
|
Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
# MedASR Bench — Hugging Face Docker Space (CPU). Next.js 16 standalone output.
|
| 3 |
+
# The Space runtime requires the container to run as uid 1000 and listen on
|
| 4 |
+
# app_port (3000, set in README.md front-matter).
|
| 5 |
+
|
| 6 |
+
# ---- deps: install full deps for the build ----
|
| 7 |
+
FROM node:22-alpine AS deps
|
| 8 |
+
RUN apk add --no-cache libc6-compat
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
COPY package.json package-lock.json ./
|
| 11 |
+
RUN npm ci
|
| 12 |
+
|
| 13 |
+
# ---- builder: produce .next/standalone ----
|
| 14 |
+
FROM node:22-alpine AS builder
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 17 |
+
COPY . .
|
| 18 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 19 |
+
RUN npm run build
|
| 20 |
+
|
| 21 |
+
# ---- runner: minimal self-contained image ----
|
| 22 |
+
FROM node:22-alpine AS runner
|
| 23 |
+
WORKDIR /app
|
| 24 |
+
ENV NODE_ENV=production \
|
| 25 |
+
NEXT_TELEMETRY_DISABLED=1 \
|
| 26 |
+
PORT=3000 \
|
| 27 |
+
HOSTNAME=0.0.0.0
|
| 28 |
+
|
| 29 |
+
# node:alpine already ships a `node` user at uid 1000 — exactly what HF Spaces
|
| 30 |
+
# expect — so no custom user is created (uid 1000 is already taken).
|
| 31 |
+
# server.js lives at the root of .next/standalone; public/ and .next/static
|
| 32 |
+
# are NOT bundled into standalone and must be copied in explicitly.
|
| 33 |
+
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
| 34 |
+
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
| 35 |
+
COPY --from=builder --chown=node:node /app/public ./public
|
| 36 |
+
|
| 37 |
+
USER node
|
| 38 |
+
EXPOSE 3000
|
| 39 |
+
CMD ["node", "server.js"]
|
README.md
CHANGED
|
@@ -1,36 +1,46 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
#
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
pnpm dev
|
| 13 |
-
# or
|
| 14 |
-
bun dev
|
| 15 |
-
```
|
| 16 |
-
|
| 17 |
-
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
-
|
| 19 |
-
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
-
|
| 21 |
-
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
|
| 23 |
-
##
|
| 24 |
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
##
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MedASR Bench
|
| 3 |
+
emoji: 🩺
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 3000
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Vietnamese medical ASR benchmark, code-switching aware
|
| 11 |
+
---
|
| 12 |
|
| 13 |
+
# MedASR Bench
|
| 14 |
|
| 15 |
+
A public benchmark for **Vietnamese medical speech recognition with English
|
| 16 |
+
code-switching**, anchored on the [ViMedCSS](https://huggingface.co/datasets/tensorxt/ViMedCSS)
|
| 17 |
+
dataset. Part of the **Clinical Scribe** program.
|
| 18 |
|
| 19 |
+
This Space is the **display layer** only — a stateless Next.js app that renders
|
| 20 |
+
the leaderboard, per-model pages, and methodology. It runs on free CPU; no audio
|
| 21 |
+
and no model inference ever happen here. Benchmark numbers are produced by the
|
| 22 |
+
offline harness (`benchmark/medasr_eval.py`) and published as versioned,
|
| 23 |
+
auditable run manifests.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
## Metrics
|
| 26 |
|
| 27 |
+
WER · CER · CS-WER (code-switched-term WER) · N-WER · MTR (medical-term
|
| 28 |
+
recall), each with bootstrap 95% CIs under a pinned text normalizer.
|
| 29 |
|
| 30 |
+
## Running locally
|
|
|
|
| 31 |
|
| 32 |
+
```bash
|
| 33 |
+
npm install
|
| 34 |
+
npm run dev # http://localhost:3000
|
| 35 |
+
```
|
| 36 |
|
| 37 |
+
## Build / deploy
|
| 38 |
|
| 39 |
+
This Space is a Docker SDK Space. The image builds the Next.js
|
| 40 |
+
[`standalone`](https://nextjs.org/docs/app/api-reference/config/next-config-js/output)
|
| 41 |
+
output and serves it with `node server.js` as uid 1000 on port 3000.
|
| 42 |
|
| 43 |
+
```bash
|
| 44 |
+
docker build -t medasr-bench .
|
| 45 |
+
docker run --rm -p 3000:3000 medasr-bench
|
| 46 |
+
```
|
SKILL.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: clinicalscribe-taste
|
| 3 |
+
description: Anti-slop frontend skill for ClinicalScribe Medical ASR Benchmark. Premium clinical instrument design with ECG phosphor accents, hairline borders, and orchestrated entrance animations.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# ClinicalScribe Taste Skill
|
| 7 |
+
|
| 8 |
+
Anti-slop frontend skill for the ClinicalScribe Medical ASR Benchmark platform.
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## Design Read
|
| 13 |
+
|
| 14 |
+
**"Reading this as:** B2B medical/scientific benchmark platform for Vietnamese Medical ASR evaluation, with a light "precision instrument" language — paper-white surfaces, ink text, deep violet accents, hairline borders, and IBM Plex Mono numerals. The aesthetic should feel like a premium lab report / modern dev-tool dashboard (Linear/Stripe register) — precise, data-dense, trustworthy.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## Dial Settings
|
| 19 |
+
|
| 20 |
+
Based on the design read:
|
| 21 |
+
|
| 22 |
+
- **`DESIGN_VARIANCE: 5`** — Scientific/professional environment, moderate asymmetry is acceptable
|
| 23 |
+
- **`MOTION_INTENSITY: 6`** — Orchestrated entrance animations, micro-interactions for interactivity
|
| 24 |
+
- **`VISUAL_DENSITY: 7`** — Data-dense benchmark platform, information-rich layouts
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## Design System Foundation
|
| 29 |
+
|
| 30 |
+
### Typography
|
| 31 |
+
|
| 32 |
+
- **Display:** `Instrument Serif` — Editorial headings with medical gravitas
|
| 33 |
+
- **Body:** `Be Vietnam Pro` — UI text with Vietnamese language support
|
| 34 |
+
- **Mono:** `IBM Plex Mono` — Data/numerals with tabular figures
|
| 35 |
+
|
| 36 |
+
**Upgrades applied:**
|
| 37 |
+
- Reduce all-caps subheaders where possible
|
| 38 |
+
- Ensure italic descender clearance with `leading-[1.1]` minimum
|
| 39 |
+
- Tighten letter-spacing on large headers
|
| 40 |
+
|
| 41 |
+
### Color Calibration
|
| 42 |
+
|
| 43 |
+
ClinicalScribe already has excellent color discipline (theme flipped to
|
| 44 |
+
LIGHT 2026-06-11 at user request — "light theme, tech savvy, professional";
|
| 45 |
+
palette built in OKLCH, all small-text pairings ≥ 4.5:1):
|
| 46 |
+
|
| 47 |
+
| Token | Value | Purpose |
|
| 48 |
+
|-------|-------|---------|
|
| 49 |
+
| `--bg` | `#f9f9fb` | Cool paper-white background |
|
| 50 |
+
| `--surface` | `#ffffff` | Card surfaces |
|
| 51 |
+
| `--surface-2` | `#eeeef2` | Recessed panels / code blocks |
|
| 52 |
+
| `--text` | `#24242f` | Ink text |
|
| 53 |
+
| `--accent` | `#654abb` | Deep violet — oklch(0.50 0.17 290) |
|
| 54 |
+
| `--warn` | `#a15e00` | Caution states |
|
| 55 |
+
| `--crit` | `#c53637` | Critical/error states |
|
| 56 |
+
|
| 57 |
+
**No green anywhere** — the user rejected phosphor green, desaturated teal,
|
| 58 |
+
and soft blue; the accent is user-picked violet. Don't drift it back toward
|
| 59 |
+
the green–blue band. The theme is LIGHT — don't reintroduce dark surfaces.
|
| 60 |
+
|
| 61 |
+
**Preserve:** Single accent color (deep violet), hairline borders, soft
|
| 62 |
+
low-alpha shadows (rgba(20,20,45,…) ≤ 0.10). All tints derive from tokens
|
| 63 |
+
via `color-mix` — never hardcode accent rgba values.
|
| 64 |
+
**Enhance:** Add subtle grain texture, improve hover spotlight effects.
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## Enhancement Priorities
|
| 69 |
+
|
| 70 |
+
### 1. Grain & Texture (High Impact)
|
| 71 |
+
|
| 72 |
+
Add subtle noise overlay for depth:
|
| 73 |
+
|
| 74 |
+
```css
|
| 75 |
+
/* Subtle film grain for tactile quality */
|
| 76 |
+
.grain-overlay::after {
|
| 77 |
+
content: "";
|
| 78 |
+
position: fixed;
|
| 79 |
+
inset: 0;
|
| 80 |
+
z-index: 31;
|
| 81 |
+
pointer-events: none;
|
| 82 |
+
opacity: 0.03;
|
| 83 |
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
| 84 |
+
}
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### 2. Spotlight Border Cards
|
| 88 |
+
|
| 89 |
+
Interactive cards that illuminate under cursor:
|
| 90 |
+
|
| 91 |
+
```css
|
| 92 |
+
.spotlight-card {
|
| 93 |
+
position: relative;
|
| 94 |
+
overflow: hidden;
|
| 95 |
+
transition: border-color 0.3s ease;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.spotlight-card::before {
|
| 99 |
+
content: "";
|
| 100 |
+
position: absolute;
|
| 101 |
+
inset: 0;
|
| 102 |
+
border-radius: inherit;
|
| 103 |
+
padding: 1px;
|
| 104 |
+
background: radial-gradient(
|
| 105 |
+
600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
|
| 106 |
+
rgba(52, 245, 197, 0.15),
|
| 107 |
+
transparent 40%
|
| 108 |
+
);
|
| 109 |
+
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
| 110 |
+
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
| 111 |
+
-webkit-mask-composite: xor;
|
| 112 |
+
mask-composite: exclude;
|
| 113 |
+
opacity: 0;
|
| 114 |
+
transition: opacity 0.3s ease;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.spotlight-card:hover::before {
|
| 118 |
+
opacity: 1;
|
| 119 |
+
}
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### 3. Tactile Button States
|
| 123 |
+
|
| 124 |
+
```css
|
| 125 |
+
.tactile-button:active {
|
| 126 |
+
transform: scale(0.98) translateY(1px);
|
| 127 |
+
transition: transform 0.1s ease;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.tactile-button:hover {
|
| 131 |
+
background-color: var(--surface-2);
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
### 4. Spring Physics for Animations
|
| 136 |
+
|
| 137 |
+
Replace linear easing with spring curves:
|
| 138 |
+
|
| 139 |
+
```css
|
| 140 |
+
.spring-in {
|
| 141 |
+
animation: spring-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
@keyframes spring-in {
|
| 145 |
+
from {
|
| 146 |
+
opacity: 0;
|
| 147 |
+
transform: scale(0.95) translateY(10px);
|
| 148 |
+
}
|
| 149 |
+
to {
|
| 150 |
+
opacity: 1;
|
| 151 |
+
transform: scale(1) translateY(0);
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
### 5. Stagger Refinements
|
| 157 |
+
|
| 158 |
+
```css
|
| 159 |
+
/* Improved stagger with spring feel */
|
| 160 |
+
.stagger-spring > * {
|
| 161 |
+
animation: stagger-spring 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
@keyframes stagger-spring {
|
| 165 |
+
from {
|
| 166 |
+
opacity: 0;
|
| 167 |
+
transform: translateY(16px);
|
| 168 |
+
}
|
| 169 |
+
to {
|
| 170 |
+
opacity: 1;
|
| 171 |
+
transform: translateY(0);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## Layout Discipline
|
| 179 |
+
|
| 180 |
+
### Preserved Elements
|
| 181 |
+
- Graph-paper micro-grid background (unique, on-brand)
|
| 182 |
+
- Scanline overlay effect
|
| 183 |
+
- Hairline borders (signature element)
|
| 184 |
+
- Editorial hero with Instrument Serif
|
| 185 |
+
- ECG phosphor accents (used sparingly)
|
| 186 |
+
|
| 187 |
+
### Enhancements
|
| 188 |
+
- Increase section breathing room
|
| 189 |
+
- Better vertical rhythm with `gap-y-12` or `gap-y-16`
|
| 190 |
+
- Asymmetric hero layout with offset stats
|
| 191 |
+
- Refined table row hover states
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## Component Upgrades
|
| 196 |
+
|
| 197 |
+
### Card Component
|
| 198 |
+
- Add spotlight border on hover
|
| 199 |
+
- Subtle inner glow effect
|
| 200 |
+
- Refined border-radius (keep `rounded-sm`)
|
| 201 |
+
|
| 202 |
+
### Badge Component
|
| 203 |
+
- Add subtle shimmer on accent badges
|
| 204 |
+
- Refine spacing and typography
|
| 205 |
+
- Consistent hover states
|
| 206 |
+
|
| 207 |
+
### Leaderboard Table
|
| 208 |
+
- Improve row hover transitions
|
| 209 |
+
- Add spotlight row highlighting
|
| 210 |
+
- Better column breathing room
|
| 211 |
+
- Refine sort indicator animations
|
| 212 |
+
|
| 213 |
+
---
|
| 214 |
+
|
| 215 |
+
## Accessibility Requirements
|
| 216 |
+
|
| 217 |
+
- Honor `prefers-reduced-motion` for all animations
|
| 218 |
+
- Maintain WCAG AA contrast ratios
|
| 219 |
+
- Keep focus-visible outlines
|
| 220 |
+
- Screen reader support for all interactive elements
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
## Pre-Flight Checklist
|
| 225 |
+
|
| 226 |
+
- [ ] All animations respect `prefers-reduced-motion`
|
| 227 |
+
- [ ] Spotlight effects work on interactive cards
|
| 228 |
+
- [ ] Grain texture is subtle (3-5% opacity max)
|
| 229 |
+
- [ ] No broken interactions
|
| 230 |
+
- [ ] Typography hierarchy is clear
|
| 231 |
+
- [ ] Light theme is consistent throughout (no stray dark surfaces)
|
next.config.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
import type { NextConfig } from "next";
|
| 2 |
|
| 3 |
const nextConfig: NextConfig = {
|
| 4 |
-
/
|
|
|
|
|
|
|
|
|
|
| 5 |
};
|
| 6 |
|
| 7 |
export default nextConfig;
|
|
|
|
| 1 |
import type { NextConfig } from "next";
|
| 2 |
|
| 3 |
const nextConfig: NextConfig = {
|
| 4 |
+
// Emit .next/standalone (server.js + traced node_modules) for a small
|
| 5 |
+
// self-contained Docker image — used by the Hugging Face Space runtime.
|
| 6 |
+
// Ref: node_modules/next/dist/docs/.../next-config-js/output.md
|
| 7 |
+
output: "standalone",
|
| 8 |
};
|
| 9 |
|
| 10 |
export default nextConfig;
|
package-lock.json
CHANGED
|
@@ -8,9 +8,13 @@
|
|
| 8 |
"name": "platform",
|
| 9 |
"version": "0.1.0",
|
| 10 |
"dependencies": {
|
|
|
|
|
|
|
|
|
|
| 11 |
"next": "16.2.9",
|
| 12 |
"react": "19.2.4",
|
| 13 |
-
"react-dom": "19.2.4"
|
|
|
|
| 14 |
},
|
| 15 |
"devDependencies": {
|
| 16 |
"@tailwindcss/postcss": "^4",
|
|
@@ -1246,6 +1250,42 @@
|
|
| 1246 |
"node": ">=12.4.0"
|
| 1247 |
}
|
| 1248 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1249 |
"node_modules/@rtsao/scc": {
|
| 1250 |
"version": "1.1.0",
|
| 1251 |
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
|
@@ -1253,6 +1293,18 @@
|
|
| 1253 |
"dev": true,
|
| 1254 |
"license": "MIT"
|
| 1255 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1256 |
"node_modules/@swc/helpers": {
|
| 1257 |
"version": "0.5.15",
|
| 1258 |
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
|
@@ -1544,6 +1596,69 @@
|
|
| 1544 |
"tslib": "^2.4.0"
|
| 1545 |
}
|
| 1546 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1547 |
"node_modules/@types/estree": {
|
| 1548 |
"version": "1.0.9",
|
| 1549 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
|
@@ -1579,7 +1694,7 @@
|
|
| 1579 |
"version": "19.2.17",
|
| 1580 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
| 1581 |
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
| 1582 |
-
"
|
| 1583 |
"license": "MIT",
|
| 1584 |
"dependencies": {
|
| 1585 |
"csstype": "^3.2.2"
|
|
@@ -1595,6 +1710,12 @@
|
|
| 1595 |
"@types/react": "^19.2.0"
|
| 1596 |
}
|
| 1597 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1598 |
"node_modules/@typescript-eslint/eslint-plugin": {
|
| 1599 |
"version": "8.61.0",
|
| 1600 |
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
|
@@ -2680,6 +2801,15 @@
|
|
| 2680 |
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
| 2681 |
"license": "MIT"
|
| 2682 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2683 |
"node_modules/color-convert": {
|
| 2684 |
"version": "2.0.1",
|
| 2685 |
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
@@ -2733,9 +2863,130 @@
|
|
| 2733 |
"version": "3.2.3",
|
| 2734 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 2735 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 2736 |
-
"
|
| 2737 |
"license": "MIT"
|
| 2738 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2739 |
"node_modules/damerau-levenshtein": {
|
| 2740 |
"version": "1.0.8",
|
| 2741 |
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
|
@@ -2815,6 +3066,12 @@
|
|
| 2815 |
}
|
| 2816 |
}
|
| 2817 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2818 |
"node_modules/deep-is": {
|
| 2819 |
"version": "0.1.4",
|
| 2820 |
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
|
@@ -3101,6 +3358,16 @@
|
|
| 3101 |
"url": "https://github.com/sponsors/ljharb"
|
| 3102 |
}
|
| 3103 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3104 |
"node_modules/escalade": {
|
| 3105 |
"version": "3.2.0",
|
| 3106 |
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
|
@@ -3530,6 +3797,12 @@
|
|
| 3530 |
"node": ">=0.10.0"
|
| 3531 |
}
|
| 3532 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3533 |
"node_modules/fast-deep-equal": {
|
| 3534 |
"version": "3.1.3",
|
| 3535 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
@@ -3671,6 +3944,33 @@
|
|
| 3671 |
"url": "https://github.com/sponsors/ljharb"
|
| 3672 |
}
|
| 3673 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3674 |
"node_modules/function-bind": {
|
| 3675 |
"version": "1.1.2",
|
| 3676 |
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
@@ -3986,6 +4286,16 @@
|
|
| 3986 |
"node": ">= 4"
|
| 3987 |
}
|
| 3988 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3989 |
"node_modules/import-fresh": {
|
| 3990 |
"version": "3.3.1",
|
| 3991 |
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
|
@@ -4028,6 +4338,15 @@
|
|
| 4028 |
"node": ">= 0.4"
|
| 4029 |
}
|
| 4030 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4031 |
"node_modules/is-array-buffer": {
|
| 4032 |
"version": "3.0.5",
|
| 4033 |
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
|
@@ -4929,6 +5248,15 @@
|
|
| 4929 |
"yallist": "^3.0.2"
|
| 4930 |
}
|
| 4931 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4932 |
"node_modules/magic-string": {
|
| 4933 |
"version": "0.30.21",
|
| 4934 |
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
|
@@ -4996,6 +5324,21 @@
|
|
| 4996 |
"url": "https://github.com/sponsors/ljharb"
|
| 4997 |
}
|
| 4998 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4999 |
"node_modules/ms": {
|
| 5000 |
"version": "2.1.3",
|
| 5001 |
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
@@ -5521,9 +5864,76 @@
|
|
| 5521 |
"version": "16.13.1",
|
| 5522 |
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
| 5523 |
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
| 5524 |
-
"dev": true,
|
| 5525 |
"license": "MIT"
|
| 5526 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5527 |
"node_modules/reflect.getprototypeof": {
|
| 5528 |
"version": "1.0.10",
|
| 5529 |
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
|
@@ -5568,6 +5978,12 @@
|
|
| 5568 |
"url": "https://github.com/sponsors/ljharb"
|
| 5569 |
}
|
| 5570 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5571 |
"node_modules/resolve": {
|
| 5572 |
"version": "2.0.0-next.7",
|
| 5573 |
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
|
@@ -6161,6 +6577,12 @@
|
|
| 6161 |
"url": "https://opencollective.com/webpack"
|
| 6162 |
}
|
| 6163 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6164 |
"node_modules/tinyglobby": {
|
| 6165 |
"version": "0.2.17",
|
| 6166 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
|
@@ -6501,6 +6923,37 @@
|
|
| 6501 |
"punycode": "^2.1.0"
|
| 6502 |
}
|
| 6503 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6504 |
"node_modules/which": {
|
| 6505 |
"version": "2.0.2",
|
| 6506 |
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
|
|
|
| 8 |
"name": "platform",
|
| 9 |
"version": "0.1.0",
|
| 10 |
"dependencies": {
|
| 11 |
+
"clsx": "^2.1.1",
|
| 12 |
+
"framer-motion": "^12.40.0",
|
| 13 |
+
"lucide-react": "^1.17.0",
|
| 14 |
"next": "16.2.9",
|
| 15 |
"react": "19.2.4",
|
| 16 |
+
"react-dom": "19.2.4",
|
| 17 |
+
"recharts": "^3.8.1"
|
| 18 |
},
|
| 19 |
"devDependencies": {
|
| 20 |
"@tailwindcss/postcss": "^4",
|
|
|
|
| 1250 |
"node": ">=12.4.0"
|
| 1251 |
}
|
| 1252 |
},
|
| 1253 |
+
"node_modules/@reduxjs/toolkit": {
|
| 1254 |
+
"version": "2.12.0",
|
| 1255 |
+
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
| 1256 |
+
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
| 1257 |
+
"license": "MIT",
|
| 1258 |
+
"dependencies": {
|
| 1259 |
+
"@standard-schema/spec": "^1.0.0",
|
| 1260 |
+
"@standard-schema/utils": "^0.3.0",
|
| 1261 |
+
"immer": "^11.0.0",
|
| 1262 |
+
"redux": "^5.0.1",
|
| 1263 |
+
"redux-thunk": "^3.1.0",
|
| 1264 |
+
"reselect": "^5.1.0"
|
| 1265 |
+
},
|
| 1266 |
+
"peerDependencies": {
|
| 1267 |
+
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
| 1268 |
+
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
| 1269 |
+
},
|
| 1270 |
+
"peerDependenciesMeta": {
|
| 1271 |
+
"react": {
|
| 1272 |
+
"optional": true
|
| 1273 |
+
},
|
| 1274 |
+
"react-redux": {
|
| 1275 |
+
"optional": true
|
| 1276 |
+
}
|
| 1277 |
+
}
|
| 1278 |
+
},
|
| 1279 |
+
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
| 1280 |
+
"version": "11.1.8",
|
| 1281 |
+
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
| 1282 |
+
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
| 1283 |
+
"license": "MIT",
|
| 1284 |
+
"funding": {
|
| 1285 |
+
"type": "opencollective",
|
| 1286 |
+
"url": "https://opencollective.com/immer"
|
| 1287 |
+
}
|
| 1288 |
+
},
|
| 1289 |
"node_modules/@rtsao/scc": {
|
| 1290 |
"version": "1.1.0",
|
| 1291 |
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
|
|
|
| 1293 |
"dev": true,
|
| 1294 |
"license": "MIT"
|
| 1295 |
},
|
| 1296 |
+
"node_modules/@standard-schema/spec": {
|
| 1297 |
+
"version": "1.1.0",
|
| 1298 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
| 1299 |
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
| 1300 |
+
"license": "MIT"
|
| 1301 |
+
},
|
| 1302 |
+
"node_modules/@standard-schema/utils": {
|
| 1303 |
+
"version": "0.3.0",
|
| 1304 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
| 1305 |
+
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
| 1306 |
+
"license": "MIT"
|
| 1307 |
+
},
|
| 1308 |
"node_modules/@swc/helpers": {
|
| 1309 |
"version": "0.5.15",
|
| 1310 |
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
|
|
|
| 1596 |
"tslib": "^2.4.0"
|
| 1597 |
}
|
| 1598 |
},
|
| 1599 |
+
"node_modules/@types/d3-array": {
|
| 1600 |
+
"version": "3.2.2",
|
| 1601 |
+
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
| 1602 |
+
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
| 1603 |
+
"license": "MIT"
|
| 1604 |
+
},
|
| 1605 |
+
"node_modules/@types/d3-color": {
|
| 1606 |
+
"version": "3.1.3",
|
| 1607 |
+
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
| 1608 |
+
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
| 1609 |
+
"license": "MIT"
|
| 1610 |
+
},
|
| 1611 |
+
"node_modules/@types/d3-ease": {
|
| 1612 |
+
"version": "3.0.2",
|
| 1613 |
+
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
| 1614 |
+
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
| 1615 |
+
"license": "MIT"
|
| 1616 |
+
},
|
| 1617 |
+
"node_modules/@types/d3-interpolate": {
|
| 1618 |
+
"version": "3.0.4",
|
| 1619 |
+
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
| 1620 |
+
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
| 1621 |
+
"license": "MIT",
|
| 1622 |
+
"dependencies": {
|
| 1623 |
+
"@types/d3-color": "*"
|
| 1624 |
+
}
|
| 1625 |
+
},
|
| 1626 |
+
"node_modules/@types/d3-path": {
|
| 1627 |
+
"version": "3.1.1",
|
| 1628 |
+
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
| 1629 |
+
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
| 1630 |
+
"license": "MIT"
|
| 1631 |
+
},
|
| 1632 |
+
"node_modules/@types/d3-scale": {
|
| 1633 |
+
"version": "4.0.9",
|
| 1634 |
+
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
| 1635 |
+
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
| 1636 |
+
"license": "MIT",
|
| 1637 |
+
"dependencies": {
|
| 1638 |
+
"@types/d3-time": "*"
|
| 1639 |
+
}
|
| 1640 |
+
},
|
| 1641 |
+
"node_modules/@types/d3-shape": {
|
| 1642 |
+
"version": "3.1.8",
|
| 1643 |
+
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
| 1644 |
+
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
| 1645 |
+
"license": "MIT",
|
| 1646 |
+
"dependencies": {
|
| 1647 |
+
"@types/d3-path": "*"
|
| 1648 |
+
}
|
| 1649 |
+
},
|
| 1650 |
+
"node_modules/@types/d3-time": {
|
| 1651 |
+
"version": "3.0.4",
|
| 1652 |
+
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
| 1653 |
+
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
| 1654 |
+
"license": "MIT"
|
| 1655 |
+
},
|
| 1656 |
+
"node_modules/@types/d3-timer": {
|
| 1657 |
+
"version": "3.0.2",
|
| 1658 |
+
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
| 1659 |
+
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
| 1660 |
+
"license": "MIT"
|
| 1661 |
+
},
|
| 1662 |
"node_modules/@types/estree": {
|
| 1663 |
"version": "1.0.9",
|
| 1664 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
|
|
|
| 1694 |
"version": "19.2.17",
|
| 1695 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
| 1696 |
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
| 1697 |
+
"devOptional": true,
|
| 1698 |
"license": "MIT",
|
| 1699 |
"dependencies": {
|
| 1700 |
"csstype": "^3.2.2"
|
|
|
|
| 1710 |
"@types/react": "^19.2.0"
|
| 1711 |
}
|
| 1712 |
},
|
| 1713 |
+
"node_modules/@types/use-sync-external-store": {
|
| 1714 |
+
"version": "0.0.6",
|
| 1715 |
+
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
| 1716 |
+
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
| 1717 |
+
"license": "MIT"
|
| 1718 |
+
},
|
| 1719 |
"node_modules/@typescript-eslint/eslint-plugin": {
|
| 1720 |
"version": "8.61.0",
|
| 1721 |
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
|
|
|
| 2801 |
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
| 2802 |
"license": "MIT"
|
| 2803 |
},
|
| 2804 |
+
"node_modules/clsx": {
|
| 2805 |
+
"version": "2.1.1",
|
| 2806 |
+
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
| 2807 |
+
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
| 2808 |
+
"license": "MIT",
|
| 2809 |
+
"engines": {
|
| 2810 |
+
"node": ">=6"
|
| 2811 |
+
}
|
| 2812 |
+
},
|
| 2813 |
"node_modules/color-convert": {
|
| 2814 |
"version": "2.0.1",
|
| 2815 |
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
|
|
| 2863 |
"version": "3.2.3",
|
| 2864 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 2865 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 2866 |
+
"devOptional": true,
|
| 2867 |
"license": "MIT"
|
| 2868 |
},
|
| 2869 |
+
"node_modules/d3-array": {
|
| 2870 |
+
"version": "3.2.4",
|
| 2871 |
+
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
| 2872 |
+
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
| 2873 |
+
"license": "ISC",
|
| 2874 |
+
"dependencies": {
|
| 2875 |
+
"internmap": "1 - 2"
|
| 2876 |
+
},
|
| 2877 |
+
"engines": {
|
| 2878 |
+
"node": ">=12"
|
| 2879 |
+
}
|
| 2880 |
+
},
|
| 2881 |
+
"node_modules/d3-color": {
|
| 2882 |
+
"version": "3.1.0",
|
| 2883 |
+
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
| 2884 |
+
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
| 2885 |
+
"license": "ISC",
|
| 2886 |
+
"engines": {
|
| 2887 |
+
"node": ">=12"
|
| 2888 |
+
}
|
| 2889 |
+
},
|
| 2890 |
+
"node_modules/d3-ease": {
|
| 2891 |
+
"version": "3.0.1",
|
| 2892 |
+
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
| 2893 |
+
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
| 2894 |
+
"license": "BSD-3-Clause",
|
| 2895 |
+
"engines": {
|
| 2896 |
+
"node": ">=12"
|
| 2897 |
+
}
|
| 2898 |
+
},
|
| 2899 |
+
"node_modules/d3-format": {
|
| 2900 |
+
"version": "3.1.2",
|
| 2901 |
+
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
| 2902 |
+
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
| 2903 |
+
"license": "ISC",
|
| 2904 |
+
"engines": {
|
| 2905 |
+
"node": ">=12"
|
| 2906 |
+
}
|
| 2907 |
+
},
|
| 2908 |
+
"node_modules/d3-interpolate": {
|
| 2909 |
+
"version": "3.0.1",
|
| 2910 |
+
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
| 2911 |
+
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
| 2912 |
+
"license": "ISC",
|
| 2913 |
+
"dependencies": {
|
| 2914 |
+
"d3-color": "1 - 3"
|
| 2915 |
+
},
|
| 2916 |
+
"engines": {
|
| 2917 |
+
"node": ">=12"
|
| 2918 |
+
}
|
| 2919 |
+
},
|
| 2920 |
+
"node_modules/d3-path": {
|
| 2921 |
+
"version": "3.1.0",
|
| 2922 |
+
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
| 2923 |
+
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
| 2924 |
+
"license": "ISC",
|
| 2925 |
+
"engines": {
|
| 2926 |
+
"node": ">=12"
|
| 2927 |
+
}
|
| 2928 |
+
},
|
| 2929 |
+
"node_modules/d3-scale": {
|
| 2930 |
+
"version": "4.0.2",
|
| 2931 |
+
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
| 2932 |
+
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
| 2933 |
+
"license": "ISC",
|
| 2934 |
+
"dependencies": {
|
| 2935 |
+
"d3-array": "2.10.0 - 3",
|
| 2936 |
+
"d3-format": "1 - 3",
|
| 2937 |
+
"d3-interpolate": "1.2.0 - 3",
|
| 2938 |
+
"d3-time": "2.1.1 - 3",
|
| 2939 |
+
"d3-time-format": "2 - 4"
|
| 2940 |
+
},
|
| 2941 |
+
"engines": {
|
| 2942 |
+
"node": ">=12"
|
| 2943 |
+
}
|
| 2944 |
+
},
|
| 2945 |
+
"node_modules/d3-shape": {
|
| 2946 |
+
"version": "3.2.0",
|
| 2947 |
+
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
| 2948 |
+
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
| 2949 |
+
"license": "ISC",
|
| 2950 |
+
"dependencies": {
|
| 2951 |
+
"d3-path": "^3.1.0"
|
| 2952 |
+
},
|
| 2953 |
+
"engines": {
|
| 2954 |
+
"node": ">=12"
|
| 2955 |
+
}
|
| 2956 |
+
},
|
| 2957 |
+
"node_modules/d3-time": {
|
| 2958 |
+
"version": "3.1.0",
|
| 2959 |
+
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
| 2960 |
+
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
| 2961 |
+
"license": "ISC",
|
| 2962 |
+
"dependencies": {
|
| 2963 |
+
"d3-array": "2 - 3"
|
| 2964 |
+
},
|
| 2965 |
+
"engines": {
|
| 2966 |
+
"node": ">=12"
|
| 2967 |
+
}
|
| 2968 |
+
},
|
| 2969 |
+
"node_modules/d3-time-format": {
|
| 2970 |
+
"version": "4.1.0",
|
| 2971 |
+
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
| 2972 |
+
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
| 2973 |
+
"license": "ISC",
|
| 2974 |
+
"dependencies": {
|
| 2975 |
+
"d3-time": "1 - 3"
|
| 2976 |
+
},
|
| 2977 |
+
"engines": {
|
| 2978 |
+
"node": ">=12"
|
| 2979 |
+
}
|
| 2980 |
+
},
|
| 2981 |
+
"node_modules/d3-timer": {
|
| 2982 |
+
"version": "3.0.1",
|
| 2983 |
+
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
| 2984 |
+
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
| 2985 |
+
"license": "ISC",
|
| 2986 |
+
"engines": {
|
| 2987 |
+
"node": ">=12"
|
| 2988 |
+
}
|
| 2989 |
+
},
|
| 2990 |
"node_modules/damerau-levenshtein": {
|
| 2991 |
"version": "1.0.8",
|
| 2992 |
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
|
|
|
| 3066 |
}
|
| 3067 |
}
|
| 3068 |
},
|
| 3069 |
+
"node_modules/decimal.js-light": {
|
| 3070 |
+
"version": "2.5.1",
|
| 3071 |
+
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
| 3072 |
+
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
| 3073 |
+
"license": "MIT"
|
| 3074 |
+
},
|
| 3075 |
"node_modules/deep-is": {
|
| 3076 |
"version": "0.1.4",
|
| 3077 |
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
|
|
|
| 3358 |
"url": "https://github.com/sponsors/ljharb"
|
| 3359 |
}
|
| 3360 |
},
|
| 3361 |
+
"node_modules/es-toolkit": {
|
| 3362 |
+
"version": "1.47.0",
|
| 3363 |
+
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz",
|
| 3364 |
+
"integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==",
|
| 3365 |
+
"license": "MIT",
|
| 3366 |
+
"workspaces": [
|
| 3367 |
+
"docs",
|
| 3368 |
+
"benchmarks"
|
| 3369 |
+
]
|
| 3370 |
+
},
|
| 3371 |
"node_modules/escalade": {
|
| 3372 |
"version": "3.2.0",
|
| 3373 |
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
|
|
|
| 3797 |
"node": ">=0.10.0"
|
| 3798 |
}
|
| 3799 |
},
|
| 3800 |
+
"node_modules/eventemitter3": {
|
| 3801 |
+
"version": "5.0.4",
|
| 3802 |
+
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
| 3803 |
+
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
| 3804 |
+
"license": "MIT"
|
| 3805 |
+
},
|
| 3806 |
"node_modules/fast-deep-equal": {
|
| 3807 |
"version": "3.1.3",
|
| 3808 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
|
|
| 3944 |
"url": "https://github.com/sponsors/ljharb"
|
| 3945 |
}
|
| 3946 |
},
|
| 3947 |
+
"node_modules/framer-motion": {
|
| 3948 |
+
"version": "12.40.0",
|
| 3949 |
+
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
|
| 3950 |
+
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
|
| 3951 |
+
"license": "MIT",
|
| 3952 |
+
"dependencies": {
|
| 3953 |
+
"motion-dom": "^12.40.0",
|
| 3954 |
+
"motion-utils": "^12.39.0",
|
| 3955 |
+
"tslib": "^2.4.0"
|
| 3956 |
+
},
|
| 3957 |
+
"peerDependencies": {
|
| 3958 |
+
"@emotion/is-prop-valid": "*",
|
| 3959 |
+
"react": "^18.0.0 || ^19.0.0",
|
| 3960 |
+
"react-dom": "^18.0.0 || ^19.0.0"
|
| 3961 |
+
},
|
| 3962 |
+
"peerDependenciesMeta": {
|
| 3963 |
+
"@emotion/is-prop-valid": {
|
| 3964 |
+
"optional": true
|
| 3965 |
+
},
|
| 3966 |
+
"react": {
|
| 3967 |
+
"optional": true
|
| 3968 |
+
},
|
| 3969 |
+
"react-dom": {
|
| 3970 |
+
"optional": true
|
| 3971 |
+
}
|
| 3972 |
+
}
|
| 3973 |
+
},
|
| 3974 |
"node_modules/function-bind": {
|
| 3975 |
"version": "1.1.2",
|
| 3976 |
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
|
|
| 4286 |
"node": ">= 4"
|
| 4287 |
}
|
| 4288 |
},
|
| 4289 |
+
"node_modules/immer": {
|
| 4290 |
+
"version": "10.2.0",
|
| 4291 |
+
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
| 4292 |
+
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
| 4293 |
+
"license": "MIT",
|
| 4294 |
+
"funding": {
|
| 4295 |
+
"type": "opencollective",
|
| 4296 |
+
"url": "https://opencollective.com/immer"
|
| 4297 |
+
}
|
| 4298 |
+
},
|
| 4299 |
"node_modules/import-fresh": {
|
| 4300 |
"version": "3.3.1",
|
| 4301 |
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
|
|
|
| 4338 |
"node": ">= 0.4"
|
| 4339 |
}
|
| 4340 |
},
|
| 4341 |
+
"node_modules/internmap": {
|
| 4342 |
+
"version": "2.0.3",
|
| 4343 |
+
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
| 4344 |
+
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
| 4345 |
+
"license": "ISC",
|
| 4346 |
+
"engines": {
|
| 4347 |
+
"node": ">=12"
|
| 4348 |
+
}
|
| 4349 |
+
},
|
| 4350 |
"node_modules/is-array-buffer": {
|
| 4351 |
"version": "3.0.5",
|
| 4352 |
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
|
|
|
| 5248 |
"yallist": "^3.0.2"
|
| 5249 |
}
|
| 5250 |
},
|
| 5251 |
+
"node_modules/lucide-react": {
|
| 5252 |
+
"version": "1.17.0",
|
| 5253 |
+
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
|
| 5254 |
+
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
|
| 5255 |
+
"license": "ISC",
|
| 5256 |
+
"peerDependencies": {
|
| 5257 |
+
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5258 |
+
}
|
| 5259 |
+
},
|
| 5260 |
"node_modules/magic-string": {
|
| 5261 |
"version": "0.30.21",
|
| 5262 |
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
|
|
|
| 5324 |
"url": "https://github.com/sponsors/ljharb"
|
| 5325 |
}
|
| 5326 |
},
|
| 5327 |
+
"node_modules/motion-dom": {
|
| 5328 |
+
"version": "12.40.0",
|
| 5329 |
+
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
|
| 5330 |
+
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
|
| 5331 |
+
"license": "MIT",
|
| 5332 |
+
"dependencies": {
|
| 5333 |
+
"motion-utils": "^12.39.0"
|
| 5334 |
+
}
|
| 5335 |
+
},
|
| 5336 |
+
"node_modules/motion-utils": {
|
| 5337 |
+
"version": "12.39.0",
|
| 5338 |
+
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
| 5339 |
+
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
| 5340 |
+
"license": "MIT"
|
| 5341 |
+
},
|
| 5342 |
"node_modules/ms": {
|
| 5343 |
"version": "2.1.3",
|
| 5344 |
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
|
|
| 5864 |
"version": "16.13.1",
|
| 5865 |
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
| 5866 |
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
|
|
|
| 5867 |
"license": "MIT"
|
| 5868 |
},
|
| 5869 |
+
"node_modules/react-redux": {
|
| 5870 |
+
"version": "9.3.0",
|
| 5871 |
+
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
| 5872 |
+
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
| 5873 |
+
"license": "MIT",
|
| 5874 |
+
"dependencies": {
|
| 5875 |
+
"@types/use-sync-external-store": "^0.0.6",
|
| 5876 |
+
"use-sync-external-store": "^1.4.0"
|
| 5877 |
+
},
|
| 5878 |
+
"peerDependencies": {
|
| 5879 |
+
"@types/react": "^18.2.25 || ^19",
|
| 5880 |
+
"react": "^18.0 || ^19",
|
| 5881 |
+
"redux": "^5.0.0"
|
| 5882 |
+
},
|
| 5883 |
+
"peerDependenciesMeta": {
|
| 5884 |
+
"@types/react": {
|
| 5885 |
+
"optional": true
|
| 5886 |
+
},
|
| 5887 |
+
"redux": {
|
| 5888 |
+
"optional": true
|
| 5889 |
+
}
|
| 5890 |
+
}
|
| 5891 |
+
},
|
| 5892 |
+
"node_modules/recharts": {
|
| 5893 |
+
"version": "3.8.1",
|
| 5894 |
+
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
| 5895 |
+
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
| 5896 |
+
"license": "MIT",
|
| 5897 |
+
"workspaces": [
|
| 5898 |
+
"www"
|
| 5899 |
+
],
|
| 5900 |
+
"dependencies": {
|
| 5901 |
+
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
| 5902 |
+
"clsx": "^2.1.1",
|
| 5903 |
+
"decimal.js-light": "^2.5.1",
|
| 5904 |
+
"es-toolkit": "^1.39.3",
|
| 5905 |
+
"eventemitter3": "^5.0.1",
|
| 5906 |
+
"immer": "^10.1.1",
|
| 5907 |
+
"react-redux": "8.x.x || 9.x.x",
|
| 5908 |
+
"reselect": "5.1.1",
|
| 5909 |
+
"tiny-invariant": "^1.3.3",
|
| 5910 |
+
"use-sync-external-store": "^1.2.2",
|
| 5911 |
+
"victory-vendor": "^37.0.2"
|
| 5912 |
+
},
|
| 5913 |
+
"engines": {
|
| 5914 |
+
"node": ">=18"
|
| 5915 |
+
},
|
| 5916 |
+
"peerDependencies": {
|
| 5917 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 5918 |
+
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 5919 |
+
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5920 |
+
}
|
| 5921 |
+
},
|
| 5922 |
+
"node_modules/redux": {
|
| 5923 |
+
"version": "5.0.1",
|
| 5924 |
+
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
| 5925 |
+
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
| 5926 |
+
"license": "MIT"
|
| 5927 |
+
},
|
| 5928 |
+
"node_modules/redux-thunk": {
|
| 5929 |
+
"version": "3.1.0",
|
| 5930 |
+
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
| 5931 |
+
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
| 5932 |
+
"license": "MIT",
|
| 5933 |
+
"peerDependencies": {
|
| 5934 |
+
"redux": "^5.0.0"
|
| 5935 |
+
}
|
| 5936 |
+
},
|
| 5937 |
"node_modules/reflect.getprototypeof": {
|
| 5938 |
"version": "1.0.10",
|
| 5939 |
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
|
|
|
| 5978 |
"url": "https://github.com/sponsors/ljharb"
|
| 5979 |
}
|
| 5980 |
},
|
| 5981 |
+
"node_modules/reselect": {
|
| 5982 |
+
"version": "5.1.1",
|
| 5983 |
+
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
| 5984 |
+
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
| 5985 |
+
"license": "MIT"
|
| 5986 |
+
},
|
| 5987 |
"node_modules/resolve": {
|
| 5988 |
"version": "2.0.0-next.7",
|
| 5989 |
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
|
|
|
| 6577 |
"url": "https://opencollective.com/webpack"
|
| 6578 |
}
|
| 6579 |
},
|
| 6580 |
+
"node_modules/tiny-invariant": {
|
| 6581 |
+
"version": "1.3.3",
|
| 6582 |
+
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
| 6583 |
+
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
| 6584 |
+
"license": "MIT"
|
| 6585 |
+
},
|
| 6586 |
"node_modules/tinyglobby": {
|
| 6587 |
"version": "0.2.17",
|
| 6588 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
|
|
|
| 6923 |
"punycode": "^2.1.0"
|
| 6924 |
}
|
| 6925 |
},
|
| 6926 |
+
"node_modules/use-sync-external-store": {
|
| 6927 |
+
"version": "1.6.0",
|
| 6928 |
+
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
| 6929 |
+
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
| 6930 |
+
"license": "MIT",
|
| 6931 |
+
"peerDependencies": {
|
| 6932 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 6933 |
+
}
|
| 6934 |
+
},
|
| 6935 |
+
"node_modules/victory-vendor": {
|
| 6936 |
+
"version": "37.3.6",
|
| 6937 |
+
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
| 6938 |
+
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
| 6939 |
+
"license": "MIT AND ISC",
|
| 6940 |
+
"dependencies": {
|
| 6941 |
+
"@types/d3-array": "^3.0.3",
|
| 6942 |
+
"@types/d3-ease": "^3.0.0",
|
| 6943 |
+
"@types/d3-interpolate": "^3.0.1",
|
| 6944 |
+
"@types/d3-scale": "^4.0.2",
|
| 6945 |
+
"@types/d3-shape": "^3.1.0",
|
| 6946 |
+
"@types/d3-time": "^3.0.0",
|
| 6947 |
+
"@types/d3-timer": "^3.0.0",
|
| 6948 |
+
"d3-array": "^3.1.6",
|
| 6949 |
+
"d3-ease": "^3.0.1",
|
| 6950 |
+
"d3-interpolate": "^3.0.1",
|
| 6951 |
+
"d3-scale": "^4.0.2",
|
| 6952 |
+
"d3-shape": "^3.1.0",
|
| 6953 |
+
"d3-time": "^3.0.0",
|
| 6954 |
+
"d3-timer": "^3.0.1"
|
| 6955 |
+
}
|
| 6956 |
+
},
|
| 6957 |
"node_modules/which": {
|
| 6958 |
"version": "2.0.2",
|
| 6959 |
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
package.json
CHANGED
|
@@ -6,12 +6,17 @@
|
|
| 6 |
"dev": "next dev",
|
| 7 |
"build": "next build",
|
| 8 |
"start": "next start",
|
| 9 |
-
"lint": "eslint"
|
|
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
|
|
|
|
|
|
|
|
|
| 12 |
"next": "16.2.9",
|
| 13 |
"react": "19.2.4",
|
| 14 |
-
"react-dom": "19.2.4"
|
|
|
|
| 15 |
},
|
| 16 |
"devDependencies": {
|
| 17 |
"@tailwindcss/postcss": "^4",
|
|
|
|
| 6 |
"dev": "next dev",
|
| 7 |
"build": "next build",
|
| 8 |
"start": "next start",
|
| 9 |
+
"lint": "eslint",
|
| 10 |
+
"sync-results": "node scripts/sync-results.mjs"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
+
"clsx": "^2.1.1",
|
| 14 |
+
"framer-motion": "^12.40.0",
|
| 15 |
+
"lucide-react": "^1.17.0",
|
| 16 |
"next": "16.2.9",
|
| 17 |
"react": "19.2.4",
|
| 18 |
+
"react-dom": "19.2.4",
|
| 19 |
+
"recharts": "^3.8.1"
|
| 20 |
},
|
| 21 |
"devDependencies": {
|
| 22 |
"@tailwindcss/postcss": "^4",
|
scripts/sync-results.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* sync-results.mjs — pull platform run outputs into src/data/results.json.
|
| 4 |
+
*
|
| 5 |
+
* Reads, for each known model and each split (test|hard):
|
| 6 |
+
* ../benchmark/results/<model>/<split>/metrics.json
|
| 7 |
+
* ../benchmark/results/<model>/<split>/run_manifest.json (optional, logged)
|
| 8 |
+
*
|
| 9 |
+
* metrics.json keys: wer, wer_ci95 [lo,hi], cer, cs_wer, cs_wer_ci95,
|
| 10 |
+
* n_wer, mtr_exact, mtr_fuzzy, rtfx_infer.
|
| 11 |
+
*
|
| 12 |
+
* Upserts the matching slug's metrics into the vimedcss entry of
|
| 13 |
+
* src/data/results.json (shape: { datasets: [{ info, models }] }) and sets
|
| 14 |
+
* provenance to "platform-verified". All other rows and fields are preserved.
|
| 15 |
+
*
|
| 16 |
+
* Usage: npm run sync-results
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
| 20 |
+
import { dirname, join, resolve } from "node:path";
|
| 21 |
+
import { fileURLToPath } from "node:url";
|
| 22 |
+
|
| 23 |
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 24 |
+
const PLATFORM_ROOT = resolve(__dirname, "..");
|
| 25 |
+
const RESULTS_ROOT = resolve(PLATFORM_ROOT, "..", "benchmark", "results");
|
| 26 |
+
const DATA_FILE = join(PLATFORM_ROOT, "src", "data", "results.json");
|
| 27 |
+
|
| 28 |
+
/** benchmark results dir name → leaderboard slug */
|
| 29 |
+
const MODEL_MAP = {
|
| 30 |
+
zipformer: "zipformer-30m",
|
| 31 |
+
qwen3asr: "qwen3-asr-0.6b",
|
| 32 |
+
gemini: "gemini-3.5-flash",
|
| 33 |
+
deepgram: "deepgram-nova-3",
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const SPLITS = ["test", "hard"];
|
| 37 |
+
|
| 38 |
+
/** Platform runs land on the anchor dataset's leaderboard. */
|
| 39 |
+
const TARGET_DATASET_ID = "vimedcss";
|
| 40 |
+
|
| 41 |
+
/** metrics.json key → SplitMetrics field */
|
| 42 |
+
function toSplitMetrics(raw) {
|
| 43 |
+
const out = {};
|
| 44 |
+
if (typeof raw.wer === "number") out.wer = raw.wer;
|
| 45 |
+
if (Array.isArray(raw.wer_ci95)) out.werCi = raw.wer_ci95;
|
| 46 |
+
if (typeof raw.cer === "number") out.cer = raw.cer;
|
| 47 |
+
if (typeof raw.cs_wer === "number") out.csWer = raw.cs_wer;
|
| 48 |
+
if (Array.isArray(raw.cs_wer_ci95)) out.csWerCi = raw.cs_wer_ci95;
|
| 49 |
+
if (typeof raw.n_wer === "number") out.nWer = raw.n_wer;
|
| 50 |
+
if (typeof raw.mtr_exact === "number") out.mtrExact = raw.mtr_exact;
|
| 51 |
+
if (typeof raw.mtr_fuzzy === "number") out.mtrFuzzy = raw.mtr_fuzzy;
|
| 52 |
+
if (typeof raw.rtfx_infer === "number") out.rtfx = raw.rtfx_infer;
|
| 53 |
+
return out;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function readJson(path) {
|
| 57 |
+
return JSON.parse(readFileSync(path, "utf8"));
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function main() {
|
| 61 |
+
if (!existsSync(RESULTS_ROOT)) {
|
| 62 |
+
console.log(`[sync-results] no results dir at ${RESULTS_ROOT} — nothing to do`);
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const data = readJson(DATA_FILE);
|
| 67 |
+
const entry = (data.datasets ?? []).find(
|
| 68 |
+
(d) => d.info?.id === TARGET_DATASET_ID,
|
| 69 |
+
);
|
| 70 |
+
if (!entry) {
|
| 71 |
+
console.error(
|
| 72 |
+
`[sync-results] dataset "${TARGET_DATASET_ID}" not found in ${DATA_FILE} — aborting`,
|
| 73 |
+
);
|
| 74 |
+
process.exitCode = 1;
|
| 75 |
+
return;
|
| 76 |
+
}
|
| 77 |
+
let updates = 0;
|
| 78 |
+
|
| 79 |
+
for (const [dir, slug] of Object.entries(MODEL_MAP)) {
|
| 80 |
+
const row = entry.models.find((m) => m.slug === slug);
|
| 81 |
+
if (!row) {
|
| 82 |
+
console.warn(`[sync-results] slug ${slug} not found in results.json — skipped`);
|
| 83 |
+
continue;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
for (const split of SPLITS) {
|
| 87 |
+
const metricsPath = join(RESULTS_ROOT, dir, split, "metrics.json");
|
| 88 |
+
if (!existsSync(metricsPath)) continue;
|
| 89 |
+
|
| 90 |
+
let raw;
|
| 91 |
+
try {
|
| 92 |
+
raw = readJson(metricsPath);
|
| 93 |
+
} catch (err) {
|
| 94 |
+
console.warn(`[sync-results] unreadable ${metricsPath}: ${err.message} — skipped`);
|
| 95 |
+
continue;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const manifestPath = join(RESULTS_ROOT, dir, split, "run_manifest.json");
|
| 99 |
+
let manifest;
|
| 100 |
+
if (existsSync(manifestPath)) {
|
| 101 |
+
try {
|
| 102 |
+
manifest = readJson(manifestPath);
|
| 103 |
+
console.log(
|
| 104 |
+
`[sync-results] ${slug}/${split} manifest:`,
|
| 105 |
+
JSON.stringify(manifest),
|
| 106 |
+
);
|
| 107 |
+
} catch {
|
| 108 |
+
console.warn(`[sync-results] unreadable manifest at ${manifestPath}`);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
row.metrics = row.metrics ?? {};
|
| 113 |
+
row.metrics[split] = toSplitMetrics(raw);
|
| 114 |
+
/* RTFx: the manifest computes audio_s / infer_s at run time and is the
|
| 115 |
+
authoritative timing record; metrics.json only snapshots it at score
|
| 116 |
+
time and can go stale across re-runs (observed on qwen3asr). API
|
| 117 |
+
runners report infer_total=0 -> manifest rtfx_infer null, so RTFx
|
| 118 |
+
stays absent (network latency ≠ local-hardware throughput). */
|
| 119 |
+
if (typeof manifest?.rtfx_infer === "number") {
|
| 120 |
+
row.metrics[split].rtfx = manifest.rtfx_infer;
|
| 121 |
+
}
|
| 122 |
+
/* Provenance: locally-run models are platform-verified (pinned
|
| 123 |
+
revision, our hardware, SOTA-eligible). Commercial APIs run through
|
| 124 |
+
the same harness but on the vendor's service with no pinned revision,
|
| 125 |
+
so they are recorded as a date-stamped "api-snapshot" instead. */
|
| 126 |
+
const isApi = row.category === "api-intl" || row.category === "api-vn";
|
| 127 |
+
row.provenance = isApi ? "api-snapshot" : "platform-verified";
|
| 128 |
+
updates += 1;
|
| 129 |
+
console.log(`[sync-results] upserted ${slug}/${split}`);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
if (updates === 0) {
|
| 134 |
+
console.log("[sync-results] no metrics found — results.json untouched");
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
writeFileSync(DATA_FILE, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
| 139 |
+
console.log(`[sync-results] wrote ${updates} split result(s) to ${DATA_FILE}`);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
main();
|
src/app/compare/loading.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Skeleton,
|
| 3 |
+
SkeletonTable,
|
| 4 |
+
SkeletonText,
|
| 5 |
+
} from "@/components/ui/LoadingSkeleton";
|
| 6 |
+
|
| 7 |
+
/* Route-level loading state for /compare — mirrors the page's structure
|
| 8 |
+
(heading + dataset chips, Pareto canvas, head-to-head card, efficiency
|
| 9 |
+
canvas) so content streams in without layout shift. Server-safe: all
|
| 10 |
+
shimmer comes from the .skeleton class, which collapses to a static
|
| 11 |
+
block under prefers-reduced-motion. */
|
| 12 |
+
|
| 13 |
+
function HeadingSkeleton({ titleWidth = 320 }: { titleWidth?: number }) {
|
| 14 |
+
return (
|
| 15 |
+
<div className="hairline-b flex flex-col gap-3 pb-6">
|
| 16 |
+
<Skeleton className="h-3" width={130} />
|
| 17 |
+
<Skeleton className="h-9" width={titleWidth} />
|
| 18 |
+
<SkeletonText className="max-w-2xl" lines={2} lastLineWidth="45%" />
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function ChartCardSkeleton({ height }: { height: number }) {
|
| 24 |
+
return (
|
| 25 |
+
<div className="glass depth-2 rounded-sm p-5">
|
| 26 |
+
{/* control row: legend chips left, toggle right */}
|
| 27 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 28 |
+
<div className="flex flex-wrap items-center gap-4">
|
| 29 |
+
{[72, 96, 64, 88].map((w, i) => (
|
| 30 |
+
<Skeleton key={i} className="h-3" width={w} />
|
| 31 |
+
))}
|
| 32 |
+
</div>
|
| 33 |
+
<Skeleton className="h-7" width={140} />
|
| 34 |
+
</div>
|
| 35 |
+
{/* chart canvas */}
|
| 36 |
+
<Skeleton className="mt-4 w-full" height={height} />
|
| 37 |
+
{/* caption */}
|
| 38 |
+
<div className="hairline-t mt-4 pt-3">
|
| 39 |
+
<Skeleton className="h-3.5" width="70%" />
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export default function CompareLoading() {
|
| 46 |
+
return (
|
| 47 |
+
<div
|
| 48 |
+
aria-busy="true"
|
| 49 |
+
aria-label="Loading model comparison"
|
| 50 |
+
className="flex flex-col gap-12 md:gap-16"
|
| 51 |
+
>
|
| 52 |
+
{/* 02 · Compare heading + dataset-scope chips */}
|
| 53 |
+
<div className="flex flex-col gap-5">
|
| 54 |
+
<HeadingSkeleton titleWidth={340} />
|
| 55 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 56 |
+
<Skeleton className="h-3" width={96} />
|
| 57 |
+
{[88, 76, 112].map((w, i) => (
|
| 58 |
+
<Skeleton key={i} className="h-6 rounded-sm" width={w} />
|
| 59 |
+
))}
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{/* pulse line strip */}
|
| 64 |
+
<Skeleton className="h-6 w-full" />
|
| 65 |
+
|
| 66 |
+
{/* Pareto frontier card */}
|
| 67 |
+
<ChartCardSkeleton height={440} />
|
| 68 |
+
|
| 69 |
+
{/* 02.1 · Head-to-head */}
|
| 70 |
+
<HeadingSkeleton titleWidth={300} />
|
| 71 |
+
<div className="glass depth-2 rounded-sm p-5">
|
| 72 |
+
{/* three model selectors */}
|
| 73 |
+
<div className="flex flex-wrap items-end gap-4">
|
| 74 |
+
{[0, 1, 2].map((i) => (
|
| 75 |
+
<div key={i} className="flex min-w-52 flex-1 flex-col gap-1.5">
|
| 76 |
+
<Skeleton className="h-3" width="45%" />
|
| 77 |
+
<Skeleton className="h-9 w-full" />
|
| 78 |
+
</div>
|
| 79 |
+
))}
|
| 80 |
+
</div>
|
| 81 |
+
{/* delta table */}
|
| 82 |
+
<SkeletonTable className="mt-5" rows={6} columns={4} />
|
| 83 |
+
{/* profile bars */}
|
| 84 |
+
<div className="mt-5 grid gap-x-10 gap-y-5 sm:grid-cols-2">
|
| 85 |
+
{[0, 1, 2, 3].map((i) => (
|
| 86 |
+
<div key={i} className="flex flex-col gap-2">
|
| 87 |
+
<Skeleton className="h-3" width="35%" />
|
| 88 |
+
<Skeleton className="h-1.5 w-full" />
|
| 89 |
+
<Skeleton className="h-1.5 w-4/5" />
|
| 90 |
+
</div>
|
| 91 |
+
))}
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* 02.2 · Efficiency */}
|
| 96 |
+
<HeadingSkeleton titleWidth={360} />
|
| 97 |
+
<ChartCardSkeleton height={360} />
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
}
|
src/app/compare/page.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Suspense } from "react";
|
| 3 |
+
import DatasetChips from "@/components/ui/DatasetChips";
|
| 4 |
+
import { Skeleton, SkeletonTable } from "@/components/ui/LoadingSkeleton";
|
| 5 |
+
import PulseLine from "@/components/ui/PulseLine";
|
| 6 |
+
import ScrollReveal from "@/components/ui/ScrollReveal";
|
| 7 |
+
import SectionHeading from "@/components/ui/SectionHeading";
|
| 8 |
+
import EfficiencySection from "@/components/compare/EfficiencySection";
|
| 9 |
+
import HeadToHead from "@/components/compare/HeadToHead";
|
| 10 |
+
import ParetoSection from "@/components/compare/ParetoSection";
|
| 11 |
+
import {
|
| 12 |
+
getDataset,
|
| 13 |
+
getDatasets,
|
| 14 |
+
getModels,
|
| 15 |
+
NORMALIZER_VERSION,
|
| 16 |
+
} from "@/lib/data";
|
| 17 |
+
|
| 18 |
+
export const metadata: Metadata = { title: "Compare" };
|
| 19 |
+
|
| 20 |
+
function HeadToHeadFallback() {
|
| 21 |
+
return (
|
| 22 |
+
<div
|
| 23 |
+
aria-busy="true"
|
| 24 |
+
aria-label="Loading head-to-head comparison"
|
| 25 |
+
className="glass depth-2 rounded-sm p-5"
|
| 26 |
+
>
|
| 27 |
+
<div className="flex flex-wrap items-end gap-4">
|
| 28 |
+
{[0, 1, 2].map((i) => (
|
| 29 |
+
<div key={i} className="flex min-w-52 flex-1 flex-col gap-1.5">
|
| 30 |
+
<Skeleton className="h-3" width="45%" />
|
| 31 |
+
<Skeleton className="h-9 w-full" />
|
| 32 |
+
</div>
|
| 33 |
+
))}
|
| 34 |
+
</div>
|
| 35 |
+
<SkeletonTable className="mt-5" rows={6} columns={4} />
|
| 36 |
+
</div>
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export default function ComparePage() {
|
| 41 |
+
const dataset = getDataset(); // anchor: ViMedCSS, the first live dataset
|
| 42 |
+
const datasets = getDatasets();
|
| 43 |
+
const models = getModels(dataset.id);
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="flex flex-col gap-12 md:gap-16">
|
| 47 |
+
{/* above the fold: spring-staggered stack */}
|
| 48 |
+
<div className="stagger-spring flex flex-col gap-12 md:gap-16">
|
| 49 |
+
<div className="flex flex-col gap-5">
|
| 50 |
+
<SectionHeading
|
| 51 |
+
as="h1"
|
| 52 |
+
eyebrow="02 · Compare"
|
| 53 |
+
title="The Pareto frontier"
|
| 54 |
+
sub={
|
| 55 |
+
<>
|
| 56 |
+
Overall WER against CS-WER — errors inside code-switched spans
|
| 57 |
+
— scoped to {dataset.name}, the hub’s first live dataset.
|
| 58 |
+
Lower-left wins. Bootstrap <span className="num">95%</span>{" "}
|
| 59 |
+
CIs; normalizer{" "}
|
| 60 |
+
<span className="num">{NORMALIZER_VERSION}</span>; WER is only
|
| 61 |
+
comparable within a normalizer version.
|
| 62 |
+
</>
|
| 63 |
+
}
|
| 64 |
+
/>
|
| 65 |
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
| 66 |
+
<span className="num text-[11px] uppercase tracking-[0.18em] text-muted">
|
| 67 |
+
Dataset scope
|
| 68 |
+
</span>
|
| 69 |
+
<DatasetChips datasets={datasets} activeId={dataset.id} />
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
<PulseLine compact />
|
| 73 |
+
<ParetoSection models={models} datasetName={dataset.name} />
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<ScrollReveal>
|
| 77 |
+
<SectionHeading
|
| 78 |
+
eyebrow="02.1 · Head-to-head"
|
| 79 |
+
title="Two models, read closely"
|
| 80 |
+
sub={`Pick a baseline and up to two challengers — all measured on ${dataset.name}. Deltas are challenger minus baseline — green improves on the baseline, red regresses. Models without measurements stay pickable and show their evidence gap.`}
|
| 81 |
+
/>
|
| 82 |
+
</ScrollReveal>
|
| 83 |
+
<ScrollReveal delay={0.08}>
|
| 84 |
+
<Suspense fallback={<HeadToHeadFallback />}>
|
| 85 |
+
<HeadToHead models={models} datasetName={dataset.name} />
|
| 86 |
+
</Suspense>
|
| 87 |
+
</ScrollReveal>
|
| 88 |
+
|
| 89 |
+
<ScrollReveal>
|
| 90 |
+
<SectionHeading
|
| 91 |
+
eyebrow="02.2 · Efficiency"
|
| 92 |
+
title="Accuracy against throughput"
|
| 93 |
+
sub={`RTFx — audio seconds per wall second on platform hardware — against WER on ${dataset.name}, with bubble area scaling by parameter count. Track E (sub-1B, CPU, under 8 GB VRAM) is config-ready.`}
|
| 94 |
+
/>
|
| 95 |
+
</ScrollReveal>
|
| 96 |
+
<ScrollReveal delay={0.08}>
|
| 97 |
+
<EfficiencySection models={models} datasetName={dataset.name} />
|
| 98 |
+
</ScrollReveal>
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
src/app/datasets/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import { notFound } from "next/navigation";
|
| 4 |
+
import { ArrowLeft, ArrowUpRight, ExternalLink } from "lucide-react";
|
| 5 |
+
import Badge from "@/components/ui/Badge";
|
| 6 |
+
import DatasetChips from "@/components/ui/DatasetChips";
|
| 7 |
+
import ScrollReveal from "@/components/ui/ScrollReveal";
|
| 8 |
+
import SectionHeading from "@/components/ui/SectionHeading";
|
| 9 |
+
import CautionCallout from "@/components/docs/CautionCallout";
|
| 10 |
+
import OnboardingCriteria from "@/components/docs/OnboardingCriteria";
|
| 11 |
+
import PipelineSteps from "@/components/docs/PipelineSteps";
|
| 12 |
+
import RoadmapStrip, { type RoadmapItem } from "@/components/docs/RoadmapStrip";
|
| 13 |
+
import SplitsTable from "@/components/docs/SplitsTable";
|
| 14 |
+
import StatTile from "@/components/docs/StatTile";
|
| 15 |
+
import TopicBars from "@/components/docs/TopicBars";
|
| 16 |
+
import { ANCHOR_DATASET_ID, fmt, getDatasets, getModels } from "@/lib/data";
|
| 17 |
+
import type { DatasetInfo } from "@/lib/types";
|
| 18 |
+
|
| 19 |
+
interface Props {
|
| 20 |
+
params: Promise<{ slug: string }>;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function generateStaticParams() {
|
| 24 |
+
return getDatasets().map((d) => ({ slug: d.id }));
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* The registry is closed (static JSON) — unknown slugs must be a real
|
| 28 |
+
HTTP 404, not a streamed 200 with not-found content. */
|
| 29 |
+
export const dynamicParams = false;
|
| 30 |
+
|
| 31 |
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
| 32 |
+
const { slug } = await params;
|
| 33 |
+
const dataset = getDatasets().find((d) => d.id === slug);
|
| 34 |
+
if (!dataset) return { title: "Dataset" };
|
| 35 |
+
return {
|
| 36 |
+
title:
|
| 37 |
+
dataset.status === "live"
|
| 38 |
+
? `${dataset.name} dataset`
|
| 39 |
+
: `${dataset.name} — onboarding`,
|
| 40 |
+
description: dataset.tagline,
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* ------------------------------------------------------------------ */
|
| 45 |
+
/* ViMedCSS editorial material — corpus-specific scholarship that has */
|
| 46 |
+
/* no home in the data API. Scoped to the vimedcss id; other live */
|
| 47 |
+
/* datasets simply skip these sections. */
|
| 48 |
+
/* ------------------------------------------------------------------ */
|
| 49 |
+
|
| 50 |
+
const VIMEDCSS_TOPICS = [
|
| 51 |
+
{ label: "Medical Sciences", vi: "Khoa học y khoa", hours: 16.3 },
|
| 52 |
+
{ label: "Pathology & Pathogens", vi: "Bệnh học & tác nhân gây bệnh", hours: 10.3 },
|
| 53 |
+
{ label: "Treatments", vi: "Điều trị", hours: 3.8 },
|
| 54 |
+
{ label: "Nutrition", vi: "Dinh dưỡng", hours: 2.1 },
|
| 55 |
+
{ label: "Diagnostics", vi: "Chẩn đoán", hours: 2.0 },
|
| 56 |
+
] as const;
|
| 57 |
+
|
| 58 |
+
const VIMEDCSS_PIPELINE = [
|
| 59 |
+
{
|
| 60 |
+
title: "YouTube crawl",
|
| 61 |
+
stat: "700+ hours",
|
| 62 |
+
detail:
|
| 63 |
+
"Vietnamese medical channels crawled and filtered down to clean lecture-style audio, then segmented into 2–21 second utterances.",
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
title: "Meddict lexicon match",
|
| 67 |
+
stat: "64,232 entries",
|
| 68 |
+
detail:
|
| 69 |
+
"Candidate English medical terms located against the 64,232-entry Meddict bilingual medical lexicon to anchor code-switched spans.",
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
title: "LLM label seeding",
|
| 73 |
+
stat: "Gemini 2.5 Pro",
|
| 74 |
+
detail:
|
| 75 |
+
"First-pass transcripts and span labels drafted by Gemini 2.5 Pro — the origin of the permanent contamination flag on Gemini-family rows.",
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
title: "Levenshtein canonicalization",
|
| 79 |
+
stat: "889 canonical terms",
|
| 80 |
+
detail:
|
| 81 |
+
"Surface variants of the same term collapsed to canonical forms by edit-distance matching, yielding 889 distinct code-switched medical terms.",
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
title: "Human verification",
|
| 85 |
+
stat: "κ = 0.65",
|
| 86 |
+
detail:
|
| 87 |
+
"Annotators verified every label; inter-annotator agreement κ = 0.65. Verified — but Gemini-seeded. See the caution below.",
|
| 88 |
+
},
|
| 89 |
+
];
|
| 90 |
+
|
| 91 |
+
/** Accent-tinted chip marking a code-switched English medical term. */
|
| 92 |
+
function CS({ children }: { children: React.ReactNode }) {
|
| 93 |
+
return (
|
| 94 |
+
<span
|
| 95 |
+
lang="en"
|
| 96 |
+
className="rounded-sm border border-accent/25 bg-accent-dim px-1 py-px text-accent"
|
| 97 |
+
>
|
| 98 |
+
{children}
|
| 99 |
+
</span>
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* ------------------------------------------------------------------ */
|
| 104 |
+
/* Shared bits */
|
| 105 |
+
/* ------------------------------------------------------------------ */
|
| 106 |
+
|
| 107 |
+
const chipLink =
|
| 108 |
+
"num inline-flex items-center gap-1.5 rounded-sm border border-line px-2 py-0.5 text-[11px] leading-4 tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
| 109 |
+
|
| 110 |
+
const actionLink =
|
| 111 |
+
"num inline-flex items-center gap-1.5 rounded-sm border border-line px-3 py-1.5 text-[12px] tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
| 112 |
+
|
| 113 |
+
const ROADMAP_ROLE: Record<string, RoadmapItem["status"]> = {
|
| 114 |
+
[ANCHOR_DATASET_ID]: "live",
|
| 115 |
+
vietmed: "next",
|
| 116 |
+
"private-clinic": "north-star",
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
function buildRoadmap(): RoadmapItem[] {
|
| 120 |
+
return getDatasets().map((d) => ({
|
| 121 |
+
name: d.name,
|
| 122 |
+
status:
|
| 123 |
+
d.status === "live" ? "live" : (ROADMAP_ROLE[d.id] ?? "next"),
|
| 124 |
+
stat:
|
| 125 |
+
d.status === "live" && d.hours !== undefined
|
| 126 |
+
? `${fmt(d.hours)} h`
|
| 127 |
+
: undefined,
|
| 128 |
+
detail: d.tagline,
|
| 129 |
+
}));
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function RegistryStrip({ activeId }: { activeId: string }) {
|
| 133 |
+
return (
|
| 134 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 135 |
+
<Link
|
| 136 |
+
href="/datasets"
|
| 137 |
+
className="num inline-flex items-center gap-1.5 rounded-sm text-[12px] tracking-wide text-muted transition-colors duration-200 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
| 138 |
+
>
|
| 139 |
+
<ArrowLeft size={13} aria-hidden />
|
| 140 |
+
All datasets
|
| 141 |
+
</Link>
|
| 142 |
+
<DatasetChips datasets={getDatasets()} activeId={activeId} />
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/* ------------------------------------------------------------------ */
|
| 148 |
+
/* Live dataset profile */
|
| 149 |
+
/* ------------------------------------------------------------------ */
|
| 150 |
+
|
| 151 |
+
function LiveProfile({ dataset }: { dataset: DatasetInfo }) {
|
| 152 |
+
const isViMedCSS = dataset.id === "vimedcss";
|
| 153 |
+
const systems = getModels(dataset.id).length;
|
| 154 |
+
|
| 155 |
+
/* sub-section numbering stays dense even when editorial sections are
|
| 156 |
+
absent on future live datasets */
|
| 157 |
+
let n = 0;
|
| 158 |
+
const sec = () => `03.${++n}`;
|
| 159 |
+
|
| 160 |
+
return (
|
| 161 |
+
<div className="spring-up flex flex-col gap-12 md:gap-16">
|
| 162 |
+
{/* 1 · Header */}
|
| 163 |
+
<header className="hairline-b flex flex-col gap-6 pb-6">
|
| 164 |
+
<RegistryStrip activeId={dataset.id} />
|
| 165 |
+
<div>
|
| 166 |
+
<p className="num mb-2 text-[11px] uppercase tracking-[0.22em] text-muted">
|
| 167 |
+
03 · Dataset profile
|
| 168 |
+
</p>
|
| 169 |
+
<h1 className="font-display text-4xl leading-tight text-text md:text-5xl">
|
| 170 |
+
{dataset.name}
|
| 171 |
+
{dataset.fullName && (
|
| 172 |
+
<span className="mt-1 block font-display text-xl italic text-muted md:text-2xl">
|
| 173 |
+
{dataset.fullName}
|
| 174 |
+
</span>
|
| 175 |
+
)}
|
| 176 |
+
</h1>
|
| 177 |
+
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted md:text-[15px]">
|
| 178 |
+
{dataset.tagline}
|
| 179 |
+
{dataset.id === ANCHOR_DATASET_ID && (
|
| 180 |
+
<> The anchor benchmark of MedASR Bench — its first live dataset.</>
|
| 181 |
+
)}
|
| 182 |
+
</p>
|
| 183 |
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
| 184 |
+
<Badge variant="accent">
|
| 185 |
+
<span
|
| 186 |
+
aria-hidden
|
| 187 |
+
className="pulse-dot inline-block size-1.5 rounded-full bg-accent"
|
| 188 |
+
/>
|
| 189 |
+
Live · {systems} systems
|
| 190 |
+
</Badge>
|
| 191 |
+
{dataset.license && (
|
| 192 |
+
<Badge variant="outline" title="License">
|
| 193 |
+
{dataset.license}
|
| 194 |
+
</Badge>
|
| 195 |
+
)}
|
| 196 |
+
{dataset.arxiv && (
|
| 197 |
+
<a
|
| 198 |
+
href={`https://arxiv.org/abs/${dataset.arxiv}`}
|
| 199 |
+
target="_blank"
|
| 200 |
+
rel="noreferrer"
|
| 201 |
+
className={chipLink}
|
| 202 |
+
>
|
| 203 |
+
arXiv:{dataset.arxiv}
|
| 204 |
+
<ExternalLink size={12} aria-hidden />
|
| 205 |
+
</a>
|
| 206 |
+
)}
|
| 207 |
+
{dataset.hf && (
|
| 208 |
+
<a
|
| 209 |
+
href={`https://huggingface.co/datasets/${dataset.hf}`}
|
| 210 |
+
target="_blank"
|
| 211 |
+
rel="noreferrer"
|
| 212 |
+
className={chipLink}
|
| 213 |
+
>
|
| 214 |
+
HF · {dataset.hf}
|
| 215 |
+
<ExternalLink size={12} aria-hidden />
|
| 216 |
+
</a>
|
| 217 |
+
)}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</header>
|
| 221 |
+
|
| 222 |
+
{/* 2 · Stat strip */}
|
| 223 |
+
<section
|
| 224 |
+
aria-label="Corpus at a glance"
|
| 225 |
+
className="grid grid-cols-2 gap-3 md:grid-cols-4 stagger-spring"
|
| 226 |
+
>
|
| 227 |
+
{dataset.hours !== undefined && (
|
| 228 |
+
<StatTile label="Audio" value={fmt(dataset.hours)} suffix="h" />
|
| 229 |
+
)}
|
| 230 |
+
{dataset.utterances !== undefined && (
|
| 231 |
+
<StatTile
|
| 232 |
+
label="Utterances"
|
| 233 |
+
value={dataset.utterances.toLocaleString("en-US")}
|
| 234 |
+
/>
|
| 235 |
+
)}
|
| 236 |
+
{dataset.csTerms !== undefined && (
|
| 237 |
+
<StatTile
|
| 238 |
+
label="CS medical terms"
|
| 239 |
+
value={dataset.csTerms.toLocaleString("en-US")}
|
| 240 |
+
sub="distinct English terms"
|
| 241 |
+
/>
|
| 242 |
+
)}
|
| 243 |
+
{isViMedCSS && <StatTile label="Clip length" value="2–21" suffix="s" />}
|
| 244 |
+
</section>
|
| 245 |
+
|
| 246 |
+
{/* Specimen — ViMedCSS editorial */}
|
| 247 |
+
{isViMedCSS && (
|
| 248 |
+
<section className="flex flex-col gap-5">
|
| 249 |
+
<SectionHeading
|
| 250 |
+
eyebrow={`${sec()} · Specimen`}
|
| 251 |
+
title="What an utterance looks like"
|
| 252 |
+
sub="Two transcript lines from the corpus — Vietnamese clinical speech with the code-switched English terms highlighted, exactly the spans CS-WER scores."
|
| 253 |
+
/>
|
| 254 |
+
<figure className="hairline depth-1 rounded-sm bg-surface px-5 py-6 md:px-6">
|
| 255 |
+
<blockquote
|
| 256 |
+
lang="vi"
|
| 257 |
+
className="flex flex-col gap-5 text-lg leading-[1.8] text-text md:text-xl"
|
| 258 |
+
>
|
| 259 |
+
<p>
|
| 260 |
+
Bệnh nhân có tiền sử tăng huyết áp, chỉ định siêu âm{" "}
|
| 261 |
+
<CS>Doppler</CS> động mạch cảnh.
|
| 262 |
+
</p>
|
| 263 |
+
<p>
|
| 264 |
+
Kết quả <CS>MRI</CS> cho thấy tổn thương chất trắng lan tỏa, cần
|
| 265 |
+
theo dõi <CS>multiple sclerosis</CS>.
|
| 266 |
+
</p>
|
| 267 |
+
</blockquote>
|
| 268 |
+
<figcaption className="num mt-5 border-t border-line pt-3 text-[11px] tracking-wide text-muted uppercase">
|
| 269 |
+
Lecture-style utterances, 2–21 s ·{" "}
|
| 270 |
+
<span className="text-accent">highlighted</span> = code-switched
|
| 271 |
+
English medical term
|
| 272 |
+
{dataset.csTerms !== undefined && <> ({dataset.csTerms} distinct)</>} ·
|
| 273 |
+
scored by CS-WER
|
| 274 |
+
</figcaption>
|
| 275 |
+
</figure>
|
| 276 |
+
</section>
|
| 277 |
+
)}
|
| 278 |
+
|
| 279 |
+
{/* Splits — any live dataset with frozen splits */}
|
| 280 |
+
{dataset.splits && (
|
| 281 |
+
<section className="flex flex-col gap-5">
|
| 282 |
+
<SectionHeading
|
| 283 |
+
eyebrow={`${sec()} · Splits`}
|
| 284 |
+
title="Four splits, two leaderboards"
|
| 285 |
+
sub="Test is the primary leaderboard. Hard isolates rare and unseen terms — when a model ranks well on Test but slides on Hard, it memorized the vocabulary instead of learning it."
|
| 286 |
+
/>
|
| 287 |
+
<SplitsTable splits={dataset.splits} datasetName={dataset.name} />
|
| 288 |
+
</section>
|
| 289 |
+
)}
|
| 290 |
+
|
| 291 |
+
{/* Topics — ViMedCSS editorial */}
|
| 292 |
+
{isViMedCSS && (
|
| 293 |
+
<ScrollReveal>
|
| 294 |
+
<section className="flex flex-col gap-5">
|
| 295 |
+
<SectionHeading
|
| 296 |
+
eyebrow={`${sec()} · Topic distribution`}
|
| 297 |
+
title="Where the hours live"
|
| 298 |
+
sub="Five clinical topic areas; bar lengths are relative to the largest topic."
|
| 299 |
+
/>
|
| 300 |
+
<TopicBars items={VIMEDCSS_TOPICS} />
|
| 301 |
+
</section>
|
| 302 |
+
</ScrollReveal>
|
| 303 |
+
)}
|
| 304 |
+
|
| 305 |
+
{/* Construction — ViMedCSS editorial */}
|
| 306 |
+
{isViMedCSS && (
|
| 307 |
+
<ScrollReveal>
|
| 308 |
+
<section className="flex flex-col gap-6">
|
| 309 |
+
<SectionHeading
|
| 310 |
+
eyebrow={`${sec()} · Construction`}
|
| 311 |
+
title="How the corpus was built"
|
| 312 |
+
sub="Five stages from raw crawl to verified labels. Stage three is why the contamination policy exists."
|
| 313 |
+
/>
|
| 314 |
+
<PipelineSteps steps={VIMEDCSS_PIPELINE} />
|
| 315 |
+
</section>
|
| 316 |
+
</ScrollReveal>
|
| 317 |
+
)}
|
| 318 |
+
|
| 319 |
+
{/* Validity — ViMedCSS editorial */}
|
| 320 |
+
{isViMedCSS && (
|
| 321 |
+
<ScrollReveal>
|
| 322 |
+
<section className="flex flex-col gap-5">
|
| 323 |
+
<SectionHeading
|
| 324 |
+
eyebrow={`${sec()} · Validity`}
|
| 325 |
+
title="Read these before citing a number"
|
| 326 |
+
sub="Two caveats bound what ViMedCSS results can and cannot claim."
|
| 327 |
+
/>
|
| 328 |
+
<div className="grid gap-4 lg:grid-cols-2">
|
| 329 |
+
<CautionCallout
|
| 330 |
+
eyebrow="Caution · Label provenance"
|
| 331 |
+
title="Gemini seeded these labels"
|
| 332 |
+
>
|
| 333 |
+
<p>
|
| 334 |
+
Transcripts and code-switch spans were first drafted by Gemini
|
| 335 |
+
2.5 Pro, then human-verified at{" "}
|
| 336 |
+
<span className="num text-text">κ = 0.65</span>. Gemini-family
|
| 337 |
+
models are therefore structurally advantaged on this benchmark
|
| 338 |
+
and carry a{" "}
|
| 339 |
+
<strong className="font-medium text-warn">
|
| 340 |
+
permanent contamination flag
|
| 341 |
+
</strong>{" "}
|
| 342 |
+
on every ViMedCSS leaderboard, regardless of score.
|
| 343 |
+
</p>
|
| 344 |
+
<p>
|
| 345 |
+
Test labels are public, so provenance declarations are
|
| 346 |
+
honor-system today. A private canary subset is on the roadmap
|
| 347 |
+
to detect test-set training.
|
| 348 |
+
</p>
|
| 349 |
+
</CautionCallout>
|
| 350 |
+
<CautionCallout
|
| 351 |
+
eyebrow="Caution · Domain mismatch"
|
| 352 |
+
title="Lecture audio is not a clinic"
|
| 353 |
+
>
|
| 354 |
+
<p>
|
| 355 |
+
ViMedCSS is lecture-style YouTube audio: clean microphones,
|
| 356 |
+
composed delivery, one speaker. The production setting —
|
| 357 |
+
noisy, two-speaker clinic conversation — is materially harder.
|
| 358 |
+
</p>
|
| 359 |
+
<p>
|
| 360 |
+
Never read a ViMedCSS score as progress against the production{" "}
|
| 361 |
+
<span className="num text-text">WER < 15%</span> deployment
|
| 362 |
+
gate. That gate is scored on conversational clinic data (Track
|
| 363 |
+
F, roadmap).
|
| 364 |
+
</p>
|
| 365 |
+
</CautionCallout>
|
| 366 |
+
</div>
|
| 367 |
+
</section>
|
| 368 |
+
</ScrollReveal>
|
| 369 |
+
)}
|
| 370 |
+
|
| 371 |
+
{/* Hub roadmap — generic, derived from the registry */}
|
| 372 |
+
<ScrollReveal>
|
| 373 |
+
<section className="flex flex-col gap-5">
|
| 374 |
+
<SectionHeading
|
| 375 |
+
eyebrow={`${sec()} · The hub`}
|
| 376 |
+
title="One dataset of several"
|
| 377 |
+
sub="MedASR Bench is multi-dataset by construction — every corpus mounts into the same harness, normalizer, and provenance rules."
|
| 378 |
+
/>
|
| 379 |
+
<RoadmapStrip items={buildRoadmap()} />
|
| 380 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 381 |
+
<Link href="/datasets" className={actionLink}>
|
| 382 |
+
<ArrowLeft size={13} aria-hidden />
|
| 383 |
+
All datasets
|
| 384 |
+
</Link>
|
| 385 |
+
<Link href="/methodology#onboarding" className={actionLink}>
|
| 386 |
+
Onboarding criteria
|
| 387 |
+
<ArrowUpRight size={13} aria-hidden />
|
| 388 |
+
</Link>
|
| 389 |
+
</div>
|
| 390 |
+
</section>
|
| 391 |
+
</ScrollReveal>
|
| 392 |
+
</div>
|
| 393 |
+
);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
/* ------------------------------------------------------------------ */
|
| 397 |
+
/* Coming-soon teaser */
|
| 398 |
+
/* ------------------------------------------------------------------ */
|
| 399 |
+
|
| 400 |
+
const LAUNCH_STEPS = [
|
| 401 |
+
{
|
| 402 |
+
title: "Profile published",
|
| 403 |
+
detail:
|
| 404 |
+
"Provenance, license, and split inventory go live on this page — frozen before any system is scored.",
|
| 405 |
+
},
|
| 406 |
+
{
|
| 407 |
+
title: "Baselines re-run",
|
| 408 |
+
detail:
|
| 409 |
+
"The platform harness runs the existing leaderboard systems against the new corpus, manifests and all.",
|
| 410 |
+
},
|
| 411 |
+
{
|
| 412 |
+
title: "Leaderboard opens",
|
| 413 |
+
detail:
|
| 414 |
+
"Six metrics with bootstrap CIs and provenance chips from day one — comparable with every other dataset on the hub.",
|
| 415 |
+
},
|
| 416 |
+
];
|
| 417 |
+
|
| 418 |
+
function ComingSoonProfile({ dataset }: { dataset: DatasetInfo }) {
|
| 419 |
+
return (
|
| 420 |
+
<div className="spring-up flex flex-col gap-12 md:gap-14">
|
| 421 |
+
{/* Header */}
|
| 422 |
+
<header className="hairline-b flex flex-col gap-6 pb-6">
|
| 423 |
+
<RegistryStrip activeId={dataset.id} />
|
| 424 |
+
<div>
|
| 425 |
+
<p className="num mb-2 text-[11px] uppercase tracking-[0.22em] text-muted">
|
| 426 |
+
03 · Dataset profile
|
| 427 |
+
</p>
|
| 428 |
+
<h1 className="font-display text-4xl leading-tight text-text md:text-5xl">
|
| 429 |
+
{dataset.name}
|
| 430 |
+
</h1>
|
| 431 |
+
<p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-muted">
|
| 432 |
+
{dataset.tagline}
|
| 433 |
+
</p>
|
| 434 |
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
| 435 |
+
<Badge variant="dashed">Onboarding</Badge>
|
| 436 |
+
{dataset.eta && (
|
| 437 |
+
<Badge variant="outline" title="Expected availability">
|
| 438 |
+
ETA {dataset.eta}
|
| 439 |
+
</Badge>
|
| 440 |
+
)}
|
| 441 |
+
{dataset.license && (
|
| 442 |
+
<Badge variant="outline" title="License">
|
| 443 |
+
{dataset.license}
|
| 444 |
+
</Badge>
|
| 445 |
+
)}
|
| 446 |
+
</div>
|
| 447 |
+
</div>
|
| 448 |
+
</header>
|
| 449 |
+
|
| 450 |
+
{/* What's known today */}
|
| 451 |
+
<section className="flex flex-col gap-5">
|
| 452 |
+
<SectionHeading
|
| 453 |
+
eyebrow="03.1 · Status"
|
| 454 |
+
title="What is known today"
|
| 455 |
+
sub="No numbers are published before the corpus is frozen — this page carries identity only, never preliminary stats."
|
| 456 |
+
/>
|
| 457 |
+
<dl className="hairline depth-1 grid gap-px overflow-hidden rounded-sm bg-line sm:grid-cols-3">
|
| 458 |
+
{[
|
| 459 |
+
{ dt: "Status", dd: "Onboarding — not yet runnable" },
|
| 460 |
+
{ dt: "Source", dd: dataset.source ?? "To be announced" },
|
| 461 |
+
{
|
| 462 |
+
dt: "Leaderboard",
|
| 463 |
+
dd: "Opens after the first platform-verified run",
|
| 464 |
+
},
|
| 465 |
+
].map((row) => (
|
| 466 |
+
<div key={row.dt} className="flex flex-col gap-1 bg-surface px-4 py-3.5">
|
| 467 |
+
<dt className="text-[11px] uppercase tracking-[0.14em] text-muted">
|
| 468 |
+
{row.dt}
|
| 469 |
+
</dt>
|
| 470 |
+
<dd className="text-[13px] leading-relaxed text-text">{row.dd}</dd>
|
| 471 |
+
</div>
|
| 472 |
+
))}
|
| 473 |
+
</dl>
|
| 474 |
+
</section>
|
| 475 |
+
|
| 476 |
+
{/* Onboarding criteria */}
|
| 477 |
+
<ScrollReveal>
|
| 478 |
+
<section className="flex flex-col gap-5">
|
| 479 |
+
<SectionHeading
|
| 480 |
+
eyebrow="03.2 · Onboarding"
|
| 481 |
+
title="What onboarding involves"
|
| 482 |
+
sub="Six criteria every dataset clears before its first leaderboard — the same bar ViMedCSS cleared."
|
| 483 |
+
/>
|
| 484 |
+
<OnboardingCriteria />
|
| 485 |
+
<Link
|
| 486 |
+
href="/methodology#onboarding"
|
| 487 |
+
className={`${actionLink} self-start`}
|
| 488 |
+
>
|
| 489 |
+
Full criteria & versioning policy
|
| 490 |
+
<ArrowUpRight size={13} aria-hidden />
|
| 491 |
+
</Link>
|
| 492 |
+
</section>
|
| 493 |
+
</ScrollReveal>
|
| 494 |
+
|
| 495 |
+
{/* Launch sequence */}
|
| 496 |
+
<ScrollReveal>
|
| 497 |
+
<section className="flex flex-col gap-5">
|
| 498 |
+
<SectionHeading
|
| 499 |
+
eyebrow="03.3 · At launch"
|
| 500 |
+
title="What happens when it goes live"
|
| 501 |
+
sub="The same sequence every dataset follows — nothing is scored before the freeze."
|
| 502 |
+
/>
|
| 503 |
+
<ol className="grid gap-3 md:grid-cols-3 stagger-spring">
|
| 504 |
+
{LAUNCH_STEPS.map((step, i) => (
|
| 505 |
+
<li
|
| 506 |
+
key={step.title}
|
| 507 |
+
className="hairline depth-1 flex flex-col gap-2 rounded-sm bg-surface p-5"
|
| 508 |
+
>
|
| 509 |
+
<span aria-hidden className="num text-[12px] text-muted">
|
| 510 |
+
{String(i + 1).padStart(2, "0")}
|
| 511 |
+
</span>
|
| 512 |
+
<h3 className="text-sm font-medium text-text">{step.title}</h3>
|
| 513 |
+
<p className="text-[13px] leading-relaxed text-muted">
|
| 514 |
+
{step.detail}
|
| 515 |
+
</p>
|
| 516 |
+
</li>
|
| 517 |
+
))}
|
| 518 |
+
</ol>
|
| 519 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 520 |
+
<Link href="/datasets" className={actionLink}>
|
| 521 |
+
<ArrowLeft size={13} aria-hidden />
|
| 522 |
+
All datasets
|
| 523 |
+
</Link>
|
| 524 |
+
<Link href={`/datasets/${ANCHOR_DATASET_ID}`} className={actionLink}>
|
| 525 |
+
See the live anchor dataset
|
| 526 |
+
</Link>
|
| 527 |
+
</div>
|
| 528 |
+
</section>
|
| 529 |
+
</ScrollReveal>
|
| 530 |
+
</div>
|
| 531 |
+
);
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
/* ------------------------------------------------------------------ */
|
| 535 |
+
/* Route */
|
| 536 |
+
/* ------------------------------------------------------------------ */
|
| 537 |
+
|
| 538 |
+
export default async function DatasetPage({ params }: Props) {
|
| 539 |
+
const { slug } = await params;
|
| 540 |
+
const dataset = getDatasets().find((d) => d.id === slug);
|
| 541 |
+
if (!dataset) notFound();
|
| 542 |
+
|
| 543 |
+
return dataset.status === "live" ? (
|
| 544 |
+
<LiveProfile dataset={dataset} />
|
| 545 |
+
) : (
|
| 546 |
+
<ComingSoonProfile dataset={dataset} />
|
| 547 |
+
);
|
| 548 |
+
}
|
src/app/datasets/loading.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Skeleton,
|
| 3 |
+
SkeletonCard,
|
| 4 |
+
SkeletonStats,
|
| 5 |
+
} from "@/components/ui/LoadingSkeleton";
|
| 6 |
+
|
| 7 |
+
/** Route-level skeleton for /datasets and its nested dataset profiles. */
|
| 8 |
+
export default function Loading() {
|
| 9 |
+
return (
|
| 10 |
+
<div className="flex flex-col gap-10 md:gap-12" aria-busy="true">
|
| 11 |
+
{/* header */}
|
| 12 |
+
<div className="hairline-b flex flex-col gap-4 pb-6">
|
| 13 |
+
<Skeleton width={140} height={12} />
|
| 14 |
+
<Skeleton width="min(28rem, 80%)" height={36} />
|
| 15 |
+
<Skeleton width="min(40rem, 100%)" height={14} />
|
| 16 |
+
<div className="flex gap-2 pt-2">
|
| 17 |
+
<Skeleton width={88} height={26} />
|
| 18 |
+
<Skeleton width={88} height={26} />
|
| 19 |
+
<Skeleton width={120} height={26} />
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
{/* registry grid */}
|
| 24 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 25 |
+
<div className="hairline depth-1 flex flex-col gap-5 rounded-sm bg-surface p-6 md:col-span-2 md:p-7">
|
| 26 |
+
<div className="flex gap-2">
|
| 27 |
+
<Skeleton width={56} height={20} />
|
| 28 |
+
<Skeleton width={104} height={20} />
|
| 29 |
+
</div>
|
| 30 |
+
<Skeleton width="min(20rem, 70%)" height={30} />
|
| 31 |
+
<Skeleton width="min(34rem, 95%)" height={14} />
|
| 32 |
+
<SkeletonStats count={3} className="grid-cols-3 max-w-xl" />
|
| 33 |
+
<Skeleton width="100%" height={10} />
|
| 34 |
+
</div>
|
| 35 |
+
<SkeletonCard lines={3} />
|
| 36 |
+
<SkeletonCard lines={3} />
|
| 37 |
+
<div className="md:col-span-2">
|
| 38 |
+
<SkeletonCard lines={2} />
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
src/app/datasets/page.tsx
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import { ArrowRight, ArrowUpRight, ExternalLink } from "lucide-react";
|
| 4 |
+
import Badge from "@/components/ui/Badge";
|
| 5 |
+
import Card from "@/components/ui/Card";
|
| 6 |
+
import DatasetChips from "@/components/ui/DatasetChips";
|
| 7 |
+
import SectionHeading from "@/components/ui/SectionHeading";
|
| 8 |
+
import { ONBOARDING_CRITERIA } from "@/components/docs/OnboardingCriteria";
|
| 9 |
+
import SplitMiniBar from "@/components/docs/SplitMiniBar";
|
| 10 |
+
import { ANCHOR_DATASET_ID, fmt, getDatasets, getModels } from "@/lib/data";
|
| 11 |
+
import type { DatasetInfo } from "@/lib/types";
|
| 12 |
+
|
| 13 |
+
export const metadata: Metadata = {
|
| 14 |
+
title: "Datasets",
|
| 15 |
+
description:
|
| 16 |
+
"The dataset registry of MedASR Bench — live Vietnamese medical ASR corpora, onboarding corpora, and the criteria a dataset must meet to mount into the harness.",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const chipLink =
|
| 20 |
+
"num inline-flex items-center gap-1.5 rounded-sm border border-line px-2 py-0.5 text-[11px] leading-4 tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
| 21 |
+
|
| 22 |
+
const actionPrimary =
|
| 23 |
+
"num inline-flex items-center gap-1.5 rounded-sm border border-accent/40 bg-accent-dim px-3 py-1.5 text-[12px] tracking-wide text-accent transition-colors duration-200 hover:bg-accent/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
| 24 |
+
|
| 25 |
+
const actionSecondary =
|
| 26 |
+
"num inline-flex items-center gap-1.5 rounded-sm border border-line px-3 py-1.5 text-[12px] tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
| 27 |
+
|
| 28 |
+
/** Rich profile card for a live dataset — stats, split bars, links. */
|
| 29 |
+
function LiveDatasetCard({ dataset }: { dataset: DatasetInfo }) {
|
| 30 |
+
const systems = getModels(dataset.id).length;
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<Card depth={2} className="h-full">
|
| 34 |
+
<div className="flex h-full flex-col gap-6 p-6 md:p-7">
|
| 35 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 36 |
+
<Badge variant="accent">
|
| 37 |
+
<span
|
| 38 |
+
aria-hidden
|
| 39 |
+
className="pulse-dot inline-block size-1.5 rounded-full bg-accent"
|
| 40 |
+
/>
|
| 41 |
+
Live
|
| 42 |
+
</Badge>
|
| 43 |
+
{dataset.id === ANCHOR_DATASET_ID && (
|
| 44 |
+
<Badge variant="outline">Anchor dataset</Badge>
|
| 45 |
+
)}
|
| 46 |
+
{dataset.license && (
|
| 47 |
+
<Badge variant="outline" title="License">
|
| 48 |
+
{dataset.license}
|
| 49 |
+
</Badge>
|
| 50 |
+
)}
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div className="grid gap-6 md:grid-cols-[1.15fr_1fr] md:gap-10">
|
| 54 |
+
{/* identity */}
|
| 55 |
+
<div className="flex flex-col gap-4">
|
| 56 |
+
<h2 className="font-display text-3xl leading-tight text-text md:text-4xl">
|
| 57 |
+
{dataset.name}
|
| 58 |
+
{dataset.fullName && (
|
| 59 |
+
<span className="mt-1 block font-display text-lg italic text-muted md:text-xl">
|
| 60 |
+
{dataset.fullName}
|
| 61 |
+
</span>
|
| 62 |
+
)}
|
| 63 |
+
</h2>
|
| 64 |
+
<p className="max-w-xl text-sm leading-relaxed text-muted md:text-[15px]">
|
| 65 |
+
{dataset.tagline}
|
| 66 |
+
</p>
|
| 67 |
+
{dataset.source && (
|
| 68 |
+
<p className="num text-[11px] uppercase tracking-[0.16em] text-muted">
|
| 69 |
+
Source · {dataset.source}
|
| 70 |
+
</p>
|
| 71 |
+
)}
|
| 72 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 73 |
+
{dataset.arxiv && (
|
| 74 |
+
<a
|
| 75 |
+
href={`https://arxiv.org/abs/${dataset.arxiv}`}
|
| 76 |
+
target="_blank"
|
| 77 |
+
rel="noreferrer"
|
| 78 |
+
className={chipLink}
|
| 79 |
+
>
|
| 80 |
+
arXiv:{dataset.arxiv}
|
| 81 |
+
<ExternalLink size={12} aria-hidden />
|
| 82 |
+
</a>
|
| 83 |
+
)}
|
| 84 |
+
{dataset.hf && (
|
| 85 |
+
<a
|
| 86 |
+
href={`https://huggingface.co/datasets/${dataset.hf}`}
|
| 87 |
+
target="_blank"
|
| 88 |
+
rel="noreferrer"
|
| 89 |
+
className={chipLink}
|
| 90 |
+
>
|
| 91 |
+
HF · {dataset.hf}
|
| 92 |
+
<ExternalLink size={12} aria-hidden />
|
| 93 |
+
</a>
|
| 94 |
+
)}
|
| 95 |
+
</div>
|
| 96 |
+
<div className="mt-auto flex flex-wrap items-center gap-2 pt-2">
|
| 97 |
+
<Link href={`/datasets/${dataset.id}`} className={actionPrimary}>
|
| 98 |
+
Dataset profile
|
| 99 |
+
<ArrowRight size={13} aria-hidden />
|
| 100 |
+
</Link>
|
| 101 |
+
<Link href="/" className={actionSecondary}>
|
| 102 |
+
Leaderboard · {systems} systems
|
| 103 |
+
<ArrowRight size={13} aria-hidden />
|
| 104 |
+
</Link>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{/* instrument readout */}
|
| 109 |
+
<div className="flex flex-col gap-5 border-line md:border-l md:pl-8">
|
| 110 |
+
<dl className="grid grid-cols-3 gap-4">
|
| 111 |
+
{dataset.hours !== undefined && (
|
| 112 |
+
<div>
|
| 113 |
+
<dt className="text-[11px] uppercase tracking-[0.14em] text-muted">
|
| 114 |
+
Audio
|
| 115 |
+
</dt>
|
| 116 |
+
<dd className="num mt-1 text-2xl leading-none text-text">
|
| 117 |
+
{fmt(dataset.hours)}
|
| 118 |
+
<span className="ml-0.5 text-base text-muted">h</span>
|
| 119 |
+
</dd>
|
| 120 |
+
</div>
|
| 121 |
+
)}
|
| 122 |
+
{dataset.utterances !== undefined && (
|
| 123 |
+
<div>
|
| 124 |
+
<dt className="text-[11px] uppercase tracking-[0.14em] text-muted">
|
| 125 |
+
Utterances
|
| 126 |
+
</dt>
|
| 127 |
+
<dd className="num mt-1 text-2xl leading-none text-text">
|
| 128 |
+
{dataset.utterances.toLocaleString("en-US")}
|
| 129 |
+
</dd>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
{dataset.csTerms !== undefined && (
|
| 133 |
+
<div>
|
| 134 |
+
<dt className="text-[11px] uppercase tracking-[0.14em] text-muted">
|
| 135 |
+
CS terms
|
| 136 |
+
</dt>
|
| 137 |
+
<dd className="num mt-1 text-2xl leading-none text-text">
|
| 138 |
+
{dataset.csTerms.toLocaleString("en-US")}
|
| 139 |
+
</dd>
|
| 140 |
+
</div>
|
| 141 |
+
)}
|
| 142 |
+
</dl>
|
| 143 |
+
{dataset.splits && (
|
| 144 |
+
<div className="flex flex-col gap-2">
|
| 145 |
+
<p className="num text-[11px] uppercase tracking-[0.16em] text-muted">
|
| 146 |
+
Frozen splits · share of audio
|
| 147 |
+
</p>
|
| 148 |
+
<SplitMiniBar splits={dataset.splits} />
|
| 149 |
+
</div>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</Card>
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/** Muted teaser card for an onboarding dataset. */
|
| 159 |
+
function ComingSoonCard({ dataset }: { dataset: DatasetInfo }) {
|
| 160 |
+
return (
|
| 161 |
+
<div className="depth-1 flex h-full flex-col gap-3 rounded-sm border border-dashed border-line bg-surface/40 p-6 transition-colors duration-200 hover:border-line hover:bg-surface/60">
|
| 162 |
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
| 163 |
+
<Badge variant="dashed">Onboarding</Badge>
|
| 164 |
+
<span className="num text-[11px] uppercase tracking-[0.16em] text-muted">
|
| 165 |
+
{dataset.eta ? `ETA ${dataset.eta}` : "soon"}
|
| 166 |
+
</span>
|
| 167 |
+
</div>
|
| 168 |
+
<h2 className="font-display text-2xl leading-tight text-text">
|
| 169 |
+
{dataset.name}
|
| 170 |
+
</h2>
|
| 171 |
+
<p className="text-[13px] leading-relaxed text-muted">{dataset.tagline}</p>
|
| 172 |
+
{dataset.source && (
|
| 173 |
+
<p className="num text-[11px] uppercase tracking-[0.16em] text-muted">
|
| 174 |
+
Source · {dataset.source}
|
| 175 |
+
</p>
|
| 176 |
+
)}
|
| 177 |
+
<Link
|
| 178 |
+
href={`/datasets/${dataset.id}`}
|
| 179 |
+
className="num mt-auto inline-flex items-center gap-1.5 self-start rounded-sm pt-2 text-[12px] tracking-wide text-muted transition-colors duration-200 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
| 180 |
+
>
|
| 181 |
+
What onboarding involves
|
| 182 |
+
<ArrowRight size={13} aria-hidden />
|
| 183 |
+
</Link>
|
| 184 |
+
</div>
|
| 185 |
+
);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
export default function DatasetsPage() {
|
| 189 |
+
const datasets = getDatasets();
|
| 190 |
+
const live = datasets.filter((d) => d.status === "live");
|
| 191 |
+
const comingSoon = datasets.filter((d) => d.status === "coming-soon");
|
| 192 |
+
|
| 193 |
+
return (
|
| 194 |
+
<div className="flex flex-col gap-10 md:gap-12">
|
| 195 |
+
{/* 1 · Header */}
|
| 196 |
+
<div className="spring-up">
|
| 197 |
+
<SectionHeading
|
| 198 |
+
as="h1"
|
| 199 |
+
eyebrow="03 · Datasets"
|
| 200 |
+
title="One instrument, many corpora"
|
| 201 |
+
sub={
|
| 202 |
+
<>
|
| 203 |
+
MedASR Bench is a benchmark hub for Vietnamese medical ASR. Every
|
| 204 |
+
dataset mounts into the same harness — same normalizer, same six
|
| 205 |
+
metrics, same provenance rules — so scores stay comparable across
|
| 206 |
+
corpora. <span className="num text-text">{live.length}</span> live
|
| 207 |
+
today, <span className="num text-text">{comingSoon.length}</span>{" "}
|
| 208 |
+
onboarding.
|
| 209 |
+
</>
|
| 210 |
+
}
|
| 211 |
+
/>
|
| 212 |
+
<DatasetChips datasets={datasets} className="mt-5" />
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* 2 · Bento registry */}
|
| 216 |
+
<section aria-label="Dataset registry" className="grid gap-4 md:grid-cols-2">
|
| 217 |
+
{live.map((dataset) => (
|
| 218 |
+
<div key={dataset.id} className="spring-up delay-1 md:col-span-2">
|
| 219 |
+
<LiveDatasetCard dataset={dataset} />
|
| 220 |
+
</div>
|
| 221 |
+
))}
|
| 222 |
+
{comingSoon.map((dataset, i) => (
|
| 223 |
+
<div
|
| 224 |
+
key={dataset.id}
|
| 225 |
+
className={`spring-up ${i % 2 === 0 ? "delay-2" : "delay-3"}`}
|
| 226 |
+
>
|
| 227 |
+
<ComingSoonCard dataset={dataset} />
|
| 228 |
+
</div>
|
| 229 |
+
))}
|
| 230 |
+
|
| 231 |
+
{/* 3 · Onboarding CTA — glass treatment */}
|
| 232 |
+
<div className="spring-up delay-4 md:col-span-2">
|
| 233 |
+
<aside
|
| 234 |
+
aria-label="Onboard a dataset"
|
| 235 |
+
className="glass depth-2 rounded-sm p-6 md:p-7"
|
| 236 |
+
>
|
| 237 |
+
<div className="grid gap-6 md:grid-cols-[1fr_auto] md:items-end md:gap-10">
|
| 238 |
+
<div className="flex flex-col gap-3">
|
| 239 |
+
<p className="num text-[11px] uppercase tracking-[0.22em] text-muted">
|
| 240 |
+
Onboard a dataset
|
| 241 |
+
</p>
|
| 242 |
+
<h2 className="font-display text-2xl leading-tight text-text md:text-3xl">
|
| 243 |
+
Run your corpus on the same instrument
|
| 244 |
+
</h2>
|
| 245 |
+
<p className="max-w-2xl text-sm leading-relaxed text-muted">
|
| 246 |
+
Any Vietnamese medical speech corpus that meets six criteria
|
| 247 |
+
can mount into the harness, and its scores are comparable from
|
| 248 |
+
the first run. Datasets are frozen, versioned snapshots —
|
| 249 |
+
never moving targets.
|
| 250 |
+
</p>
|
| 251 |
+
<ul className="mt-1 flex flex-wrap gap-2" aria-label="Onboarding criteria">
|
| 252 |
+
{ONBOARDING_CRITERIA.map((c) => (
|
| 253 |
+
<li key={c.title}>
|
| 254 |
+
<span
|
| 255 |
+
title={c.detail}
|
| 256 |
+
className="num inline-flex items-center rounded-sm border border-line bg-surface/60 px-2 py-0.5 text-[11px] leading-4 tracking-wide text-text"
|
| 257 |
+
>
|
| 258 |
+
{c.title}
|
| 259 |
+
</span>
|
| 260 |
+
</li>
|
| 261 |
+
))}
|
| 262 |
+
</ul>
|
| 263 |
+
</div>
|
| 264 |
+
<Link href="/methodology#onboarding" className={actionPrimary}>
|
| 265 |
+
Criteria & versioning policy
|
| 266 |
+
<ArrowUpRight size={13} aria-hidden />
|
| 267 |
+
</Link>
|
| 268 |
+
</div>
|
| 269 |
+
</aside>
|
| 270 |
+
</div>
|
| 271 |
+
</section>
|
| 272 |
+
</div>
|
| 273 |
+
);
|
| 274 |
+
}
|
src/app/favicon.ico
DELETED
|
Binary file (25.9 kB)
|
|
|
src/app/globals.css
CHANGED
|
@@ -1,26 +1,823 @@
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
:root {
|
| 4 |
-
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
@theme inline {
|
| 9 |
-
|
| 10 |
-
--color-
|
| 11 |
-
--
|
| 12 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
body {
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
+
/* ------------------------------------------------------------------ */
|
| 4 |
+
/* MedASR Bench — "Clinical Instrument" token system */
|
| 5 |
+
/* ------------------------------------------------------------------ */
|
| 6 |
+
|
| 7 |
:root {
|
| 8 |
+
color-scheme: light;
|
| 9 |
+
|
| 10 |
+
/* Palette — light "precision instrument": cool paper-white surfaces, ink
|
| 11 |
+
text, deep violet accent (user-picked hue; deepened for light-mode
|
| 12 |
+
contrast: oklch(0.50 0.17 290) = 6.1:1 on --bg). Built in OKLCH; all
|
| 13 |
+
small-text pairings clear WCAG 4.5:1. Saturated hues are reserved for
|
| 14 |
+
meaning (accent/warn/crit). NO green — repeatedly rejected by the user. */
|
| 15 |
+
--bg: #f9f9fb;
|
| 16 |
+
--surface: #ffffff;
|
| 17 |
+
--surface-2: #eeeef2;
|
| 18 |
+
--line: rgba(36, 36, 60, 0.13);
|
| 19 |
+
--text: #24242f;
|
| 20 |
+
--muted: #62626f;
|
| 21 |
+
--accent: #654abb; /* deep violet — sparingly */
|
| 22 |
+
--warn: #a15e00; /* contamination / caution only */
|
| 23 |
+
--crit: #c53637; /* critical / error only */
|
| 24 |
+
|
| 25 |
+
/* Derived tints — single-source: everything mixes from the tokens above */
|
| 26 |
+
--accent-dim: color-mix(in srgb, var(--accent) 12%, transparent);
|
| 27 |
+
--warn-dim: color-mix(in srgb, var(--warn) 12%, transparent);
|
| 28 |
+
--crit-dim: color-mix(in srgb, var(--crit) 10%, transparent);
|
| 29 |
+
--grid-ink: rgba(36, 36, 80, 0.045);
|
| 30 |
+
--grid-ink-major: rgba(36, 36, 80, 0.065);
|
| 31 |
}
|
| 32 |
|
| 33 |
@theme inline {
|
| 34 |
+
/* Colors → Tailwind utilities (bg-bg, text-muted, border-line, …) */
|
| 35 |
+
--color-bg: var(--bg);
|
| 36 |
+
--color-surface: var(--surface);
|
| 37 |
+
--color-surface-2: var(--surface-2);
|
| 38 |
+
--color-line: var(--line);
|
| 39 |
+
--color-text: var(--text);
|
| 40 |
+
--color-muted: var(--muted);
|
| 41 |
+
--color-accent: var(--accent);
|
| 42 |
+
--color-warn: var(--warn);
|
| 43 |
+
--color-crit: var(--crit);
|
| 44 |
+
--color-accent-dim: var(--accent-dim);
|
| 45 |
+
--color-warn-dim: var(--warn-dim);
|
| 46 |
+
--color-crit-dim: var(--crit-dim);
|
| 47 |
+
|
| 48 |
+
/* Fonts → font-display / font-body / font-mono utilities.
|
| 49 |
+
The var() chain resolves to next/font variables set on <html>. */
|
| 50 |
+
--font-display: var(--font-display), "Instrument Serif", Georgia, serif;
|
| 51 |
+
--font-body: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif;
|
| 52 |
+
--font-sans: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif;
|
| 53 |
+
--font-mono: var(--font-mono), "IBM Plex Mono", ui-monospace, SFMono-Regular,
|
| 54 |
+
monospace;
|
| 55 |
}
|
| 56 |
|
| 57 |
+
/* ------------------------------------------------------------------ */
|
| 58 |
+
/* Base + atmosphere: graph-paper micro-grid + scanline layer */
|
| 59 |
+
/* ------------------------------------------------------------------ */
|
| 60 |
+
|
| 61 |
+
html {
|
| 62 |
+
background-color: var(--bg);
|
| 63 |
}
|
| 64 |
|
| 65 |
body {
|
| 66 |
+
/* solid color lives on <html>; body stays transparent so the fixed
|
| 67 |
+
negative-z ambient glow layers (AmbientEffects) shine through the grid */
|
| 68 |
+
background-color: transparent;
|
| 69 |
+
color: var(--text);
|
| 70 |
+
font-family: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif;
|
| 71 |
+
/* graph-paper grid: 140px major rhythm over the 28px minor micro-grid */
|
| 72 |
+
background-image: linear-gradient(var(--grid-ink-major) 1px, transparent 1px),
|
| 73 |
+
linear-gradient(90deg, var(--grid-ink-major) 1px, transparent 1px),
|
| 74 |
+
linear-gradient(var(--grid-ink) 1px, transparent 1px),
|
| 75 |
+
linear-gradient(90deg, var(--grid-ink) 1px, transparent 1px);
|
| 76 |
+
background-size:
|
| 77 |
+
140px 140px,
|
| 78 |
+
140px 140px,
|
| 79 |
+
28px 28px,
|
| 80 |
+
28px 28px;
|
| 81 |
+
-webkit-font-smoothing: antialiased;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* ------------------------------------------------------------------ */
|
| 85 |
+
/* Grain texture — subtle film noise for tactile depth */
|
| 86 |
+
/* ------------------------------------------------------------------ */
|
| 87 |
+
|
| 88 |
+
body::before {
|
| 89 |
+
content: "";
|
| 90 |
+
position: fixed;
|
| 91 |
+
inset: 0;
|
| 92 |
+
z-index: 29;
|
| 93 |
+
pointer-events: none;
|
| 94 |
+
opacity: 0.02;
|
| 95 |
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* scanline veil — fixed, non-interactive, layered above grain */
|
| 99 |
+
body::after {
|
| 100 |
+
content: "";
|
| 101 |
+
position: fixed;
|
| 102 |
+
inset: 0;
|
| 103 |
+
z-index: 30;
|
| 104 |
+
pointer-events: none;
|
| 105 |
+
background: repeating-linear-gradient(
|
| 106 |
+
to bottom,
|
| 107 |
+
color-mix(in srgb, var(--text) 1.2%, transparent) 0px,
|
| 108 |
+
color-mix(in srgb, var(--text) 1.2%, transparent) 1px,
|
| 109 |
+
transparent 1px,
|
| 110 |
+
transparent 3px
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
::selection {
|
| 115 |
+
background: var(--accent-dim);
|
| 116 |
+
color: var(--text);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* ------------------------------------------------------------------ */
|
| 120 |
+
/* Ambient light — fixed phosphor glow washes behind all content */
|
| 121 |
+
/* (rendered by components/ui/AmbientEffects; GPU-cheap transform drift) */
|
| 122 |
+
/* ------------------------------------------------------------------ */
|
| 123 |
+
|
| 124 |
+
@keyframes ambient-drift-a {
|
| 125 |
+
0%,
|
| 126 |
+
100% {
|
| 127 |
+
transform: translate3d(0, 0, 0) scale(1);
|
| 128 |
+
}
|
| 129 |
+
50% {
|
| 130 |
+
transform: translate3d(4%, 3%, 0) scale(1.08);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
@keyframes ambient-drift-b {
|
| 135 |
+
0%,
|
| 136 |
+
100% {
|
| 137 |
+
transform: translate3d(0, 0, 0) scale(1);
|
| 138 |
+
}
|
| 139 |
+
50% {
|
| 140 |
+
transform: translate3d(-3%, -4%, 0) scale(1.06);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.ambient-glow {
|
| 145 |
+
position: absolute;
|
| 146 |
+
pointer-events: none;
|
| 147 |
+
will-change: transform;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* top-center wash — the room's key light */
|
| 151 |
+
.ambient-glow-top {
|
| 152 |
+
top: -32vh;
|
| 153 |
+
left: -12vw;
|
| 154 |
+
right: -12vw;
|
| 155 |
+
height: 78vh;
|
| 156 |
+
background: radial-gradient(
|
| 157 |
+
ellipse 55% 55% at 50% 45%,
|
| 158 |
+
color-mix(in srgb, var(--accent) 9%, transparent),
|
| 159 |
+
transparent 70%
|
| 160 |
+
);
|
| 161 |
+
animation: ambient-drift-a 70s ease-in-out infinite;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* lower-right fill — faint counter-glow */
|
| 165 |
+
.ambient-glow-corner {
|
| 166 |
+
right: -22vw;
|
| 167 |
+
bottom: -30vh;
|
| 168 |
+
width: 72vw;
|
| 169 |
+
height: 75vh;
|
| 170 |
+
background: radial-gradient(
|
| 171 |
+
ellipse 50% 50% at 55% 55%,
|
| 172 |
+
color-mix(in srgb, var(--accent) 5.5%, transparent),
|
| 173 |
+
transparent 70%
|
| 174 |
+
);
|
| 175 |
+
animation: ambient-drift-b 90s ease-in-out infinite;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/* ------------------------------------------------------------------ */
|
| 179 |
+
/* Glass morphism — translucent surfaces tuned to the lifted palette */
|
| 180 |
+
/* ------------------------------------------------------------------ */
|
| 181 |
+
|
| 182 |
+
/* layered as components so Tailwind utilities (bg-surface/55 …) can
|
| 183 |
+
override the surface opacity per-instance */
|
| 184 |
+
@layer components {
|
| 185 |
+
/* veil only: translucent surface + blur, no border (compose freely) */
|
| 186 |
+
.glass-veil {
|
| 187 |
+
background: color-mix(in srgb, var(--surface) 66%, transparent);
|
| 188 |
+
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
| 189 |
+
backdrop-filter: blur(16px) saturate(1.2);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/* full glass panel: veil + hairline + machined top-edge relief */
|
| 193 |
+
.glass {
|
| 194 |
+
background: color-mix(in srgb, var(--surface) 72%, transparent);
|
| 195 |
+
-webkit-backdrop-filter: blur(14px) saturate(1.2);
|
| 196 |
+
backdrop-filter: blur(14px) saturate(1.2);
|
| 197 |
+
border: 1px solid var(--line);
|
| 198 |
+
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/* ------------------------------------------------------------------ */
|
| 203 |
+
/* Hairline utilities (1px --line rules, the only "shadows" we use) */
|
| 204 |
+
/* ------------------------------------------------------------------ */
|
| 205 |
+
|
| 206 |
+
.hairline {
|
| 207 |
+
border: 1px solid var(--line);
|
| 208 |
+
/* machined top-edge relief — the only "shadow" beyond hairlines */
|
| 209 |
+
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent);
|
| 210 |
+
}
|
| 211 |
+
.hairline-t {
|
| 212 |
+
border-top: 1px solid var(--line);
|
| 213 |
+
}
|
| 214 |
+
.hairline-b {
|
| 215 |
+
border-bottom: 1px solid var(--line);
|
| 216 |
+
}
|
| 217 |
+
.hairline-l {
|
| 218 |
+
border-left: 1px solid var(--line);
|
| 219 |
+
}
|
| 220 |
+
.hairline-r {
|
| 221 |
+
border-right: 1px solid var(--line);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* ------------------------------------------------------------------ */
|
| 225 |
+
/* Spring animations — physics-based motion */
|
| 226 |
+
/* ------------------------------------------------------------------ */
|
| 227 |
+
|
| 228 |
+
@keyframes spring-in {
|
| 229 |
+
from {
|
| 230 |
+
opacity: 0;
|
| 231 |
+
transform: scale(0.95) translateY(12px);
|
| 232 |
+
}
|
| 233 |
+
to {
|
| 234 |
+
opacity: 1;
|
| 235 |
+
transform: scale(1) translateY(0);
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
@keyframes spring-up {
|
| 240 |
+
from {
|
| 241 |
+
opacity: 0;
|
| 242 |
+
transform: translateY(20px);
|
| 243 |
+
}
|
| 244 |
+
to {
|
| 245 |
+
opacity: 1;
|
| 246 |
+
transform: translateY(0);
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
@keyframes soft-appear {
|
| 251 |
+
from {
|
| 252 |
+
opacity: 0;
|
| 253 |
+
transform: translateY(8px);
|
| 254 |
+
}
|
| 255 |
+
to {
|
| 256 |
+
opacity: 1;
|
| 257 |
+
transform: translateY(0);
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
/* Spring-based entrance animations */
|
| 262 |
+
.spring-in {
|
| 263 |
+
animation: spring-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.spring-up {
|
| 267 |
+
animation: spring-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.soft-appear {
|
| 271 |
+
animation: soft-appear 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
/* Improved stagger with spring feel */
|
| 275 |
+
.stagger-spring > * {
|
| 276 |
+
animation: spring-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
|
| 277 |
+
}
|
| 278 |
+
.stagger-spring > :nth-child(1) { animation-delay: 0ms; }
|
| 279 |
+
.stagger-spring > :nth-child(2) { animation-delay: 60ms; }
|
| 280 |
+
.stagger-spring > :nth-child(3) { animation-delay: 120ms; }
|
| 281 |
+
.stagger-spring > :nth-child(4) { animation-delay: 180ms; }
|
| 282 |
+
.stagger-spring > :nth-child(5) { animation-delay: 240ms; }
|
| 283 |
+
.stagger-spring > :nth-child(6) { animation-delay: 300ms; }
|
| 284 |
+
.stagger-spring > :nth-child(7) { animation-delay: 360ms; }
|
| 285 |
+
.stagger-spring > :nth-child(8) { animation-delay: 420ms; }
|
| 286 |
+
|
| 287 |
+
/* ------------------------------------------------------------------ */
|
| 288 |
+
/* Spotlight border effect — card illumination on hover */
|
| 289 |
+
/* ------------------------------------------------------------------ */
|
| 290 |
+
|
| 291 |
+
.spotlight-card {
|
| 292 |
+
position: relative;
|
| 293 |
+
isolation: isolate;
|
| 294 |
+
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.spotlight-card::before {
|
| 298 |
+
content: "";
|
| 299 |
+
position: absolute;
|
| 300 |
+
inset: 0;
|
| 301 |
+
border-radius: inherit;
|
| 302 |
+
background: radial-gradient(
|
| 303 |
+
500px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
|
| 304 |
+
color-mix(in srgb, var(--accent) 12%, transparent),
|
| 305 |
+
transparent 40%
|
| 306 |
+
);
|
| 307 |
+
opacity: 0;
|
| 308 |
+
transition: opacity 0.4s ease;
|
| 309 |
+
z-index: -1;
|
| 310 |
+
pointer-events: none;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.spotlight-card:hover {
|
| 314 |
+
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
|
| 315 |
+
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--accent) 8%, transparent);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.spotlight-card:hover::before {
|
| 319 |
+
opacity: 1;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* Spotlight row for tables.
|
| 323 |
+
IMPORTANT: no ::before/::after on this class — it sits on <tr>, and a
|
| 324 |
+
generated box on a table row gets wrapped in an ANONYMOUS CELL, silently
|
| 325 |
+
shifting every real cell one column right of the headers. The hover
|
| 326 |
+
sweep is painted as the row's own background instead. */
|
| 327 |
+
.spotlight-row {
|
| 328 |
+
transition: background-color 0.2s ease;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.spotlight-row:hover {
|
| 332 |
+
background-color: var(--surface-2);
|
| 333 |
+
background-image: linear-gradient(
|
| 334 |
+
90deg,
|
| 335 |
+
transparent 0%,
|
| 336 |
+
color-mix(in srgb, var(--accent) 4%, transparent) 50%,
|
| 337 |
+
transparent 100%
|
| 338 |
+
);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
/* ------------------------------------------------------------------ */
|
| 342 |
+
/* Tactile button states */
|
| 343 |
+
/* ------------------------------------------------------------------ */
|
| 344 |
+
|
| 345 |
+
.tactile {
|
| 346 |
+
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1),
|
| 347 |
+
background-color 0.2s ease,
|
| 348 |
+
border-color 0.2s ease;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.tactile:active {
|
| 352 |
+
transform: scale(0.975) translateY(1px);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.tactile:hover:not(:disabled) {
|
| 356 |
+
background-color: var(--surface-2);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
/* Interactive elements with subtle lift */
|
| 360 |
+
.hover-lift {
|
| 361 |
+
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),
|
| 362 |
+
box-shadow 0.2s ease;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.hover-lift:hover {
|
| 366 |
+
transform: translateY(-2px);
|
| 367 |
+
box-shadow:
|
| 368 |
+
0 4px 14px rgba(20, 20, 45, 0.1),
|
| 369 |
+
inset 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
/* ------------------------------------------------------------------ */
|
| 373 |
+
/* Accent glow effects */
|
| 374 |
+
/* ------------------------------------------------------------------ */
|
| 375 |
+
|
| 376 |
+
.glow-accent-subtle {
|
| 377 |
+
box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 10%, transparent);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/* ------------------------------------------------------------------ */
|
| 381 |
+
/* Edge accent bar — vertical phosphor line */
|
| 382 |
+
/* ------------------------------------------------------------------ */
|
| 383 |
+
|
| 384 |
+
.edge-accent {
|
| 385 |
+
position: relative;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.edge-accent::before {
|
| 389 |
+
content: "";
|
| 390 |
+
position: absolute;
|
| 391 |
+
left: 0;
|
| 392 |
+
top: 0;
|
| 393 |
+
bottom: 0;
|
| 394 |
+
width: 2px;
|
| 395 |
+
background: linear-gradient(
|
| 396 |
+
to bottom,
|
| 397 |
+
transparent,
|
| 398 |
+
var(--accent) 20%,
|
| 399 |
+
var(--accent) 80%,
|
| 400 |
+
transparent
|
| 401 |
+
);
|
| 402 |
+
opacity: 0.6;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
/* Mono numerals everywhere data lives */
|
| 406 |
+
.num {
|
| 407 |
+
font-family: var(--font-mono), "IBM Plex Mono", ui-monospace, monospace;
|
| 408 |
+
font-variant-numeric: tabular-nums;
|
| 409 |
+
}
|
| 410 |
+
.tabular {
|
| 411 |
+
font-variant-numeric: tabular-nums;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
/* ------------------------------------------------------------------ */
|
| 415 |
+
/* Keyframes */
|
| 416 |
+
/* ------------------------------------------------------------------ */
|
| 417 |
+
|
| 418 |
+
@keyframes rise {
|
| 419 |
+
from {
|
| 420 |
+
opacity: 0;
|
| 421 |
+
transform: translateY(14px);
|
| 422 |
+
}
|
| 423 |
+
to {
|
| 424 |
+
opacity: 1;
|
| 425 |
+
transform: translateY(0);
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
/* pair with pathLength={1} + stroke-dasharray: 1 on an SVG path */
|
| 430 |
+
@keyframes drawLine {
|
| 431 |
+
from {
|
| 432 |
+
stroke-dashoffset: 1;
|
| 433 |
+
}
|
| 434 |
+
to {
|
| 435 |
+
stroke-dashoffset: 0;
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
/* ambient ECG sweep: a short bright tracer cycling over the dim base path */
|
| 440 |
+
@keyframes sweep {
|
| 441 |
+
from {
|
| 442 |
+
stroke-dashoffset: 1;
|
| 443 |
+
}
|
| 444 |
+
to {
|
| 445 |
+
stroke-dashoffset: 0;
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
@keyframes shimmer {
|
| 450 |
+
from {
|
| 451 |
+
background-position: -200% 0;
|
| 452 |
+
}
|
| 453 |
+
to {
|
| 454 |
+
background-position: 200% 0;
|
| 455 |
+
}
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
@keyframes pulseDot {
|
| 459 |
+
0%,
|
| 460 |
+
100% {
|
| 461 |
+
opacity: 1;
|
| 462 |
+
transform: scale(1);
|
| 463 |
+
}
|
| 464 |
+
50% {
|
| 465 |
+
opacity: 0.35;
|
| 466 |
+
transform: scale(0.78);
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
@keyframes scaleInX {
|
| 471 |
+
from {
|
| 472 |
+
transform: scaleX(0);
|
| 473 |
+
}
|
| 474 |
+
to {
|
| 475 |
+
transform: scaleX(1);
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
/* ------------------------------------------------------------------ */
|
| 480 |
+
/* Motion helpers — one orchestrated load per route */
|
| 481 |
+
/* ------------------------------------------------------------------ */
|
| 482 |
+
|
| 483 |
+
/* stagger container: children rise with 55ms steps */
|
| 484 |
+
.stagger > * {
|
| 485 |
+
animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
|
| 486 |
+
}
|
| 487 |
+
.stagger > :nth-child(1) {
|
| 488 |
+
animation-delay: 0ms;
|
| 489 |
+
}
|
| 490 |
+
.stagger > :nth-child(2) {
|
| 491 |
+
animation-delay: 55ms;
|
| 492 |
+
}
|
| 493 |
+
.stagger > :nth-child(3) {
|
| 494 |
+
animation-delay: 110ms;
|
| 495 |
+
}
|
| 496 |
+
.stagger > :nth-child(4) {
|
| 497 |
+
animation-delay: 165ms;
|
| 498 |
+
}
|
| 499 |
+
.stagger > :nth-child(5) {
|
| 500 |
+
animation-delay: 220ms;
|
| 501 |
+
}
|
| 502 |
+
.stagger > :nth-child(6) {
|
| 503 |
+
animation-delay: 275ms;
|
| 504 |
+
}
|
| 505 |
+
.stagger > :nth-child(7) {
|
| 506 |
+
animation-delay: 330ms;
|
| 507 |
+
}
|
| 508 |
+
.stagger > :nth-child(8) {
|
| 509 |
+
animation-delay: 385ms;
|
| 510 |
+
}
|
| 511 |
+
.stagger > :nth-child(9) {
|
| 512 |
+
animation-delay: 440ms;
|
| 513 |
+
}
|
| 514 |
+
.stagger > :nth-child(10) {
|
| 515 |
+
animation-delay: 495ms;
|
| 516 |
+
}
|
| 517 |
+
.stagger > :nth-child(11) {
|
| 518 |
+
animation-delay: 550ms;
|
| 519 |
+
}
|
| 520 |
+
.stagger > :nth-child(12) {
|
| 521 |
+
animation-delay: 605ms;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
/* manual stagger steps for non-sibling layouts */
|
| 525 |
+
.delay-1 {
|
| 526 |
+
animation-delay: 55ms;
|
| 527 |
+
}
|
| 528 |
+
.delay-2 {
|
| 529 |
+
animation-delay: 110ms;
|
| 530 |
+
}
|
| 531 |
+
.delay-3 {
|
| 532 |
+
animation-delay: 165ms;
|
| 533 |
+
}
|
| 534 |
+
.delay-4 {
|
| 535 |
+
animation-delay: 220ms;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
/* skeleton block: surface placeholder swept by the shimmer keyframes */
|
| 539 |
+
.skeleton {
|
| 540 |
+
position: relative;
|
| 541 |
+
overflow: hidden;
|
| 542 |
+
border-radius: 2px;
|
| 543 |
+
background-color: var(--surface-2);
|
| 544 |
+
background-image: linear-gradient(
|
| 545 |
+
100deg,
|
| 546 |
+
transparent 40%,
|
| 547 |
+
color-mix(in srgb, var(--text) 7%, transparent) 50%,
|
| 548 |
+
transparent 60%
|
| 549 |
+
);
|
| 550 |
+
background-size: 200% 100%;
|
| 551 |
+
animation: shimmer 1.8s linear infinite;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
/* shimmering surface for status=running chips */
|
| 555 |
+
.chip-shimmer {
|
| 556 |
+
background-image: linear-gradient(
|
| 557 |
+
100deg,
|
| 558 |
+
color-mix(in srgb, var(--accent) 7%, transparent) 40%,
|
| 559 |
+
color-mix(in srgb, var(--accent) 22%, transparent) 50%,
|
| 560 |
+
color-mix(in srgb, var(--accent) 7%, transparent) 60%
|
| 561 |
+
);
|
| 562 |
+
background-size: 200% 100%;
|
| 563 |
+
animation: shimmer 2.4s linear infinite;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.pulse-dot {
|
| 567 |
+
animation: pulseDot 1.4s ease-in-out infinite;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
.draw-line {
|
| 571 |
+
stroke-dasharray: 1;
|
| 572 |
+
animation: drawLine 1.4s cubic-bezier(0.4, 0, 0.2, 1) 0.15s both;
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
/* pair with pathLength={1}: 6%-long phosphor tracer, ~7s per pass */
|
| 576 |
+
.pulse-sweep {
|
| 577 |
+
stroke-dasharray: 0.06 0.94;
|
| 578 |
+
animation: sweep 7s linear infinite;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
/* CI whisker draw-in: the micro-bar sweeps open left → right once */
|
| 582 |
+
.ci-draw {
|
| 583 |
+
transform-origin: left center;
|
| 584 |
+
animation: scaleInX 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
/* NOTE: scroll-driven entrances (animation-timeline: view()) were tried
|
| 588 |
+
and removed — with fill-mode both, content whose timeline binds to the
|
| 589 |
+
wrong scroll container (e.g. rows inside overflow-x-auto) can be stuck
|
| 590 |
+
at opacity 0 forever. Entrances are load-orchestrated only. */
|
| 591 |
+
|
| 592 |
+
/* ------------------------------------------------------------------ */
|
| 593 |
+
/* Scroll-triggered animations (Intersection Observer based) */
|
| 594 |
+
/* ------------------------------------------------------------------ */
|
| 595 |
+
|
| 596 |
+
/* Base scroll animation classes */
|
| 597 |
+
.scroll-animate {
|
| 598 |
+
opacity: 0;
|
| 599 |
+
transform: translateY(24px);
|
| 600 |
+
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1),
|
| 601 |
+
transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
.scroll-animate.in-view {
|
| 605 |
+
opacity: 1;
|
| 606 |
+
transform: translateY(0);
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
/* Scroll scale animation */
|
| 610 |
+
.scroll-scale {
|
| 611 |
+
opacity: 0;
|
| 612 |
+
transform: scale(0.95);
|
| 613 |
+
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1),
|
| 614 |
+
transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.scroll-scale.in-view {
|
| 618 |
+
opacity: 1;
|
| 619 |
+
transform: scale(1);
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
/* Scroll slide animations */
|
| 623 |
+
.scroll-slide-left {
|
| 624 |
+
opacity: 0;
|
| 625 |
+
transform: translateX(-24px);
|
| 626 |
+
transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1),
|
| 627 |
+
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.scroll-slide-left.in-view {
|
| 631 |
+
opacity: 1;
|
| 632 |
+
transform: translateX(0);
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.scroll-slide-right {
|
| 636 |
+
opacity: 0;
|
| 637 |
+
transform: translateX(24px);
|
| 638 |
+
transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1),
|
| 639 |
+
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
.scroll-slide-right.in-view {
|
| 643 |
+
opacity: 1;
|
| 644 |
+
transform: translateX(0);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.scroll-slide-down {
|
| 648 |
+
opacity: 0;
|
| 649 |
+
transform: translateY(-24px);
|
| 650 |
+
transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1),
|
| 651 |
+
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.scroll-slide-down.in-view {
|
| 655 |
+
opacity: 1;
|
| 656 |
+
transform: translateY(0);
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
/* Scroll fade animation */
|
| 660 |
+
.scroll-fade {
|
| 661 |
+
opacity: 0;
|
| 662 |
+
transition: opacity 0.4s ease-out;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
.scroll-fade.in-view {
|
| 666 |
+
opacity: 1;
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
/* Stagger children on scroll */
|
| 670 |
+
.scroll-stagger > * {
|
| 671 |
+
opacity: 0;
|
| 672 |
+
transform: translateY(16px);
|
| 673 |
+
transition: opacity 0.4s ease-out,
|
| 674 |
+
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
.scroll-stagger.in-view > *:nth-child(1) { opacity: 1; transform: translateY(0); transition-delay: 0ms; }
|
| 678 |
+
.scroll-stagger.in-view > *:nth-child(2) { opacity: 1; transform: translateY(0); transition-delay: 60ms; }
|
| 679 |
+
.scroll-stagger.in-view > *:nth-child(3) { opacity: 1; transform: translateY(0); transition-delay: 120ms; }
|
| 680 |
+
.scroll-stagger.in-view > *:nth-child(4) { opacity: 1; transform: translateY(0); transition-delay: 180ms; }
|
| 681 |
+
.scroll-stagger.in-view > *:nth-child(5) { opacity: 1; transform: translateY(0); transition-delay: 240ms; }
|
| 682 |
+
.scroll-stagger.in-view > *:nth-child(6) { opacity: 1; transform: translateY(0); transition-delay: 300ms; }
|
| 683 |
+
.scroll-stagger.in-view > *:nth-child(7) { opacity: 1; transform: translateY(0); transition-delay: 360ms; }
|
| 684 |
+
.scroll-stagger.in-view > *:nth-child(8) { opacity: 1; transform: translateY(0); transition-delay: 420ms; }
|
| 685 |
+
|
| 686 |
+
/* ------------------------------------------------------------------ */
|
| 687 |
+
/* Micro-interactions */
|
| 688 |
+
/* ------------------------------------------------------------------ */
|
| 689 |
+
|
| 690 |
+
/* ------------------------------------------------------------------ */
|
| 691 |
+
/* Card depth variations */
|
| 692 |
+
/* ------------------------------------------------------------------ */
|
| 693 |
+
|
| 694 |
+
.depth-1 {
|
| 695 |
+
box-shadow: 0 1px 3px rgba(20, 20, 45, 0.06);
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
.depth-2 {
|
| 699 |
+
box-shadow: 0 2px 10px rgba(20, 20, 45, 0.07);
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.depth-3 {
|
| 703 |
+
box-shadow:
|
| 704 |
+
0 8px 28px rgba(20, 20, 45, 0.09),
|
| 705 |
+
0 2px 6px rgba(20, 20, 45, 0.05);
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
/* ------------------------------------------------------------------ */
|
| 709 |
+
/* Full-bleed breakout — a block wider than the page container, */
|
| 710 |
+
/* centered on the viewport (parent must be a centered max-w column) */
|
| 711 |
+
/* ------------------------------------------------------------------ */
|
| 712 |
+
|
| 713 |
+
@media (min-width: 80rem) {
|
| 714 |
+
.breakout-wide {
|
| 715 |
+
--breakout-w: min(100vw - 5rem, 85rem);
|
| 716 |
+
width: var(--breakout-w);
|
| 717 |
+
margin-left: calc(50% - var(--breakout-w) / 2);
|
| 718 |
+
}
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
/* ------------------------------------------------------------------ */
|
| 722 |
+
/* Nav sweep — a phosphor tracer riding the sticky nav's bottom */
|
| 723 |
+
/* hairline; the instrument is always on */
|
| 724 |
+
/* ------------------------------------------------------------------ */
|
| 725 |
+
|
| 726 |
+
.nav-sweep {
|
| 727 |
+
position: absolute;
|
| 728 |
+
inset-inline: 0;
|
| 729 |
+
bottom: -1px;
|
| 730 |
+
height: 1px;
|
| 731 |
+
overflow: hidden;
|
| 732 |
+
pointer-events: none;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.nav-sweep::before {
|
| 736 |
+
content: "";
|
| 737 |
+
position: absolute;
|
| 738 |
+
top: 0;
|
| 739 |
+
left: 0;
|
| 740 |
+
height: 100%;
|
| 741 |
+
width: 140px;
|
| 742 |
+
background: linear-gradient(
|
| 743 |
+
90deg,
|
| 744 |
+
transparent,
|
| 745 |
+
color-mix(in srgb, var(--accent) 55%, transparent),
|
| 746 |
+
transparent
|
| 747 |
+
);
|
| 748 |
+
animation: nav-sweep 9s linear infinite;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
@keyframes nav-sweep {
|
| 752 |
+
from {
|
| 753 |
+
transform: translateX(-140px);
|
| 754 |
+
}
|
| 755 |
+
to {
|
| 756 |
+
transform: translateX(100vw);
|
| 757 |
+
}
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
/* ------------------------------------------------------------------ */
|
| 761 |
+
/* Reduced motion: everything settles instantly */
|
| 762 |
+
/* ------------------------------------------------------------------ */
|
| 763 |
+
|
| 764 |
+
@media (prefers-reduced-motion: reduce) {
|
| 765 |
+
*,
|
| 766 |
+
*::before,
|
| 767 |
+
*::after {
|
| 768 |
+
animation-duration: 0.001ms !important;
|
| 769 |
+
animation-delay: 0ms !important;
|
| 770 |
+
animation-iteration-count: 1 !important;
|
| 771 |
+
animation-timeline: auto !important;
|
| 772 |
+
transition-duration: 0.001ms !important;
|
| 773 |
+
scroll-behavior: auto !important;
|
| 774 |
+
}
|
| 775 |
+
/* ambient effects are pure ambience — remove them when motion is reduced */
|
| 776 |
+
.pulse-sweep {
|
| 777 |
+
visibility: hidden;
|
| 778 |
+
}
|
| 779 |
+
.nav-sweep {
|
| 780 |
+
display: none;
|
| 781 |
+
}
|
| 782 |
+
/* ambient glow drift stops; the static light wash may remain */
|
| 783 |
+
.ambient-glow {
|
| 784 |
+
animation: none;
|
| 785 |
+
will-change: auto;
|
| 786 |
+
}
|
| 787 |
+
/* skeletons settle to a static placeholder */
|
| 788 |
+
.skeleton {
|
| 789 |
+
animation: none;
|
| 790 |
+
background-image: none;
|
| 791 |
+
}
|
| 792 |
+
/* grain and scanline remain as they don't animate */
|
| 793 |
+
body::before,
|
| 794 |
+
body::after {
|
| 795 |
+
display: none;
|
| 796 |
+
}
|
| 797 |
+
/* spotlight effects collapse to static */
|
| 798 |
+
.spotlight-card::before {
|
| 799 |
+
display: none;
|
| 800 |
+
}
|
| 801 |
+
/* hover lifts settle */
|
| 802 |
+
.hover-lift:hover {
|
| 803 |
+
transform: none;
|
| 804 |
+
}
|
| 805 |
+
/* glows collapse */
|
| 806 |
+
.glow-accent-subtle {
|
| 807 |
+
box-shadow: none;
|
| 808 |
+
}
|
| 809 |
+
/* scroll animations resolve immediately */
|
| 810 |
+
.scroll-animate,
|
| 811 |
+
.scroll-scale,
|
| 812 |
+
.scroll-slide-left,
|
| 813 |
+
.scroll-slide-right,
|
| 814 |
+
.scroll-slide-down,
|
| 815 |
+
.scroll-fade {
|
| 816 |
+
opacity: 1;
|
| 817 |
+
transform: none;
|
| 818 |
+
}
|
| 819 |
+
.scroll-stagger > * {
|
| 820 |
+
opacity: 1;
|
| 821 |
+
transform: none;
|
| 822 |
+
}
|
| 823 |
}
|
src/app/icon.svg
ADDED
|
|
src/app/layout.tsx
CHANGED
|
@@ -1,33 +1,140 @@
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import "./globals.css";
|
| 4 |
|
| 5 |
-
const
|
| 6 |
-
|
|
|
|
| 7 |
subsets: ["latin"],
|
|
|
|
| 8 |
});
|
| 9 |
|
| 10 |
-
const
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
subsets: ["latin"],
|
|
|
|
| 13 |
});
|
| 14 |
|
| 15 |
export const metadata: Metadata = {
|
| 16 |
-
title:
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
};
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
export default function RootLayout({
|
| 21 |
children,
|
| 22 |
}: Readonly<{
|
| 23 |
children: React.ReactNode;
|
| 24 |
}>) {
|
|
|
|
| 25 |
return (
|
| 26 |
<html
|
| 27 |
lang="en"
|
| 28 |
-
className={`${
|
| 29 |
>
|
| 30 |
-
<body className="min-h-full flex
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
</html>
|
| 32 |
);
|
| 33 |
}
|
|
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import { Be_Vietnam_Pro, IBM_Plex_Mono, Instrument_Serif } from "next/font/google";
|
| 4 |
+
import SiteNav from "@/components/SiteNav";
|
| 5 |
+
import AmbientEffects from "@/components/ui/AmbientEffects";
|
| 6 |
+
import { getDataset, HARNESS_VERSION, NORMALIZER_VERSION } from "@/lib/data";
|
| 7 |
import "./globals.css";
|
| 8 |
|
| 9 |
+
const display = Instrument_Serif({
|
| 10 |
+
weight: "400",
|
| 11 |
+
style: ["normal", "italic"],
|
| 12 |
subsets: ["latin"],
|
| 13 |
+
variable: "--font-display",
|
| 14 |
});
|
| 15 |
|
| 16 |
+
const body = Be_Vietnam_Pro({
|
| 17 |
+
weight: ["400", "500", "600", "700"],
|
| 18 |
+
subsets: ["vietnamese", "latin"],
|
| 19 |
+
variable: "--font-body",
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
const mono = IBM_Plex_Mono({
|
| 23 |
+
weight: ["400", "500", "600"],
|
| 24 |
subsets: ["latin"],
|
| 25 |
+
variable: "--font-mono",
|
| 26 |
});
|
| 27 |
|
| 28 |
export const metadata: Metadata = {
|
| 29 |
+
title: {
|
| 30 |
+
default: "MedASR Bench — Vietnamese Medical ASR Benchmark",
|
| 31 |
+
template: "%s · MedASR Bench",
|
| 32 |
+
},
|
| 33 |
+
description:
|
| 34 |
+
"Public benchmark for Vietnamese medical speech recognition with English code-switching, anchored on ViMedCSS. Part of the Clinical Scribe program.",
|
| 35 |
};
|
| 36 |
|
| 37 |
+
const footerLink =
|
| 38 |
+
"transition-colors duration-200 ease-out hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70";
|
| 39 |
+
|
| 40 |
export default function RootLayout({
|
| 41 |
children,
|
| 42 |
}: Readonly<{
|
| 43 |
children: React.ReactNode;
|
| 44 |
}>) {
|
| 45 |
+
const dataset = getDataset();
|
| 46 |
return (
|
| 47 |
<html
|
| 48 |
lang="en"
|
| 49 |
+
className={`${display.variable} ${body.variable} ${mono.variable} h-full antialiased`}
|
| 50 |
>
|
| 51 |
+
<body className="flex min-h-full flex-col scroll-smooth">
|
| 52 |
+
<AmbientEffects />
|
| 53 |
+
<SiteNav />
|
| 54 |
+
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 md:px-6">
|
| 55 |
+
{children}
|
| 56 |
+
</main>
|
| 57 |
+
<footer className="edge-accent hairline-t mt-16">
|
| 58 |
+
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-7 md:grid-cols-3 md:px-6">
|
| 59 |
+
{/* wordmark + program */}
|
| 60 |
+
<div className="flex flex-col gap-1.5">
|
| 61 |
+
<p className="font-display text-lg leading-none text-text">
|
| 62 |
+
<em className="italic">Med</em>ASR Bench
|
| 63 |
+
</p>
|
| 64 |
+
<p className="text-[12px] leading-relaxed text-muted">
|
| 65 |
+
Part of the <span className="text-text">Clinical Scribe</span>{" "}
|
| 66 |
+
program — Vietnamese medical ASR, measured in the open.
|
| 67 |
+
</p>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
{/* navigation echo + citation */}
|
| 71 |
+
<nav aria-label="Footer" className="text-[12px] text-muted">
|
| 72 |
+
<ul className="grid grid-cols-2 gap-x-6 gap-y-1.5">
|
| 73 |
+
<li>
|
| 74 |
+
<Link href="/" className={footerLink}>
|
| 75 |
+
Leaderboard
|
| 76 |
+
</Link>
|
| 77 |
+
</li>
|
| 78 |
+
<li>
|
| 79 |
+
<Link href="/compare" className={footerLink}>
|
| 80 |
+
Compare
|
| 81 |
+
</Link>
|
| 82 |
+
</li>
|
| 83 |
+
<li>
|
| 84 |
+
<Link href="/datasets" className={footerLink}>
|
| 85 |
+
Datasets
|
| 86 |
+
</Link>
|
| 87 |
+
</li>
|
| 88 |
+
<li>
|
| 89 |
+
<Link href="/methodology" className={footerLink}>
|
| 90 |
+
Methodology
|
| 91 |
+
</Link>
|
| 92 |
+
</li>
|
| 93 |
+
<li>
|
| 94 |
+
<a
|
| 95 |
+
href={`https://arxiv.org/abs/${dataset.arxiv}`}
|
| 96 |
+
target="_blank"
|
| 97 |
+
rel="noreferrer"
|
| 98 |
+
className={`num ${footerLink}`}
|
| 99 |
+
>
|
| 100 |
+
Cite · arXiv:{dataset.arxiv}
|
| 101 |
+
</a>
|
| 102 |
+
</li>
|
| 103 |
+
<li>
|
| 104 |
+
<a
|
| 105 |
+
href={`https://huggingface.co/datasets/${dataset.hf}`}
|
| 106 |
+
target="_blank"
|
| 107 |
+
rel="noreferrer"
|
| 108 |
+
className={`num ${footerLink}`}
|
| 109 |
+
>
|
| 110 |
+
HF · {dataset.hf}
|
| 111 |
+
</a>
|
| 112 |
+
</li>
|
| 113 |
+
</ul>
|
| 114 |
+
</nav>
|
| 115 |
+
|
| 116 |
+
{/* mono colophon */}
|
| 117 |
+
<dl className="num flex flex-col gap-1.5 text-[11px] tracking-wide text-muted/80 md:text-right">
|
| 118 |
+
<div>
|
| 119 |
+
<dt className="sr-only">Normalizer</dt>
|
| 120 |
+
<dd>normalizer {NORMALIZER_VERSION}</dd>
|
| 121 |
+
</div>
|
| 122 |
+
<div>
|
| 123 |
+
<dt className="sr-only">Harness</dt>
|
| 124 |
+
<dd>{HARNESS_VERSION}</dd>
|
| 125 |
+
</div>
|
| 126 |
+
<div>
|
| 127 |
+
<dt className="sr-only">License</dt>
|
| 128 |
+
<dd>benchmark data {dataset.license}</dd>
|
| 129 |
+
</div>
|
| 130 |
+
<div>
|
| 131 |
+
<dt className="sr-only">API snapshot policy</dt>
|
| 132 |
+
<dd>API results are date-stamped snapshots</dd>
|
| 133 |
+
</div>
|
| 134 |
+
</dl>
|
| 135 |
+
</div>
|
| 136 |
+
</footer>
|
| 137 |
+
</body>
|
| 138 |
</html>
|
| 139 |
);
|
| 140 |
}
|
src/app/loading.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Skeleton,
|
| 3 |
+
SkeletonStats,
|
| 4 |
+
SkeletonTable,
|
| 5 |
+
SkeletonText,
|
| 6 |
+
} from "@/components/ui/LoadingSkeleton";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Route-level loading state for the leaderboard home. Mirrors the page
|
| 10 |
+
* skeleton-for-skeleton: kicker + headline + lede, dataset chip strip,
|
| 11 |
+
* anchor-dataset stats band, then filter controls and the table. All shimmer
|
| 12 |
+
* comes from the `.skeleton` class, which the global prefers-reduced-motion
|
| 13 |
+
* override settles to a static placeholder.
|
| 14 |
+
*/
|
| 15 |
+
export default function Loading() {
|
| 16 |
+
return (
|
| 17 |
+
<div aria-busy="true" className="flex flex-col gap-12 md:gap-16">
|
| 18 |
+
<p role="status" className="sr-only">
|
| 19 |
+
Loading the leaderboard…
|
| 20 |
+
</p>
|
| 21 |
+
|
| 22 |
+
{/* ---------- hero ---------- */}
|
| 23 |
+
<section aria-hidden>
|
| 24 |
+
<Skeleton className="h-3" width={220} />
|
| 25 |
+
<Skeleton className="mt-5 h-12 md:h-14" width="min(38rem, 95%)" />
|
| 26 |
+
<Skeleton className="mt-3 h-12 md:h-14" width="min(22rem, 65%)" />
|
| 27 |
+
<SkeletonText className="mt-6 max-w-xl" lines={2} lastLineWidth="45%" />
|
| 28 |
+
{/* dataset chip strip */}
|
| 29 |
+
<div className="mt-8 flex flex-wrap items-center gap-2">
|
| 30 |
+
<Skeleton className="h-3.5" width={64} />
|
| 31 |
+
<Skeleton className="h-[26px]" width={92} />
|
| 32 |
+
<Skeleton className="h-[26px]" width={108} />
|
| 33 |
+
<Skeleton className="h-[26px]" width={132} />
|
| 34 |
+
</div>
|
| 35 |
+
</section>
|
| 36 |
+
|
| 37 |
+
{/* ---------- anchor-dataset stats band ---------- */}
|
| 38 |
+
<section aria-hidden className="flex flex-col gap-px">
|
| 39 |
+
<Skeleton className="h-9 rounded-b-none" />
|
| 40 |
+
<SkeletonStats
|
| 41 |
+
className="grid-cols-2 rounded-t-none md:grid-cols-4"
|
| 42 |
+
count={4}
|
| 43 |
+
/>
|
| 44 |
+
</section>
|
| 45 |
+
|
| 46 |
+
{/* ---------- leaderboard: title + controls + table ---------- */}
|
| 47 |
+
<section aria-hidden className="flex flex-col gap-4">
|
| 48 |
+
<Skeleton className="h-8" width="min(24rem, 80%)" />
|
| 49 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 50 |
+
<Skeleton className="h-7" width={140} />
|
| 51 |
+
<Skeleton className="h-7" width={176} />
|
| 52 |
+
<Skeleton className="h-7" width={232} />
|
| 53 |
+
<Skeleton className="ml-auto h-8 w-full max-w-52" />
|
| 54 |
+
</div>
|
| 55 |
+
<SkeletonTable rows={10} columns={8} />
|
| 56 |
+
</section>
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
}
|
src/app/methodology/page.tsx
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import { AlertTriangle, ArrowRight } from "lucide-react";
|
| 4 |
+
import Badge from "@/components/ui/Badge";
|
| 5 |
+
import CIBar from "@/components/ui/CIBar";
|
| 6 |
+
import Card from "@/components/ui/Card";
|
| 7 |
+
import ScrollReveal, { Stagger } from "@/components/ui/ScrollReveal";
|
| 8 |
+
import SectionHeading from "@/components/ui/SectionHeading";
|
| 9 |
+
import ManifestCard from "@/components/docs/ManifestCard";
|
| 10 |
+
import MetricDefCard from "@/components/docs/MetricDefCard";
|
| 11 |
+
import OnboardingCriteria from "@/components/docs/OnboardingCriteria";
|
| 12 |
+
import ProvenanceLadder from "@/components/docs/ProvenanceLadder";
|
| 13 |
+
import TracksTable from "@/components/docs/TracksTable";
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "Methodology",
|
| 17 |
+
description:
|
| 18 |
+
"Metric definitions, evaluation tracks, provenance classes, reproducibility policy, and dataset onboarding criteria for MedASR Bench.",
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const NORMALIZER_STEPS = [
|
| 22 |
+
"Unicode NFC normalization",
|
| 23 |
+
"Lowercase",
|
| 24 |
+
"Punctuation → space",
|
| 25 |
+
"Collapse repeated whitespace",
|
| 26 |
+
"Digits kept as-is",
|
| 27 |
+
];
|
| 28 |
+
|
| 29 |
+
export default function MethodologyPage() {
|
| 30 |
+
return (
|
| 31 |
+
<div className="spring-up flex flex-col gap-12 md:gap-16">
|
| 32 |
+
{/* 1 · Header */}
|
| 33 |
+
<SectionHeading
|
| 34 |
+
as="h1"
|
| 35 |
+
eyebrow="04 · Methodology"
|
| 36 |
+
title="The instrument’s calibration sheet"
|
| 37 |
+
sub="What every metric means, what each track allows, how much to trust a row, how to reproduce it — and what a dataset must clear to join the hub. Every number on this platform is a measurement; this page defines the measurement, identically for every dataset."
|
| 38 |
+
/>
|
| 39 |
+
|
| 40 |
+
{/* 2 · Metrics */}
|
| 41 |
+
<section className="flex flex-col gap-5">
|
| 42 |
+
<SectionHeading
|
| 43 |
+
eyebrow="04.1 · Metrics"
|
| 44 |
+
title="Six numbers per run"
|
| 45 |
+
sub="All metrics are computed by the platform harness from raw transcripts after normalization — WER-family values are percentages, lower is better unless marked otherwise."
|
| 46 |
+
/>
|
| 47 |
+
<Stagger className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
| 48 |
+
<MetricDefCard
|
| 49 |
+
abbr="WER"
|
| 50 |
+
name="Word error rate"
|
| 51 |
+
formula={"WER = (S + D + I) / N × 100"}
|
| 52 |
+
direction="lower is better"
|
| 53 |
+
>
|
| 54 |
+
The headline metric: word-level edit distance against the
|
| 55 |
+
human-verified reference, after normalizer v0.1. S, D, I are
|
| 56 |
+
substitutions, deletions, insertions; N is reference words.
|
| 57 |
+
</MetricDefCard>
|
| 58 |
+
<MetricDefCard
|
| 59 |
+
abbr="CER"
|
| 60 |
+
name="Character error rate"
|
| 61 |
+
formula={"CER = (S + D + I) / N_chars × 100"}
|
| 62 |
+
direction="lower is better"
|
| 63 |
+
>
|
| 64 |
+
The same edit distance at character level — robust to tokenization
|
| 65 |
+
choices and a fairer lens on Vietnamese diacritics.
|
| 66 |
+
</MetricDefCard>
|
| 67 |
+
<MetricDefCard
|
| 68 |
+
abbr="CS-WER"
|
| 69 |
+
name="Code-switch word error rate"
|
| 70 |
+
formula={"WER over tokens ∈ code-switched spans"}
|
| 71 |
+
direction="lower is better"
|
| 72 |
+
>
|
| 73 |
+
WER restricted to code-switched English spans — the clinical
|
| 74 |
+
failure mode this benchmark exists to expose. Vietnamese-specialized
|
| 75 |
+
models win overall WER yet lose here.
|
| 76 |
+
</MetricDefCard>
|
| 77 |
+
<MetricDefCard
|
| 78 |
+
abbr="N-WER"
|
| 79 |
+
name="Native word error rate"
|
| 80 |
+
formula={"WER over Vietnamese-only tokens"}
|
| 81 |
+
direction="lower is better"
|
| 82 |
+
>
|
| 83 |
+
The mirror image of CS-WER: WER over Vietnamese-only tokens with
|
| 84 |
+
code-switched spans excluded. Multilingual models invert the
|
| 85 |
+
pattern — strong here is rare when CS-WER is strong too.
|
| 86 |
+
</MetricDefCard>
|
| 87 |
+
<MetricDefCard
|
| 88 |
+
abbr="MTR"
|
| 89 |
+
name="Medical term recall"
|
| 90 |
+
formula={"MTR = |terms recovered| / |terms in reference|"}
|
| 91 |
+
direction="higher is better"
|
| 92 |
+
>
|
| 93 |
+
Share of reference medical terms recovered in the hypothesis. The
|
| 94 |
+
exact tier requires verbatim recovery; the fuzzy tier tolerates
|
| 95 |
+
minor surface variation.
|
| 96 |
+
</MetricDefCard>
|
| 97 |
+
<MetricDefCard
|
| 98 |
+
abbr="RTFx"
|
| 99 |
+
name="Real-time factor (inverted)"
|
| 100 |
+
formula={"RTFx = audio seconds / wall-clock seconds"}
|
| 101 |
+
direction="higher is better"
|
| 102 |
+
>
|
| 103 |
+
Throughput during the platform run on the manifest’s declared
|
| 104 |
+
hardware. Above 1.0 means faster than real time.
|
| 105 |
+
</MetricDefCard>
|
| 106 |
+
</Stagger>
|
| 107 |
+
|
| 108 |
+
<div className="grid gap-4 lg:grid-cols-2">
|
| 109 |
+
<Card className="flex flex-col gap-3">
|
| 110 |
+
<h3 className="font-display text-xl text-text">
|
| 111 |
+
Confidence intervals & the SOTA rule
|
| 112 |
+
</h3>
|
| 113 |
+
<p className="text-[13px] leading-relaxed text-muted">
|
| 114 |
+
Headline metrics carry bootstrap 95% confidence intervals from
|
| 115 |
+
utterance-level resampling, rendered as whisker micro-bars beside
|
| 116 |
+
every value. The policy is strict:{" "}
|
| 117 |
+
<strong className="font-medium text-text">
|
| 118 |
+
no SOTA badge without significance
|
| 119 |
+
</strong>{" "}
|
| 120 |
+
— a model is never promoted while its CI overlaps the
|
| 121 |
+
incumbent’s.
|
| 122 |
+
</p>
|
| 123 |
+
<div className="hairline flex flex-col gap-2 rounded-sm bg-surface-2 px-4 py-3">
|
| 124 |
+
<p className="num text-[10px] uppercase tracking-[0.16em] text-muted">
|
| 125 |
+
Illustration — overlapping CIs, no badge awarded
|
| 126 |
+
</p>
|
| 127 |
+
<div className="flex items-center gap-3">
|
| 128 |
+
<span className="num w-24 text-[12px] text-muted">incumbent</span>
|
| 129 |
+
<span className="num text-[13px] text-text">24.80</span>
|
| 130 |
+
<CIBar value={24.8} ci={[23.9, 25.7]} />
|
| 131 |
+
</div>
|
| 132 |
+
<div className="flex items-center gap-3">
|
| 133 |
+
<span className="num w-24 text-[12px] text-muted">candidate</span>
|
| 134 |
+
<span className="num text-[13px] text-text">23.90</span>
|
| 135 |
+
<CIBar value={23.9} ci={[23.0, 24.8]} />
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</Card>
|
| 139 |
+
|
| 140 |
+
<Card className="flex flex-col gap-3">
|
| 141 |
+
<h3 className="font-display text-xl text-text">Normalizer v0.1</h3>
|
| 142 |
+
<p className="text-[13px] leading-relaxed text-muted">
|
| 143 |
+
Both reference and hypothesis pass through the same normalizer
|
| 144 |
+
before scoring. The version is recorded in every run manifest.
|
| 145 |
+
</p>
|
| 146 |
+
<ol className="num flex flex-col gap-1.5 text-[12px] text-text">
|
| 147 |
+
{NORMALIZER_STEPS.map((step, i) => (
|
| 148 |
+
<li key={step} className="flex items-baseline gap-3">
|
| 149 |
+
<span className="text-muted">{String(i + 1).padStart(2, "0")}</span>
|
| 150 |
+
{step}
|
| 151 |
+
</li>
|
| 152 |
+
))}
|
| 153 |
+
</ol>
|
| 154 |
+
<p className="mt-auto flex items-start gap-2 border-t border-warn/20 pt-3 text-[12px] leading-relaxed text-warn">
|
| 155 |
+
<AlertTriangle size={14} aria-hidden className="mt-0.5 shrink-0" />
|
| 156 |
+
WER is only comparable within a normalizer version. Cross-version
|
| 157 |
+
numbers are never ranked against each other.
|
| 158 |
+
</p>
|
| 159 |
+
</Card>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<Stagger className="grid gap-4 md:grid-cols-3">
|
| 163 |
+
<MetricDefCard abbr="MTR-fb" name="Frequency-bucketed MTR" roadmap>
|
| 164 |
+
Term recall split by training-frequency buckets — separates
|
| 165 |
+
memorized vocabulary from genuine generalization.
|
| 166 |
+
</MetricDefCard>
|
| 167 |
+
<MetricDefCard abbr="CTER" name="Critical term error rate" roadmap>
|
| 168 |
+
Error rate over confusable drug-name pairs, where a substitution is
|
| 169 |
+
a patient-safety event rather than a typo.
|
| 170 |
+
</MetricDefCard>
|
| 171 |
+
<MetricDefCard abbr="LAT / $" name="API latency & cost" roadmap>
|
| 172 |
+
p50/p90 latency and dollars per audio-hour for commercial APIs,
|
| 173 |
+
captured during date-stamped snapshot runs.
|
| 174 |
+
</MetricDefCard>
|
| 175 |
+
</Stagger>
|
| 176 |
+
</section>
|
| 177 |
+
|
| 178 |
+
{/* 3 · Tracks */}
|
| 179 |
+
<ScrollReveal>
|
| 180 |
+
<section className="flex flex-col gap-5">
|
| 181 |
+
<SectionHeading
|
| 182 |
+
eyebrow="04.2 · Tracks"
|
| 183 |
+
title="Six tracks, one axis: allowed signal"
|
| 184 |
+
sub="A track defines exactly what a system may know about a dataset's audio and labels before it transcribes a single clip. Rows only compete within their track, on the same dataset."
|
| 185 |
+
/>
|
| 186 |
+
<TracksTable />
|
| 187 |
+
</section>
|
| 188 |
+
</ScrollReveal>
|
| 189 |
+
|
| 190 |
+
{/* 4 · Provenance */}
|
| 191 |
+
<ScrollReveal>
|
| 192 |
+
<section className="flex flex-col gap-5">
|
| 193 |
+
<SectionHeading
|
| 194 |
+
eyebrow="04.3 · Provenance"
|
| 195 |
+
title="Trust is a ladder, not a boolean"
|
| 196 |
+
sub="Every row declares where its numbers came from, and the chip follows the number everywhere it appears."
|
| 197 |
+
/>
|
| 198 |
+
<ProvenanceLadder />
|
| 199 |
+
<Card className="flex flex-col gap-2">
|
| 200 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 201 |
+
<h3 className="font-display text-xl text-text">
|
| 202 |
+
Commercial APIs are snapshots
|
| 203 |
+
</h3>
|
| 204 |
+
<Badge variant="outline">date-stamped</Badge>
|
| 205 |
+
<Badge variant="ghost">quarterly drift re-runs · roadmap</Badge>
|
| 206 |
+
</div>
|
| 207 |
+
<p className="text-[13px] leading-relaxed text-muted">
|
| 208 |
+
API systems change behind stable names, so their rows are
|
| 209 |
+
date-stamped snapshots of the service on the day of the run.
|
| 210 |
+
Quarterly drift re-runs are on the roadmap; superseded snapshots
|
| 211 |
+
stay visible with their dates instead of being overwritten.
|
| 212 |
+
</p>
|
| 213 |
+
</Card>
|
| 214 |
+
</section>
|
| 215 |
+
</ScrollReveal>
|
| 216 |
+
|
| 217 |
+
{/* 5 · Reproducibility */}
|
| 218 |
+
<ScrollReveal>
|
| 219 |
+
<section className="flex flex-col gap-5">
|
| 220 |
+
<SectionHeading
|
| 221 |
+
eyebrow="04.4 · Reproducibility"
|
| 222 |
+
title="Every number carries its manifest"
|
| 223 |
+
sub="A result you cannot reproduce is an anecdote. Platform-verified runs publish the complete manifest below."
|
| 224 |
+
/>
|
| 225 |
+
<ManifestCard />
|
| 226 |
+
<Card className="flex flex-col gap-3">
|
| 227 |
+
<h3 className="font-display text-xl text-text">Anti-gaming policy</h3>
|
| 228 |
+
<ul className="flex flex-col gap-2.5 text-[13px] leading-relaxed text-muted">
|
| 229 |
+
<li className="flex gap-3">
|
| 230 |
+
<span aria-hidden className="num text-muted/60">01</span>
|
| 231 |
+
Provenance is declared per row and rendered beside the number
|
| 232 |
+
everywhere it appears — it never washes out in aggregation.
|
| 233 |
+
</li>
|
| 234 |
+
<li className="flex gap-3">
|
| 235 |
+
<span aria-hidden className="num text-muted/60">02</span>
|
| 236 |
+
Contamination flags are permanent and scoped per dataset. On
|
| 237 |
+
ViMedCSS, Gemini-family models seeded the labels and carry the
|
| 238 |
+
amber flag forever, regardless of later scores.
|
| 239 |
+
</li>
|
| 240 |
+
<li className="flex gap-3">
|
| 241 |
+
<span aria-hidden className="num text-muted/60">03</span>
|
| 242 |
+
Where a dataset’s test labels are public — as ViMedCSS’s are —
|
| 243 |
+
self-reported numbers are honor-system today. A private canary
|
| 244 |
+
subset is on the roadmap to detect test-set training.
|
| 245 |
+
</li>
|
| 246 |
+
<li className="flex gap-3">
|
| 247 |
+
<span aria-hidden className="num text-muted/60">04</span>
|
| 248 |
+
Commercial APIs are evaluated as date-stamped snapshots; history
|
| 249 |
+
is preserved, never overwritten.
|
| 250 |
+
</li>
|
| 251 |
+
</ul>
|
| 252 |
+
</Card>
|
| 253 |
+
</section>
|
| 254 |
+
</ScrollReveal>
|
| 255 |
+
|
| 256 |
+
{/* 6 · Dataset onboarding */}
|
| 257 |
+
<ScrollReveal>
|
| 258 |
+
<section id="onboarding" className="flex scroll-mt-28 flex-col gap-5">
|
| 259 |
+
<SectionHeading
|
| 260 |
+
eyebrow="04.5 · Onboarding"
|
| 261 |
+
title="What it takes to join the hub"
|
| 262 |
+
sub="MedASR Bench is a multi-dataset hub: any Vietnamese medical speech corpus clearing six criteria mounts into the same harness and becomes directly comparable. ViMedCSS cleared this bar first; VietMed and the private clinic set are next."
|
| 263 |
+
/>
|
| 264 |
+
<OnboardingCriteria />
|
| 265 |
+
<Card className="flex flex-col gap-3">
|
| 266 |
+
<h3 className="font-display text-xl text-text">
|
| 267 |
+
Snapshots & versioning
|
| 268 |
+
</h3>
|
| 269 |
+
<ul className="flex flex-col gap-2.5 text-[13px] leading-relaxed text-muted">
|
| 270 |
+
<li className="flex gap-3">
|
| 271 |
+
<span aria-hidden className="num text-muted/60">01</span>
|
| 272 |
+
<span>
|
| 273 |
+
Datasets mount as frozen, versioned snapshots (e.g.{" "}
|
| 274 |
+
<span className="num text-text">vimedcss v1</span>). Split
|
| 275 |
+
membership never changes within a version.
|
| 276 |
+
</span>
|
| 277 |
+
</li>
|
| 278 |
+
<li className="flex gap-3">
|
| 279 |
+
<span aria-hidden className="num text-muted/60">02</span>
|
| 280 |
+
Any revision — new clips, relabeled spans, re-frozen splits —
|
| 281 |
+
creates a new dataset version and re-runs the affected
|
| 282 |
+
leaderboards. Old numbers stay visible, scoped to their version,
|
| 283 |
+
never silently edited.
|
| 284 |
+
</li>
|
| 285 |
+
<li className="flex gap-3">
|
| 286 |
+
<span aria-hidden className="num text-muted/60">03</span>
|
| 287 |
+
Every run manifest records the dataset version alongside the
|
| 288 |
+
normalizer and harness versions; numbers are only ranked within
|
| 289 |
+
matching versions.
|
| 290 |
+
</li>
|
| 291 |
+
</ul>
|
| 292 |
+
<Link
|
| 293 |
+
href="/datasets"
|
| 294 |
+
className="num mt-1 inline-flex items-center gap-1.5 self-start rounded-sm border border-line px-3 py-1.5 text-[12px] tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
| 295 |
+
>
|
| 296 |
+
See the dataset registry
|
| 297 |
+
<ArrowRight size={13} aria-hidden />
|
| 298 |
+
</Link>
|
| 299 |
+
</Card>
|
| 300 |
+
</section>
|
| 301 |
+
</ScrollReveal>
|
| 302 |
+
</div>
|
| 303 |
+
);
|
| 304 |
+
}
|
src/app/models/[slug]/loading.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Skeleton,
|
| 3 |
+
SkeletonCard,
|
| 4 |
+
SkeletonStats,
|
| 5 |
+
SkeletonText,
|
| 6 |
+
} from "@/components/ui/LoadingSkeleton";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Route-level skeleton for the model-detail page. Mirrors the real layout:
|
| 10 |
+
* identity header, dataset scope bar, the Results — <dataset> metric grid
|
| 11 |
+
* (with the frontier thumb column on lg), then generalization + manifest
|
| 12 |
+
* cards. Server-safe — all shimmer comes from the .skeleton CSS class, so
|
| 13 |
+
* prefers-reduced-motion collapses it to static blocks.
|
| 14 |
+
*/
|
| 15 |
+
export default function Loading() {
|
| 16 |
+
return (
|
| 17 |
+
<div
|
| 18 |
+
aria-busy="true"
|
| 19 |
+
aria-label="Loading model profile"
|
| 20 |
+
className="flex flex-col gap-10"
|
| 21 |
+
>
|
| 22 |
+
{/* 01 — model identity header */}
|
| 23 |
+
<div className="hairline-b pb-6">
|
| 24 |
+
<Skeleton className="mb-3 h-3" width={210} />
|
| 25 |
+
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
|
| 26 |
+
<Skeleton className="h-10 md:h-12" width="48%" />
|
| 27 |
+
<Skeleton className="h-6" width={132} />
|
| 28 |
+
</div>
|
| 29 |
+
<div className="mt-4 flex flex-wrap items-center gap-3">
|
| 30 |
+
<Skeleton className="h-4" width={96} />
|
| 31 |
+
<Skeleton className="h-5" width={72} />
|
| 32 |
+
<Skeleton className="h-5" width={150} />
|
| 33 |
+
</div>
|
| 34 |
+
<SkeletonText className="mt-4 max-w-2xl" lines={2} lastLineWidth="40%" />
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
{/* dataset scope bar */}
|
| 38 |
+
<div className="hairline flex items-center justify-between gap-6 rounded-sm px-4 py-3">
|
| 39 |
+
<div className="flex items-center gap-3">
|
| 40 |
+
<Skeleton className="h-3" width={72} />
|
| 41 |
+
<Skeleton className="h-5" width={110} />
|
| 42 |
+
<Skeleton className="hidden h-3 sm:block" width={220} />
|
| 43 |
+
</div>
|
| 44 |
+
<Skeleton className="h-3" width={96} />
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
{/* 02 — results grid + frontier thumb column */}
|
| 48 |
+
<div className="grid gap-8 lg:grid-cols-[1fr_272px] lg:items-start">
|
| 49 |
+
<div className="flex flex-col gap-4">
|
| 50 |
+
<div className="flex flex-wrap items-end justify-between gap-3">
|
| 51 |
+
<Skeleton className="h-6" width={230} />
|
| 52 |
+
<Skeleton className="h-7" width={140} />
|
| 53 |
+
</div>
|
| 54 |
+
<SkeletonStats
|
| 55 |
+
count={6}
|
| 56 |
+
className="grid-cols-2 md:grid-cols-3"
|
| 57 |
+
/>
|
| 58 |
+
<Skeleton className="h-3" width="70%" />
|
| 59 |
+
</div>
|
| 60 |
+
<div className="flex flex-col gap-4">
|
| 61 |
+
<Skeleton className="h-3" width={170} />
|
| 62 |
+
<div className="hairline rounded-sm bg-surface p-4">
|
| 63 |
+
<Skeleton className="h-44 w-full" />
|
| 64 |
+
<Skeleton className="mt-3 h-3" width="85%" />
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
{/* 03 — generalization strip */}
|
| 70 |
+
<div className="flex flex-col gap-4">
|
| 71 |
+
<Skeleton className="h-6" width={190} />
|
| 72 |
+
<div className="flex flex-wrap gap-3">
|
| 73 |
+
<SkeletonCard className="min-w-[160px] flex-none" lines={1} />
|
| 74 |
+
<SkeletonCard className="min-w-[160px] flex-none" lines={1} />
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{/* 04/05 — per-topic + run manifest */}
|
| 79 |
+
<SkeletonCard lines={3} />
|
| 80 |
+
<SkeletonCard lines={4} />
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
}
|
src/app/models/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
import { notFound } from "next/navigation";
|
| 4 |
+
import { ArrowLeft, ArrowUpRight } from "lucide-react";
|
| 5 |
+
import DatasetScope from "@/components/model/DatasetScope";
|
| 6 |
+
import FrontierThumb from "@/components/model/FrontierThumb";
|
| 7 |
+
import GeneralizationStrip from "@/components/model/GeneralizationStrip";
|
| 8 |
+
import MetricPanel from "@/components/model/MetricPanel";
|
| 9 |
+
import ModelHeader from "@/components/model/ModelHeader";
|
| 10 |
+
import RunManifest from "@/components/model/RunManifest";
|
| 11 |
+
import StatusNotice from "@/components/model/StatusNotice";
|
| 12 |
+
import TopicBreakdown from "@/components/model/TopicBreakdown";
|
| 13 |
+
import PulseLine from "@/components/ui/PulseLine";
|
| 14 |
+
import ScrollReveal from "@/components/ui/ScrollReveal";
|
| 15 |
+
import {
|
| 16 |
+
ANCHOR_DATASET_ID,
|
| 17 |
+
getDataset,
|
| 18 |
+
getModel,
|
| 19 |
+
getModels,
|
| 20 |
+
} from "@/lib/data";
|
| 21 |
+
import type { ModelRow } from "@/lib/types";
|
| 22 |
+
|
| 23 |
+
interface Props {
|
| 24 |
+
params: Promise<{ slug: string }>;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Model pages are generated over the anchor dataset's leaderboard. */
|
| 28 |
+
export function generateStaticParams() {
|
| 29 |
+
return getModels(ANCHOR_DATASET_ID).map((m) => ({ slug: m.slug }));
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* The leaderboard is closed (static JSON) — unknown slugs must be a real
|
| 33 |
+
HTTP 404, not a streamed 200 with not-found content. */
|
| 34 |
+
export const dynamicParams = false;
|
| 35 |
+
|
| 36 |
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
| 37 |
+
const { slug } = await params;
|
| 38 |
+
const model = getModel(slug);
|
| 39 |
+
if (!model) return { title: "Model" };
|
| 40 |
+
const dataset = getDataset();
|
| 41 |
+
return {
|
| 42 |
+
title: model.name,
|
| 43 |
+
description: `${model.name} (${model.org}) — results on the ${dataset.name} benchmark, MedASR Bench.`,
|
| 44 |
+
};
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const footerLink =
|
| 48 |
+
"num inline-flex items-center gap-1.5 rounded-sm border border-line px-3 py-1.5 text-[13px] tracking-wide transition-colors duration-200 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
| 49 |
+
|
| 50 |
+
/** Everything a first platform run writes — listed in the awaiting panel. */
|
| 51 |
+
const PENDING_CHIPS = [
|
| 52 |
+
"WER",
|
| 53 |
+
"CS-WER",
|
| 54 |
+
"N-WER",
|
| 55 |
+
"CER",
|
| 56 |
+
"MTR",
|
| 57 |
+
"RTFx",
|
| 58 |
+
"Test → Hard Δ",
|
| 59 |
+
"per-topic WER",
|
| 60 |
+
] as const;
|
| 61 |
+
|
| 62 |
+
function hasAnyMeasurement(model: ModelRow): boolean {
|
| 63 |
+
return (["test", "hard"] as const).some((s) =>
|
| 64 |
+
Object.values(model.metrics?.[s] ?? {}).some((v) => typeof v === "number"),
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function PageFooter({ slug }: { slug: string }) {
|
| 69 |
+
return (
|
| 70 |
+
<footer className="hairline-t flex flex-wrap items-center justify-between gap-4 pt-6">
|
| 71 |
+
<Link
|
| 72 |
+
href="/"
|
| 73 |
+
className={`${footerLink} text-muted hover:border-line hover:text-text`}
|
| 74 |
+
>
|
| 75 |
+
<ArrowLeft size={14} aria-hidden />
|
| 76 |
+
Back to leaderboard
|
| 77 |
+
</Link>
|
| 78 |
+
<Link
|
| 79 |
+
href={`/compare?m=${slug}`}
|
| 80 |
+
className={`${footerLink} text-text hover:border-accent/40 hover:text-accent`}
|
| 81 |
+
>
|
| 82 |
+
Compare this model
|
| 83 |
+
<ArrowUpRight size={14} aria-hidden />
|
| 84 |
+
</Link>
|
| 85 |
+
</footer>
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export default async function ModelPage({ params }: Props) {
|
| 90 |
+
const { slug } = await params;
|
| 91 |
+
const model = getModel(slug);
|
| 92 |
+
if (!model) notFound();
|
| 93 |
+
|
| 94 |
+
/* Anchor dataset (ViMedCSS) — the dataset every number on this page is
|
| 95 |
+
scoped to. It is live, so split statistics are guaranteed present. */
|
| 96 |
+
const dataset = getDataset();
|
| 97 |
+
const splits = dataset.splits;
|
| 98 |
+
if (!splits) {
|
| 99 |
+
throw new Error(
|
| 100 |
+
`Dataset "${dataset.id}" has no split statistics — model pages require a live dataset.`,
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
const models = getModels();
|
| 104 |
+
|
| 105 |
+
const showStatus =
|
| 106 |
+
model.provenance === "running" || model.provenance === "planned";
|
| 107 |
+
const hasParetoPoint =
|
| 108 |
+
typeof model.metrics?.test?.wer === "number" &&
|
| 109 |
+
typeof model.metrics?.test?.csWer === "number";
|
| 110 |
+
const measured = hasAnyMeasurement(model);
|
| 111 |
+
|
| 112 |
+
/* unmeasured pages collapse sections 02–04 into one composed panel and
|
| 113 |
+
promote the run manifest — the only real data — under the header */
|
| 114 |
+
if (!measured) {
|
| 115 |
+
const queue = models.filter((m) => !hasAnyMeasurement(m));
|
| 116 |
+
const queuePos = queue.findIndex((m) => m.slug === model.slug) + 1;
|
| 117 |
+
|
| 118 |
+
return (
|
| 119 |
+
<div className="flex flex-col gap-10">
|
| 120 |
+
<div className="stagger flex flex-col gap-10">
|
| 121 |
+
<ModelHeader model={model} />
|
| 122 |
+
|
| 123 |
+
<DatasetScope dataset={dataset} />
|
| 124 |
+
|
| 125 |
+
<RunManifest model={model} index="02" />
|
| 126 |
+
|
| 127 |
+
<section
|
| 128 |
+
aria-label="Awaiting first platform run"
|
| 129 |
+
className="flex flex-col gap-4"
|
| 130 |
+
>
|
| 131 |
+
{showStatus && (
|
| 132 |
+
<StatusNotice model={model} datasetName={dataset.name} />
|
| 133 |
+
)}
|
| 134 |
+
<div className="hairline depth-1 rounded-sm bg-surface px-5 py-6">
|
| 135 |
+
<div aria-hidden className="flex items-center">
|
| 136 |
+
<span className="h-px flex-1 bg-accent/20" />
|
| 137 |
+
<span className="pulse-dot size-2 shrink-0 rounded-full bg-accent" />
|
| 138 |
+
</div>
|
| 139 |
+
<p className="mt-4 font-display text-lg italic text-text">
|
| 140 |
+
awaiting first platform run
|
| 141 |
+
</p>
|
| 142 |
+
<p className="mt-1.5 max-w-xl text-[13px] leading-relaxed text-muted">
|
| 143 |
+
No trace exists for this model on {dataset.name} yet —
|
| 144 |
+
measurements, Test → Hard generalization and the
|
| 145 |
+
per-topic breakdown all land with its first run on{" "}
|
| 146 |
+
<span className="num">Test</span> +{" "}
|
| 147 |
+
<span className="num">Hard</span>.
|
| 148 |
+
</p>
|
| 149 |
+
<p className="num mt-3 text-[11px] tracking-[0.14em] text-muted uppercase">
|
| 150 |
+
Queue position {queuePos} of {queue.length} unmeasured systems
|
| 151 |
+
</p>
|
| 152 |
+
<ul
|
| 153 |
+
className="mt-4 flex flex-wrap gap-1.5"
|
| 154 |
+
aria-label="What the first run will populate"
|
| 155 |
+
>
|
| 156 |
+
{PENDING_CHIPS.map((c) => (
|
| 157 |
+
<li
|
| 158 |
+
key={c}
|
| 159 |
+
className="num rounded-sm border border-line/70 px-1.5 py-0.5 text-[11px] tracking-wide text-muted"
|
| 160 |
+
>
|
| 161 |
+
{c}
|
| 162 |
+
</li>
|
| 163 |
+
))}
|
| 164 |
+
</ul>
|
| 165 |
+
</div>
|
| 166 |
+
</section>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<PageFooter slug={model.slug} />
|
| 170 |
+
</div>
|
| 171 |
+
);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
return (
|
| 175 |
+
<div className="flex flex-col gap-10">
|
| 176 |
+
{/* above the fold: load-orchestrated — header first, then panels */}
|
| 177 |
+
<div className="stagger flex flex-col gap-10">
|
| 178 |
+
<ModelHeader model={model} />
|
| 179 |
+
|
| 180 |
+
<DatasetScope dataset={dataset} />
|
| 181 |
+
|
| 182 |
+
{showStatus && (
|
| 183 |
+
<StatusNotice model={model} datasetName={dataset.name} />
|
| 184 |
+
)}
|
| 185 |
+
|
| 186 |
+
{hasParetoPoint ? (
|
| 187 |
+
<div className="grid gap-8 lg:grid-cols-[1fr_272px] lg:items-start">
|
| 188 |
+
<MetricPanel
|
| 189 |
+
datasetName={dataset.name}
|
| 190 |
+
metrics={model.metrics}
|
| 191 |
+
splits={{ test: splits.test, hard: splits.hard }}
|
| 192 |
+
provenance={model.provenance}
|
| 193 |
+
/>
|
| 194 |
+
<FrontierThumb
|
| 195 |
+
models={models}
|
| 196 |
+
slug={model.slug}
|
| 197 |
+
datasetName={dataset.name}
|
| 198 |
+
/>
|
| 199 |
+
</div>
|
| 200 |
+
) : (
|
| 201 |
+
<MetricPanel
|
| 202 |
+
datasetName={dataset.name}
|
| 203 |
+
metrics={model.metrics}
|
| 204 |
+
splits={{ test: splits.test, hard: splits.hard }}
|
| 205 |
+
provenance={model.provenance}
|
| 206 |
+
/>
|
| 207 |
+
)}
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
{/* below the fold: scroll-revealed sections */}
|
| 211 |
+
<ScrollReveal direction="fade">
|
| 212 |
+
<PulseLine compact />
|
| 213 |
+
</ScrollReveal>
|
| 214 |
+
|
| 215 |
+
<ScrollReveal>
|
| 216 |
+
<GeneralizationStrip model={model} hardInfo={splits.hard} />
|
| 217 |
+
</ScrollReveal>
|
| 218 |
+
|
| 219 |
+
<ScrollReveal>
|
| 220 |
+
<TopicBreakdown model={model} />
|
| 221 |
+
</ScrollReveal>
|
| 222 |
+
|
| 223 |
+
<ScrollReveal>
|
| 224 |
+
<RunManifest model={model} />
|
| 225 |
+
</ScrollReveal>
|
| 226 |
+
|
| 227 |
+
<PageFooter slug={model.slug} />
|
| 228 |
+
</div>
|
| 229 |
+
);
|
| 230 |
+
}
|
src/app/page.tsx
CHANGED
|
@@ -1,65 +1,183 @@
|
|
| 1 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
export default function Home() {
|
| 4 |
return (
|
| 5 |
-
<div className="flex flex-col
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
To get started, edit the page.tsx file.
|
| 18 |
-
</h1>
|
| 19 |
-
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
| 20 |
-
Looking for a starting point or more instructions? Head over to{" "}
|
| 21 |
-
<a
|
| 22 |
-
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
| 23 |
-
className="font-medium text-zinc-950 dark:text-zinc-50"
|
| 24 |
>
|
| 25 |
-
|
| 26 |
-
</
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
className="font-medium text-zinc-950 dark:text-zinc-50"
|
| 31 |
>
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
src="/vercel.svg"
|
| 47 |
-
alt="Vercel logomark"
|
| 48 |
-
width={16}
|
| 49 |
-
height={16}
|
| 50 |
-
/>
|
| 51 |
-
Deploy Now
|
| 52 |
-
</a>
|
| 53 |
-
<a
|
| 54 |
-
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
| 55 |
-
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
| 56 |
-
target="_blank"
|
| 57 |
-
rel="noopener noreferrer"
|
| 58 |
>
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
);
|
| 65 |
}
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import PulseLine from "@/components/ui/PulseLine";
|
| 3 |
+
import DatasetChips from "@/components/ui/DatasetChips";
|
| 4 |
+
import ScrollReveal from "@/components/ui/ScrollReveal";
|
| 5 |
+
import HeroStats from "@/components/leaderboard/HeroStats";
|
| 6 |
+
import LeaderboardTable from "@/components/leaderboard/LeaderboardTable";
|
| 7 |
+
import {
|
| 8 |
+
ANCHOR_DATASET_ID,
|
| 9 |
+
getDataset,
|
| 10 |
+
getDatasets,
|
| 11 |
+
getLiveDatasets,
|
| 12 |
+
getModels,
|
| 13 |
+
HARNESS_VERSION,
|
| 14 |
+
NORMALIZER_VERSION,
|
| 15 |
+
} from "@/lib/data";
|
| 16 |
+
|
| 17 |
+
export const metadata: Metadata = {
|
| 18 |
+
title: "Leaderboard",
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export default function LeaderboardPage() {
|
| 22 |
+
const datasets = getDatasets();
|
| 23 |
+
const liveCount = getLiveDatasets().length;
|
| 24 |
+
/* The home leaderboard reads the anchor dataset. vimedcss is live, so its
|
| 25 |
+
measured fields are always present — hence the non-null assertions below. */
|
| 26 |
+
const dataset = getDataset(ANCHOR_DATASET_ID);
|
| 27 |
+
const models = getModels(ANCHOR_DATASET_ID);
|
| 28 |
|
|
|
|
| 29 |
return (
|
| 30 |
+
<div className="spring-up flex flex-col gap-12 md:gap-16">
|
| 31 |
+
{/* ---------- hero ---------- */}
|
| 32 |
+
<section aria-labelledby="hero-title" className="stagger-spring">
|
| 33 |
+
<div className="md:grid md:grid-cols-[1fr_auto] md:items-start md:gap-12">
|
| 34 |
+
<div>
|
| 35 |
+
<p className="num mb-4 text-[11px] tracking-[0.22em] text-muted uppercase spring-in">
|
| 36 |
+
MedASR Bench · Clinical Scribe Program
|
| 37 |
+
</p>
|
| 38 |
+
<h1
|
| 39 |
+
id="hero-title"
|
| 40 |
+
className="font-display max-w-3xl text-[clamp(2.6rem,11.5vw,3.75rem)] leading-[1.04] tracking-tight text-text spring-in"
|
| 41 |
+
style={{ animationDelay: "60ms" }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
>
|
| 43 |
+
The Vietnamese Medical <em className="italic">ASR</em> Benchmark
|
| 44 |
+
</h1>
|
| 45 |
+
<p
|
| 46 |
+
className="mt-5 max-w-2xl text-[15px] leading-relaxed text-muted md:text-base spring-in"
|
| 47 |
+
style={{ animationDelay: "120ms" }}
|
|
|
|
| 48 |
>
|
| 49 |
+
One harness measuring how speech recognition survives real
|
| 50 |
+
Vietnamese medical speech — and the English clinical terms
|
| 51 |
+
code-switched through it — across a growing registry of
|
| 52 |
+
datasets. Six metrics, bootstrap{" "}
|
| 53 |
+
<span className="num text-text">95%</span> CIs, transparent
|
| 54 |
+
provenance on every number.
|
| 55 |
+
</p>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
{/* instrument readout — quiet counterweight to the headline */}
|
| 59 |
+
<dl
|
| 60 |
+
aria-label="Platform readout"
|
| 61 |
+
className="num hidden flex-col gap-3 border-l border-line pl-6 text-[11px] tracking-[0.14em] text-muted md:flex spring-in"
|
| 62 |
+
style={{ animationDelay: "180ms" }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
>
|
| 64 |
+
<div className="flex items-baseline justify-between gap-8">
|
| 65 |
+
<dt className="uppercase text-muted/70">Datasets</dt>
|
| 66 |
+
<dd className="text-text">
|
| 67 |
+
{liveCount} live · {datasets.length - liveCount} onboarding
|
| 68 |
+
</dd>
|
| 69 |
+
</div>
|
| 70 |
+
<div className="flex items-baseline justify-between gap-8">
|
| 71 |
+
<dt className="uppercase text-muted/70">Anchor set</dt>
|
| 72 |
+
<dd className="text-text">{dataset.name} v1</dd>
|
| 73 |
+
</div>
|
| 74 |
+
<div className="flex items-baseline justify-between gap-8">
|
| 75 |
+
<dt className="uppercase text-muted/70">Normalizer</dt>
|
| 76 |
+
<dd className="text-text">{NORMALIZER_VERSION}</dd>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="flex items-baseline justify-between gap-8">
|
| 79 |
+
<dt className="uppercase text-muted/70">Harness</dt>
|
| 80 |
+
<dd className="text-text">{HARNESS_VERSION}</dd>
|
| 81 |
+
</div>
|
| 82 |
+
<div className="flex items-baseline justify-between gap-8">
|
| 83 |
+
<dt className="uppercase text-muted/70">Systems on anchor</dt>
|
| 84 |
+
<dd className="text-text">{models.length}</dd>
|
| 85 |
+
</div>
|
| 86 |
+
</dl>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
{/* dataset registry */}
|
| 90 |
+
<div
|
| 91 |
+
className="mt-8 flex flex-wrap items-center gap-3 spring-in"
|
| 92 |
+
style={{ animationDelay: "240ms" }}
|
| 93 |
+
>
|
| 94 |
+
<span className="num text-[11px] tracking-[0.18em] text-muted uppercase">
|
| 95 |
+
Datasets
|
| 96 |
+
</span>
|
| 97 |
+
<DatasetChips datasets={datasets} activeId={ANCHOR_DATASET_ID} />
|
| 98 |
</div>
|
| 99 |
+
</section>
|
| 100 |
+
|
| 101 |
+
{/* ---------- anchor-dataset stats band ---------- */}
|
| 102 |
+
<HeroStats
|
| 103 |
+
hours={dataset.hours!}
|
| 104 |
+
utterances={dataset.utterances!}
|
| 105 |
+
csTerms={dataset.csTerms!}
|
| 106 |
+
datasetName={dataset.name}
|
| 107 |
+
datasetHref={`/datasets/${dataset.id}`}
|
| 108 |
+
/>
|
| 109 |
+
|
| 110 |
+
{/* ---------- ECG divider ---------- */}
|
| 111 |
+
<PulseLine />
|
| 112 |
+
|
| 113 |
+
{/* ---------- editorial insight — scoped to the anchor dataset ---------- */}
|
| 114 |
+
<p className="font-display max-w-3xl text-xl leading-snug text-muted italic md:text-2xl spring-in">
|
| 115 |
+
On {dataset.name}, Vietnamese-specialized models win the overall word
|
| 116 |
+
error rate but lose the code-switched spans; multilingual models invert
|
| 117 |
+
the trade — <span className="text-text">adaptation closes both.</span>
|
| 118 |
+
</p>
|
| 119 |
+
|
| 120 |
+
{/* ---------- leaderboard ---------- */}
|
| 121 |
+
<LeaderboardTable
|
| 122 |
+
models={models}
|
| 123 |
+
datasetName={dataset.name}
|
| 124 |
+
hardSplit={dataset.splits!.hard}
|
| 125 |
+
/>
|
| 126 |
+
|
| 127 |
+
{/* ---------- footnotes ---------- */}
|
| 128 |
+
<ScrollReveal direction="fade">
|
| 129 |
+
<section aria-label="Methodology notes" className="hairline-t pt-6">
|
| 130 |
+
<ol className="flex max-w-[72ch] flex-col gap-3 text-[12px] leading-relaxed text-muted">
|
| 131 |
+
<li className="flex gap-3">
|
| 132 |
+
<span className="num shrink-0 text-muted/80">[1]</span>
|
| 133 |
+
<span>
|
| 134 |
+
All WER-family numbers were computed under text normalizer{" "}
|
| 135 |
+
<span className="num text-text">v0.1</span>. WER is only
|
| 136 |
+
comparable within a normalizer version; the version is recorded
|
| 137 |
+
per run.
|
| 138 |
+
</span>
|
| 139 |
+
</li>
|
| 140 |
+
<li className="flex gap-3">
|
| 141 |
+
<span className="num shrink-0 text-muted/80">[2]</span>
|
| 142 |
+
<span>
|
| 143 |
+
Paper-imported rows (dashed chips) reproduce numbers from{" "}
|
| 144 |
+
<span className="num">arXiv:{dataset.arxiv}</span> as published
|
| 145 |
+
— not yet re-verified on this platform.
|
| 146 |
+
</span>
|
| 147 |
+
</li>
|
| 148 |
+
<li className="flex gap-3">
|
| 149 |
+
<span className="num shrink-0 text-muted/80">[3]</span>
|
| 150 |
+
<span>
|
| 151 |
+
Contamination flag: {dataset.name} labels were seeded by Gemini
|
| 152 |
+
2.5 Pro before human verification (κ ={" "}
|
| 153 |
+
<span className="num">0.65</span>), so Gemini-family models
|
| 154 |
+
carry a permanent contamination flag. With public labels,
|
| 155 |
+
provenance declarations are honor-system for now; a private
|
| 156 |
+
canary subset is on the roadmap.
|
| 157 |
+
</span>
|
| 158 |
+
</li>
|
| 159 |
+
<li className="flex gap-3">
|
| 160 |
+
<span className="num shrink-0 text-muted/80">[4]</span>
|
| 161 |
+
<span>
|
| 162 |
+
Domain caveat: {dataset.name}{" "}
|
| 163 |
+
is lecture-style YouTube audio, not noisy two-speaker clinic
|
| 164 |
+
conversation. Scores here do not
|
| 165 |
+
certify the production deployment gate (WER <{" "}
|
| 166 |
+
<span className="num">15%</span> on clinic speech).
|
| 167 |
+
</span>
|
| 168 |
+
</li>
|
| 169 |
+
<li className="flex gap-3">
|
| 170 |
+
<span className="num shrink-0 text-muted/80">[5]</span>
|
| 171 |
+
<span>
|
| 172 |
+
Bars under WER and CS-WER place the value on a fixed{" "}
|
| 173 |
+
<span className="num">15–65</span> scale; whiskers appear when
|
| 174 |
+
bootstrap <span className="num">95%</span> CIs are available —
|
| 175 |
+
platform runs record them, paper imports rarely publish them.
|
| 176 |
+
</span>
|
| 177 |
+
</li>
|
| 178 |
+
</ol>
|
| 179 |
+
</section>
|
| 180 |
+
</ScrollReveal>
|
| 181 |
</div>
|
| 182 |
);
|
| 183 |
}
|
src/app/template.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import PageTransition from "@/components/ui/PageTransition";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Root template — wraps every route's content in a fade + rise entrance.
|
| 7 |
+
* PageTransition keys itself by pathname, so the animation also fires on
|
| 8 |
+
* deep navigations that don't remount this template (e.g. between models).
|
| 9 |
+
*/
|
| 10 |
+
export default function Template({
|
| 11 |
+
children,
|
| 12 |
+
}: {
|
| 13 |
+
children: React.ReactNode;
|
| 14 |
+
}) {
|
| 15 |
+
return <PageTransition>{children}</PageTransition>;
|
| 16 |
+
}
|
src/components/SiteNav.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import clsx from "clsx";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { usePathname } from "next/navigation";
|
| 6 |
+
import { motion } from "framer-motion";
|
| 7 |
+
import Badge from "@/components/ui/Badge";
|
| 8 |
+
import { NORMALIZER_VERSION } from "@/lib/data";
|
| 9 |
+
|
| 10 |
+
const LINKS = [
|
| 11 |
+
{ href: "/", label: "Leaderboard" },
|
| 12 |
+
{ href: "/compare", label: "Compare" },
|
| 13 |
+
{ href: "/datasets", label: "Datasets" },
|
| 14 |
+
{ href: "/methodology", label: "Methodology" },
|
| 15 |
+
] as const;
|
| 16 |
+
|
| 17 |
+
function isActive(pathname: string, href: string): boolean {
|
| 18 |
+
if (href === "/") return pathname === "/" || pathname.startsWith("/models");
|
| 19 |
+
return pathname === href || pathname.startsWith(`${href}/`);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Top navigation: wordmark + section links with animated active-route styling +
|
| 24 |
+
* normalizer chip. Features smooth animated indicator for active state.
|
| 25 |
+
*/
|
| 26 |
+
export default function SiteNav() {
|
| 27 |
+
const pathname = usePathname();
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<nav className="glass-veil hairline-b sticky top-0 z-40">
|
| 31 |
+
{/* phosphor tracer riding the bottom hairline — the instrument is on */}
|
| 32 |
+
<span aria-hidden className="nav-sweep" />
|
| 33 |
+
<div className="mx-auto max-w-6xl px-4 md:px-6">
|
| 34 |
+
<div className="flex flex-col md:h-14 md:flex-row md:items-center md:gap-6">
|
| 35 |
+
{/* row 1 on mobile · the whole bar on md+ */}
|
| 36 |
+
<div className="flex h-12 items-center justify-between gap-4 md:h-auto md:flex-1 md:justify-start md:gap-6">
|
| 37 |
+
<Link
|
| 38 |
+
href="/"
|
| 39 |
+
className="group flex shrink-0 items-center gap-2.5"
|
| 40 |
+
>
|
| 41 |
+
<span className="font-display text-xl leading-none text-text transition-all duration-200 group-hover:brightness-110">
|
| 42 |
+
<em className="italic">Med</em>
|
| 43 |
+
<span className="not-italic">ASR Bench</span>
|
| 44 |
+
</span>
|
| 45 |
+
<span className="hidden min-[420px]:inline-flex">
|
| 46 |
+
<Badge variant="outline">ViMedCSS v1</Badge>
|
| 47 |
+
</span>
|
| 48 |
+
</Link>
|
| 49 |
+
|
| 50 |
+
<div className="hidden min-w-0 flex-1 items-center gap-1 md:flex">
|
| 51 |
+
{LINKS.map(({ href, label }) => {
|
| 52 |
+
const active = isActive(pathname, href);
|
| 53 |
+
return (
|
| 54 |
+
<Link
|
| 55 |
+
key={href}
|
| 56 |
+
href={href}
|
| 57 |
+
aria-current={active ? "page" : undefined}
|
| 58 |
+
className={clsx(
|
| 59 |
+
"relative shrink-0 whitespace-nowrap rounded-sm px-2.5 py-1.5 text-[13px]",
|
| 60 |
+
"transition-colors duration-200 ease-out",
|
| 61 |
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
|
| 62 |
+
active
|
| 63 |
+
? "text-text"
|
| 64 |
+
: "text-muted hover:text-text hover:bg-surface/50",
|
| 65 |
+
)}
|
| 66 |
+
>
|
| 67 |
+
{label}
|
| 68 |
+
{/* Animated underline for active state */}
|
| 69 |
+
{active && (
|
| 70 |
+
<motion.span
|
| 71 |
+
layoutId="nav-indicator"
|
| 72 |
+
className="absolute inset-x-2 bottom-1 h-px bg-accent"
|
| 73 |
+
initial={false}
|
| 74 |
+
transition={{
|
| 75 |
+
type: "spring",
|
| 76 |
+
stiffness: 500,
|
| 77 |
+
damping: 35,
|
| 78 |
+
}}
|
| 79 |
+
/>
|
| 80 |
+
)}
|
| 81 |
+
</Link>
|
| 82 |
+
);
|
| 83 |
+
})}
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<span className="hidden shrink-0 sm:inline-flex">
|
| 87 |
+
<Badge variant="outline">normalizer {NORMALIZER_VERSION}</Badge>
|
| 88 |
+
</span>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{/* row 2, mobile only: scrollable link strip with edge fade */}
|
| 92 |
+
<div className="relative -mx-4 md:hidden">
|
| 93 |
+
<div className="flex items-center gap-1 overflow-x-auto px-4 pb-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
| 94 |
+
{LINKS.map(({ href, label }) => {
|
| 95 |
+
const active = isActive(pathname, href);
|
| 96 |
+
return (
|
| 97 |
+
<Link
|
| 98 |
+
key={href}
|
| 99 |
+
href={href}
|
| 100 |
+
aria-current={active ? "page" : undefined}
|
| 101 |
+
className={clsx(
|
| 102 |
+
"relative shrink-0 whitespace-nowrap rounded-sm px-2.5 py-1.5 text-[13px]",
|
| 103 |
+
"transition-colors duration-200 ease-out",
|
| 104 |
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
|
| 105 |
+
active
|
| 106 |
+
? "hairline bg-surface text-text"
|
| 107 |
+
: "text-muted hover:text-text hover:bg-surface/50",
|
| 108 |
+
)}
|
| 109 |
+
>
|
| 110 |
+
{label}
|
| 111 |
+
{/* Animated dot indicator for mobile */}
|
| 112 |
+
{active && (
|
| 113 |
+
<motion.span
|
| 114 |
+
layoutId="nav-dot"
|
| 115 |
+
className="absolute bottom-0.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-accent"
|
| 116 |
+
initial={false}
|
| 117 |
+
transition={{
|
| 118 |
+
type: "spring",
|
| 119 |
+
stiffness: 500,
|
| 120 |
+
damping: 35,
|
| 121 |
+
}}
|
| 122 |
+
/>
|
| 123 |
+
)}
|
| 124 |
+
</Link>
|
| 125 |
+
);
|
| 126 |
+
})}
|
| 127 |
+
</div>
|
| 128 |
+
<span
|
| 129 |
+
aria-hidden
|
| 130 |
+
className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-surface/80 to-transparent"
|
| 131 |
+
/>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</nav>
|
| 136 |
+
);
|
| 137 |
+
}
|
src/components/compare/EfficiencySection.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import type { ReactElement } from "react";
|
| 4 |
+
import {
|
| 5 |
+
CartesianGrid,
|
| 6 |
+
ResponsiveContainer,
|
| 7 |
+
Scatter,
|
| 8 |
+
ScatterChart,
|
| 9 |
+
Tooltip,
|
| 10 |
+
XAxis,
|
| 11 |
+
YAxis,
|
| 12 |
+
ZAxis,
|
| 13 |
+
} from "recharts";
|
| 14 |
+
import ProvenanceChip from "@/components/ui/ProvenanceChip";
|
| 15 |
+
import { categoryLabels, fmt } from "@/lib/data";
|
| 16 |
+
import type { ModelRow } from "@/lib/types";
|
| 17 |
+
import {
|
| 18 |
+
CHART_AXIS_LABEL,
|
| 19 |
+
CHART_AXIS_TICK,
|
| 20 |
+
CHART_LABEL_FILL,
|
| 21 |
+
CHART_LABEL_KNOCKOUT,
|
| 22 |
+
categoryColor,
|
| 23 |
+
isApiCategory,
|
| 24 |
+
shortName,
|
| 25 |
+
} from "./compare-utils";
|
| 26 |
+
|
| 27 |
+
interface EffDatum {
|
| 28 |
+
rtfx: number;
|
| 29 |
+
wer: number;
|
| 30 |
+
paramsB: number;
|
| 31 |
+
model: ModelRow;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function renderBubble(props: unknown): ReactElement {
|
| 35 |
+
const { cx, cy, size, payload } = props as {
|
| 36 |
+
cx?: number;
|
| 37 |
+
cy?: number;
|
| 38 |
+
size?: number;
|
| 39 |
+
payload?: EffDatum;
|
| 40 |
+
};
|
| 41 |
+
if (typeof cx !== "number" || typeof cy !== "number" || !payload) {
|
| 42 |
+
return <g />;
|
| 43 |
+
}
|
| 44 |
+
const color = categoryColor[payload.model.category];
|
| 45 |
+
const r = Math.max(Math.sqrt((size ?? 64) / Math.PI), 4);
|
| 46 |
+
const side = r * Math.SQRT2; // diamond with the same half-diagonal
|
| 47 |
+
return (
|
| 48 |
+
<g>
|
| 49 |
+
{isApiCategory(payload.model.category) ? (
|
| 50 |
+
<rect
|
| 51 |
+
x={cx - side / 2}
|
| 52 |
+
y={cy - side / 2}
|
| 53 |
+
width={side}
|
| 54 |
+
height={side}
|
| 55 |
+
transform={`rotate(45 ${cx} ${cy})`}
|
| 56 |
+
style={{ fill: color }}
|
| 57 |
+
fillOpacity={0.14}
|
| 58 |
+
stroke={color}
|
| 59 |
+
strokeWidth={1.25}
|
| 60 |
+
/>
|
| 61 |
+
) : (
|
| 62 |
+
<circle
|
| 63 |
+
cx={cx}
|
| 64 |
+
cy={cy}
|
| 65 |
+
r={r}
|
| 66 |
+
style={{ fill: color }}
|
| 67 |
+
fillOpacity={0.14}
|
| 68 |
+
stroke={color}
|
| 69 |
+
strokeWidth={1.25}
|
| 70 |
+
/>
|
| 71 |
+
)}
|
| 72 |
+
<circle cx={cx} cy={cy} r={1.6} style={{ fill: color }} />
|
| 73 |
+
<text
|
| 74 |
+
x={cx}
|
| 75 |
+
y={cy - r - 6}
|
| 76 |
+
textAnchor="middle"
|
| 77 |
+
style={{
|
| 78 |
+
fill: CHART_LABEL_FILL,
|
| 79 |
+
fontSize: 11,
|
| 80 |
+
fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
|
| 81 |
+
...CHART_LABEL_KNOCKOUT,
|
| 82 |
+
}}
|
| 83 |
+
>
|
| 84 |
+
{shortName(payload.model)}
|
| 85 |
+
</text>
|
| 86 |
+
</g>
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function EffTooltip(props: unknown) {
|
| 91 |
+
const { active, payload } = props as {
|
| 92 |
+
active?: boolean;
|
| 93 |
+
payload?: ReadonlyArray<{ payload?: EffDatum }>;
|
| 94 |
+
};
|
| 95 |
+
const d = payload?.[0]?.payload;
|
| 96 |
+
if (!active || !d) return null;
|
| 97 |
+
return (
|
| 98 |
+
<div className="glass depth-2 min-w-48 rounded-sm bg-surface-2/85 px-3 py-2.5">
|
| 99 |
+
<p className="text-sm leading-snug text-text">{d.model.name}</p>
|
| 100 |
+
<p className="num mt-0.5 text-[11px] tracking-wide text-muted">
|
| 101 |
+
{d.model.org} · {categoryLabels[d.model.category]}
|
| 102 |
+
</p>
|
| 103 |
+
<dl className="num mt-2 space-y-1 text-[12px]">
|
| 104 |
+
<div className="flex items-baseline justify-between gap-6">
|
| 105 |
+
<dt className="text-muted">RTFx</dt>
|
| 106 |
+
<dd className="text-text">{fmt(d.rtfx)}×</dd>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="flex items-baseline justify-between gap-6">
|
| 109 |
+
<dt className="text-muted">WER</dt>
|
| 110 |
+
<dd className="text-text">{fmt(d.wer)}</dd>
|
| 111 |
+
</div>
|
| 112 |
+
<div className="flex items-baseline justify-between gap-6">
|
| 113 |
+
<dt className="text-muted">Params</dt>
|
| 114 |
+
<dd className="text-text">{fmt(d.paramsB, 3)}B</dd>
|
| 115 |
+
</div>
|
| 116 |
+
</dl>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export interface EfficiencySectionProps {
|
| 122 |
+
models: ModelRow[];
|
| 123 |
+
/** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */
|
| 124 |
+
datasetName?: string;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export default function EfficiencySection({
|
| 128 |
+
models,
|
| 129 |
+
datasetName = "ViMedCSS",
|
| 130 |
+
}: EfficiencySectionProps) {
|
| 131 |
+
const data: EffDatum[] = models.flatMap((m) => {
|
| 132 |
+
const s = m.metrics?.test;
|
| 133 |
+
if (typeof s?.rtfx !== "number" || typeof s?.wer !== "number") return [];
|
| 134 |
+
return [{ rtfx: s.rtfx, wer: s.wer, paramsB: m.paramsB ?? 0.1, model: m }];
|
| 135 |
+
});
|
| 136 |
+
const running = models.filter((m) => m.provenance === "running");
|
| 137 |
+
/* a sparse plot reads as broken unless the gap is named: when most
|
| 138 |
+
systems are unmeasured, say so inside the plot itself */
|
| 139 |
+
const unmeasured = models.length - data.length;
|
| 140 |
+
const sparse = data.length > 0 && unmeasured > models.length / 2;
|
| 141 |
+
|
| 142 |
+
return (
|
| 143 |
+
<section aria-label="Efficiency: throughput against accuracy">
|
| 144 |
+
<div className="glass depth-2 spotlight-card rounded-sm p-5">
|
| 145 |
+
{data.length > 0 ? (
|
| 146 |
+
<figure>
|
| 147 |
+
<div
|
| 148 |
+
className="relative h-[360px] w-full"
|
| 149 |
+
role="img"
|
| 150 |
+
aria-label={`Bubble chart of RTFx throughput against WER on the ${datasetName} Test split; bubble area scales with parameter count. ${data.length} platform-measured models plotted.`}
|
| 151 |
+
>
|
| 152 |
+
{sparse && (
|
| 153 |
+
<p className="num absolute top-10 left-16 z-10 max-w-[34ch] rounded-sm border border-dashed border-line bg-surface/70 px-2.5 py-1.5 text-[11px] leading-relaxed tracking-wide text-muted">
|
| 154 |
+
{data.length} of {models.length} systems measured — RTFx only
|
| 155 |
+
ships with platform-verified runs; the rest of this plane is
|
| 156 |
+
the evidence gap, not the field.
|
| 157 |
+
</p>
|
| 158 |
+
)}
|
| 159 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 160 |
+
<ScatterChart
|
| 161 |
+
margin={{ top: 28, right: 36, bottom: 16, left: 8 }}
|
| 162 |
+
>
|
| 163 |
+
<CartesianGrid stroke="var(--line)" strokeWidth={1} />
|
| 164 |
+
<XAxis
|
| 165 |
+
type="number"
|
| 166 |
+
dataKey="rtfx"
|
| 167 |
+
name="RTFx"
|
| 168 |
+
domain={[0, "auto"]}
|
| 169 |
+
tick={CHART_AXIS_TICK}
|
| 170 |
+
tickLine={{ stroke: "var(--line)" }}
|
| 171 |
+
axisLine={{ stroke: "var(--line)" }}
|
| 172 |
+
label={{
|
| 173 |
+
value: "RTFX × · BETTER →",
|
| 174 |
+
position: "insideBottomRight",
|
| 175 |
+
offset: -8,
|
| 176 |
+
style: CHART_AXIS_LABEL,
|
| 177 |
+
}}
|
| 178 |
+
/>
|
| 179 |
+
<YAxis
|
| 180 |
+
type="number"
|
| 181 |
+
dataKey="wer"
|
| 182 |
+
name="WER"
|
| 183 |
+
domain={[15, 65]}
|
| 184 |
+
tick={CHART_AXIS_TICK}
|
| 185 |
+
tickLine={{ stroke: "var(--line)" }}
|
| 186 |
+
axisLine={{ stroke: "var(--line)" }}
|
| 187 |
+
width={44}
|
| 188 |
+
label={{
|
| 189 |
+
value: "WER % · ↓ BETTER",
|
| 190 |
+
angle: -90,
|
| 191 |
+
position: "insideLeft",
|
| 192 |
+
offset: 8,
|
| 193 |
+
style: { ...CHART_AXIS_LABEL, textAnchor: "middle" },
|
| 194 |
+
}}
|
| 195 |
+
/>
|
| 196 |
+
<ZAxis
|
| 197 |
+
type="number"
|
| 198 |
+
dataKey="paramsB"
|
| 199 |
+
range={[80, 640]}
|
| 200 |
+
name="Params (B)"
|
| 201 |
+
/>
|
| 202 |
+
<Tooltip
|
| 203 |
+
cursor={{ stroke: "var(--line)", strokeDasharray: "3 3" }}
|
| 204 |
+
content={EffTooltip}
|
| 205 |
+
/>
|
| 206 |
+
<Scatter data={data} shape={renderBubble} />
|
| 207 |
+
</ScatterChart>
|
| 208 |
+
</ResponsiveContainer>
|
| 209 |
+
</div>
|
| 210 |
+
<figcaption className="hairline-t mt-4 pt-3 text-[13px] text-muted">
|
| 211 |
+
RTFx = audio seconds ÷ wall seconds, measured on platform
|
| 212 |
+
hardware. Bubble area scales with parameter count. {datasetName}{" "}
|
| 213 |
+
test split · normalizer v0.1.
|
| 214 |
+
</figcaption>
|
| 215 |
+
</figure>
|
| 216 |
+
) : (
|
| 217 |
+
/* slim status banner — the tall canvas is reserved for real data */
|
| 218 |
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-sm border border-dashed border-line bg-surface-2/40 px-4 py-3">
|
| 219 |
+
<span
|
| 220 |
+
aria-hidden
|
| 221 |
+
className="pulse-dot inline-block size-2 shrink-0 rounded-full bg-accent"
|
| 222 |
+
/>
|
| 223 |
+
<p
|
| 224 |
+
className="num text-[12px] tracking-[0.18em] text-muted"
|
| 225 |
+
title="Throughput is only recorded when this platform executes the model itself; paper-imported rows never carry RTFx."
|
| 226 |
+
>
|
| 227 |
+
AWAITING PILOT DATA — RTFX REQUIRES A PLATFORM-VERIFIED RUN
|
| 228 |
+
</p>
|
| 229 |
+
{running.length > 0 && (
|
| 230 |
+
<ul className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
| 231 |
+
{running.map((m) => (
|
| 232 |
+
<li key={m.slug} className="flex items-center gap-2">
|
| 233 |
+
<span className="num text-[12px] text-text">{m.name}</span>
|
| 234 |
+
<ProvenanceChip provenance={m.provenance} />
|
| 235 |
+
</li>
|
| 236 |
+
))}
|
| 237 |
+
</ul>
|
| 238 |
+
)}
|
| 239 |
+
</div>
|
| 240 |
+
)}
|
| 241 |
+
</div>
|
| 242 |
+
</section>
|
| 243 |
+
);
|
| 244 |
+
}
|
src/components/compare/HeadToHead.tsx
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import clsx from "clsx";
|
| 4 |
+
import { motion, MotionConfig } from "framer-motion";
|
| 5 |
+
import { useSearchParams } from "next/navigation";
|
| 6 |
+
import { useState } from "react";
|
| 7 |
+
import FlagBadges from "@/components/ui/FlagBadges";
|
| 8 |
+
import ProvenanceChip from "@/components/ui/ProvenanceChip";
|
| 9 |
+
import {
|
| 10 |
+
categoryLabels,
|
| 11 |
+
fmt,
|
| 12 |
+
metricLabels,
|
| 13 |
+
provenanceLabels,
|
| 14 |
+
} from "@/lib/data";
|
| 15 |
+
import type { Category, MetricKey, ModelRow } from "@/lib/types";
|
| 16 |
+
import {
|
| 17 |
+
H2H_METRICS,
|
| 18 |
+
METRIC_UNIT,
|
| 19 |
+
deltaTone,
|
| 20 |
+
fmtDelta,
|
| 21 |
+
hasEvidenceGap,
|
| 22 |
+
seriesColor,
|
| 23 |
+
type DeltaTone,
|
| 24 |
+
} from "./compare-utils";
|
| 25 |
+
|
| 26 |
+
const DEFAULT_A = "phowhisper-large";
|
| 27 |
+
const DEFAULT_B = "whisper-large-v3";
|
| 28 |
+
|
| 29 |
+
const toneClass: Record<DeltaTone, string> = {
|
| 30 |
+
better: "text-accent",
|
| 31 |
+
worse: "text-crit/90",
|
| 32 |
+
flat: "text-muted",
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
/* Entrance for freshly-selected column values: a short fade with a 2px
|
| 36 |
+
rise. MotionConfig reducedMotion="user" strips the transform for
|
| 37 |
+
reduced-motion users, leaving an instant-feeling opacity settle. */
|
| 38 |
+
const VALUE_FADE = {
|
| 39 |
+
initial: { opacity: 0, y: 2 },
|
| 40 |
+
animate: { opacity: 1, y: 0 },
|
| 41 |
+
transition: { duration: 0.3, ease: "easeOut" },
|
| 42 |
+
} as const;
|
| 43 |
+
|
| 44 |
+
/* custom select caret: 1.5px-stroke chevron in the --muted token, painted
|
| 45 |
+
as a background so the native OS glyph never appears */
|
| 46 |
+
const SELECT_CARET: React.CSSProperties = {
|
| 47 |
+
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' fill='none' stroke='%23b9c9c2' stroke-width='1.5'/%3E%3C/svg%3E")`,
|
| 48 |
+
backgroundPosition: "right 10px center",
|
| 49 |
+
backgroundRepeat: "no-repeat",
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
/** [A, B, C] slugs; C may be "" (off). Honors ?m=slug,slug(,slug). */
|
| 53 |
+
function parseM(raw: string | null, models: ModelRow[]): [string, string, string] {
|
| 54 |
+
const valid =
|
| 55 |
+
raw
|
| 56 |
+
?.split(",")
|
| 57 |
+
.map((s) => s.trim())
|
| 58 |
+
.filter((s) => models.some((m) => m.slug === s)) ?? [];
|
| 59 |
+
const uniq = [...new Set(valid)];
|
| 60 |
+
const a = uniq[0] ?? DEFAULT_A;
|
| 61 |
+
let b = uniq[1] ?? DEFAULT_B;
|
| 62 |
+
if (b === a) b = a === DEFAULT_B ? DEFAULT_A : DEFAULT_B;
|
| 63 |
+
const c = uniq[2] && uniq[2] !== a && uniq[2] !== b ? uniq[2] : "";
|
| 64 |
+
return [a, b, c];
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function syncUrl(sel: [string, string, string]) {
|
| 68 |
+
if (typeof window === "undefined") return;
|
| 69 |
+
const url = new URL(window.location.href);
|
| 70 |
+
url.searchParams.set("m", sel.filter(Boolean).join(","));
|
| 71 |
+
window.history.replaceState(null, "", url);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function ModelSelect({
|
| 75 |
+
id,
|
| 76 |
+
label,
|
| 77 |
+
value,
|
| 78 |
+
models,
|
| 79 |
+
allowNone,
|
| 80 |
+
onChange,
|
| 81 |
+
}: {
|
| 82 |
+
id: string;
|
| 83 |
+
label: string;
|
| 84 |
+
value: string;
|
| 85 |
+
models: ModelRow[];
|
| 86 |
+
allowNone?: boolean;
|
| 87 |
+
onChange: (slug: string) => void;
|
| 88 |
+
}) {
|
| 89 |
+
const groups = (Object.keys(categoryLabels) as Category[])
|
| 90 |
+
.map((c) => ({ c, rows: models.filter((m) => m.category === c) }))
|
| 91 |
+
.filter((g) => g.rows.length > 0);
|
| 92 |
+
return (
|
| 93 |
+
<div className="flex min-w-52 flex-1 flex-col gap-1.5">
|
| 94 |
+
<label
|
| 95 |
+
htmlFor={id}
|
| 96 |
+
className="num text-[11px] uppercase tracking-[0.18em] text-muted"
|
| 97 |
+
>
|
| 98 |
+
{label}
|
| 99 |
+
</label>
|
| 100 |
+
<select
|
| 101 |
+
id={id}
|
| 102 |
+
value={value}
|
| 103 |
+
onChange={(e) => onChange(e.target.value)}
|
| 104 |
+
className="hairline num tactile w-full cursor-pointer appearance-none rounded-sm bg-surface-2 py-2 pr-8 pl-2.5 text-[12px] tracking-wide text-text outline-none transition-colors duration-200 ease-out hover:border-accent/40 focus-visible:border-accent/60 focus-visible:ring-1 focus-visible:ring-accent/40"
|
| 105 |
+
style={SELECT_CARET}
|
| 106 |
+
>
|
| 107 |
+
{allowNone && <option value="">— none —</option>}
|
| 108 |
+
{groups.map((g) => (
|
| 109 |
+
<optgroup key={g.c} label={categoryLabels[g.c]}>
|
| 110 |
+
{g.rows.map((m) => (
|
| 111 |
+
<option key={m.slug} value={m.slug}>
|
| 112 |
+
{m.name}
|
| 113 |
+
{hasEvidenceGap(m, "test")
|
| 114 |
+
? ` (${provenanceLabels[m.provenance].toLowerCase()})`
|
| 115 |
+
: ""}
|
| 116 |
+
</option>
|
| 117 |
+
))}
|
| 118 |
+
</optgroup>
|
| 119 |
+
))}
|
| 120 |
+
</select>
|
| 121 |
+
</div>
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
export interface HeadToHeadProps {
|
| 126 |
+
models: ModelRow[];
|
| 127 |
+
/** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */
|
| 128 |
+
datasetName?: string;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
export default function HeadToHead({
|
| 132 |
+
models,
|
| 133 |
+
datasetName = "ViMedCSS",
|
| 134 |
+
}: HeadToHeadProps) {
|
| 135 |
+
const searchParams = useSearchParams();
|
| 136 |
+
const [sel, setSel] = useState<[string, string, string]>(() =>
|
| 137 |
+
parseM(searchParams.get("m"), models),
|
| 138 |
+
);
|
| 139 |
+
const [showUnmeasured, setShowUnmeasured] = useState(false);
|
| 140 |
+
|
| 141 |
+
const pick = (i: 0 | 1 | 2) => (slug: string) => {
|
| 142 |
+
setSel((prev) => {
|
| 143 |
+
const next: [string, string, string] = [...prev];
|
| 144 |
+
next[i] = slug;
|
| 145 |
+
syncUrl(next);
|
| 146 |
+
return next;
|
| 147 |
+
});
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
const cols = sel
|
| 151 |
+
.filter(Boolean)
|
| 152 |
+
.map((s) => models.find((m) => m.slug === s))
|
| 153 |
+
.filter((m): m is ModelRow => Boolean(m));
|
| 154 |
+
const baseline = cols[0];
|
| 155 |
+
const gapModels = cols.filter((m) => hasEvidenceGap(m, "test"));
|
| 156 |
+
/* metrics at least one selected model has measured drive the table rows
|
| 157 |
+
and the profile bars; the rest collapse into one quiet expandable row */
|
| 158 |
+
const measuredMetrics = H2H_METRICS.filter((k) =>
|
| 159 |
+
cols.some((m) => typeof m.metrics?.test?.[k] === "number"),
|
| 160 |
+
);
|
| 161 |
+
const unmeasuredMetrics = H2H_METRICS.filter(
|
| 162 |
+
(k) => !cols.some((m) => typeof m.metrics?.test?.[k] === "number"),
|
| 163 |
+
);
|
| 164 |
+
const barMetrics = measuredMetrics;
|
| 165 |
+
|
| 166 |
+
return (
|
| 167 |
+
<MotionConfig reducedMotion="user">
|
| 168 |
+
<section aria-label="Head-to-head model comparison">
|
| 169 |
+
<div className="glass depth-2 spotlight-card rounded-sm p-5">
|
| 170 |
+
{/* selectors */}
|
| 171 |
+
<div className="flex flex-wrap items-end gap-4">
|
| 172 |
+
<ModelSelect
|
| 173 |
+
id="h2h-a"
|
| 174 |
+
label="Model A · baseline"
|
| 175 |
+
value={sel[0]}
|
| 176 |
+
models={models}
|
| 177 |
+
onChange={pick(0)}
|
| 178 |
+
/>
|
| 179 |
+
<ModelSelect
|
| 180 |
+
id="h2h-b"
|
| 181 |
+
label="Model B"
|
| 182 |
+
value={sel[1]}
|
| 183 |
+
models={models}
|
| 184 |
+
onChange={pick(1)}
|
| 185 |
+
/>
|
| 186 |
+
<ModelSelect
|
| 187 |
+
id="h2h-c"
|
| 188 |
+
label="Model C · optional"
|
| 189 |
+
value={sel[2]}
|
| 190 |
+
models={models}
|
| 191 |
+
allowNone
|
| 192 |
+
onChange={pick(2)}
|
| 193 |
+
/>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
{/* evidence-gap notices */}
|
| 197 |
+
{gapModels.length > 0 && (
|
| 198 |
+
<div className="mt-4 flex flex-col gap-2">
|
| 199 |
+
{gapModels.map((m) => (
|
| 200 |
+
<motion.p
|
| 201 |
+
key={m.slug}
|
| 202 |
+
{...VALUE_FADE}
|
| 203 |
+
className="flex flex-wrap items-center gap-2 rounded-sm border border-dashed border-line bg-surface-2/40 px-3 py-2 text-[12px] text-muted"
|
| 204 |
+
>
|
| 205 |
+
<span className="num tracking-wide">
|
| 206 |
+
EVIDENCE GAP — {m.name} has no measurements on Test yet.
|
| 207 |
+
</span>
|
| 208 |
+
<ProvenanceChip provenance={m.provenance} />
|
| 209 |
+
</motion.p>
|
| 210 |
+
))}
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
|
| 214 |
+
{/* delta table */}
|
| 215 |
+
<div className="mt-5 overflow-x-auto">
|
| 216 |
+
<table className="w-full min-w-[560px] border-collapse text-sm">
|
| 217 |
+
<caption className="sr-only">
|
| 218 |
+
Metric comparison on the {datasetName} Test split. Deltas are
|
| 219 |
+
challenger minus baseline; green improves on the baseline, red
|
| 220 |
+
regresses.
|
| 221 |
+
</caption>
|
| 222 |
+
<thead>
|
| 223 |
+
<tr className="hairline-b text-left align-bottom">
|
| 224 |
+
<th
|
| 225 |
+
scope="col"
|
| 226 |
+
className="num py-2.5 pr-3 text-[11px] font-normal uppercase tracking-[0.18em] text-muted"
|
| 227 |
+
>
|
| 228 |
+
Metric · Test
|
| 229 |
+
</th>
|
| 230 |
+
{cols.map((m, i) => (
|
| 231 |
+
<th
|
| 232 |
+
key={m.slug}
|
| 233 |
+
scope="col"
|
| 234 |
+
className="py-2.5 pr-3 font-normal"
|
| 235 |
+
>
|
| 236 |
+
<motion.span
|
| 237 |
+
{...VALUE_FADE}
|
| 238 |
+
className="flex flex-wrap items-center gap-2"
|
| 239 |
+
>
|
| 240 |
+
<span
|
| 241 |
+
aria-hidden
|
| 242 |
+
className="inline-block size-2 rounded-full"
|
| 243 |
+
style={{ background: seriesColor[i] }}
|
| 244 |
+
/>
|
| 245 |
+
<span className="text-text">{m.name}</span>
|
| 246 |
+
<FlagBadges flags={m.flags} />
|
| 247 |
+
{i === 0 && (
|
| 248 |
+
<span className="num rounded-sm border border-line px-1.5 py-0.5 text-[11px] tracking-wide text-muted">
|
| 249 |
+
BASELINE
|
| 250 |
+
</span>
|
| 251 |
+
)}
|
| 252 |
+
</motion.span>
|
| 253 |
+
</th>
|
| 254 |
+
))}
|
| 255 |
+
</tr>
|
| 256 |
+
</thead>
|
| 257 |
+
<tbody>
|
| 258 |
+
{(showUnmeasured
|
| 259 |
+
? [...measuredMetrics, ...unmeasuredMetrics]
|
| 260 |
+
: measuredMetrics
|
| 261 |
+
).map((k: MetricKey) => {
|
| 262 |
+
const base = baseline?.metrics?.test?.[k];
|
| 263 |
+
return (
|
| 264 |
+
<tr key={k} className="hairline-b">
|
| 265 |
+
<th
|
| 266 |
+
scope="row"
|
| 267 |
+
className="num py-2.5 pr-3 text-left text-[12px] font-normal text-muted"
|
| 268 |
+
>
|
| 269 |
+
{metricLabels[k]}{" "}
|
| 270 |
+
<span className="text-muted/60">{METRIC_UNIT[k]}</span>
|
| 271 |
+
</th>
|
| 272 |
+
{cols.map((m, i) => {
|
| 273 |
+
const v = m.metrics?.test?.[k];
|
| 274 |
+
const showDelta =
|
| 275 |
+
i > 0 &&
|
| 276 |
+
typeof v === "number" &&
|
| 277 |
+
typeof base === "number";
|
| 278 |
+
const d = showDelta ? v - base : 0;
|
| 279 |
+
return (
|
| 280 |
+
/* keyed by slug: swapping a model remounts the
|
| 281 |
+
cell, so the fresh value fades in */
|
| 282 |
+
<td key={m.slug} className="num py-2.5 pr-3">
|
| 283 |
+
<motion.span
|
| 284 |
+
{...VALUE_FADE}
|
| 285 |
+
className="inline-block"
|
| 286 |
+
>
|
| 287 |
+
<span
|
| 288 |
+
className={
|
| 289 |
+
typeof v === "number"
|
| 290 |
+
? "text-text"
|
| 291 |
+
: "text-muted/60"
|
| 292 |
+
}
|
| 293 |
+
>
|
| 294 |
+
{fmt(v)}
|
| 295 |
+
</span>
|
| 296 |
+
{showDelta && (
|
| 297 |
+
<span
|
| 298 |
+
className={clsx(
|
| 299 |
+
"ml-2 text-[12px]",
|
| 300 |
+
toneClass[deltaTone(d, k)],
|
| 301 |
+
)}
|
| 302 |
+
>
|
| 303 |
+
{fmtDelta(d)}
|
| 304 |
+
</span>
|
| 305 |
+
)}
|
| 306 |
+
</motion.span>
|
| 307 |
+
</td>
|
| 308 |
+
);
|
| 309 |
+
})}
|
| 310 |
+
</tr>
|
| 311 |
+
);
|
| 312 |
+
})}
|
| 313 |
+
{unmeasuredMetrics.length > 0 && (
|
| 314 |
+
<tr className="hairline-b">
|
| 315 |
+
<td colSpan={cols.length + 1} className="py-2">
|
| 316 |
+
<button
|
| 317 |
+
type="button"
|
| 318 |
+
aria-expanded={showUnmeasured}
|
| 319 |
+
onClick={() => setShowUnmeasured((v) => !v)}
|
| 320 |
+
className="num tactile inline-flex items-center gap-1.5 rounded-sm px-1 py-1 text-[11px] tracking-wide text-muted transition-colors duration-150 ease-out hover:text-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent/70"
|
| 321 |
+
>
|
| 322 |
+
<span aria-hidden>{showUnmeasured ? "▴" : "▸"}</span>
|
| 323 |
+
{unmeasuredMetrics.length}{" "}
|
| 324 |
+
{unmeasuredMetrics.length === 1 ? "metric" : "metrics"}{" "}
|
| 325 |
+
· no platform trace yet for the selected models
|
| 326 |
+
</button>
|
| 327 |
+
</td>
|
| 328 |
+
</tr>
|
| 329 |
+
)}
|
| 330 |
+
</tbody>
|
| 331 |
+
</table>
|
| 332 |
+
</div>
|
| 333 |
+
|
| 334 |
+
{/* grouped horizontal bars */}
|
| 335 |
+
{barMetrics.length > 0 && (
|
| 336 |
+
<div className="hairline-t mt-5 pt-5">
|
| 337 |
+
<h3 className="num text-[11px] uppercase tracking-[0.18em] text-muted">
|
| 338 |
+
Profile — grouped bars, Test split
|
| 339 |
+
</h3>
|
| 340 |
+
<div className="mt-4 grid gap-x-10 gap-y-5 sm:grid-cols-2">
|
| 341 |
+
{barMetrics.map((k) => {
|
| 342 |
+
const values = cols.map((m) => m.metrics?.test?.[k]);
|
| 343 |
+
const max = Math.max(
|
| 344 |
+
...values.map((v) => (typeof v === "number" ? v : 0)),
|
| 345 |
+
);
|
| 346 |
+
/* %-unit metrics share one fixed 0–100 domain so equal bar
|
| 347 |
+
lengths always encode equal values; RTFx (open-ended)
|
| 348 |
+
keeps local-max scaling */
|
| 349 |
+
const isPct = METRIC_UNIT[k] === "%";
|
| 350 |
+
const widthPct = (v: number) =>
|
| 351 |
+
isPct
|
| 352 |
+
? Math.min(v, 100)
|
| 353 |
+
: max > 0
|
| 354 |
+
? Math.max((v / (max * 1.08)) * 100, 1.5)
|
| 355 |
+
: 0;
|
| 356 |
+
return (
|
| 357 |
+
<div key={k}>
|
| 358 |
+
<p className="num text-[11px] tracking-wide text-muted">
|
| 359 |
+
{metricLabels[k]}{" "}
|
| 360 |
+
<span className="text-muted/60">
|
| 361 |
+
{METRIC_UNIT[k]}
|
| 362 |
+
{isPct && " · 0–100"}
|
| 363 |
+
</span>
|
| 364 |
+
</p>
|
| 365 |
+
<div className="mt-1.5 flex flex-col gap-1.5">
|
| 366 |
+
{/* rows are keyed by SLOT, not slug — the bar element
|
| 367 |
+
survives a model swap, so its width tweens to the
|
| 368 |
+
new value (CSS transition; reduced-motion safe) */}
|
| 369 |
+
{cols.map((m, i) => {
|
| 370 |
+
const v = values[i];
|
| 371 |
+
return (
|
| 372 |
+
<div
|
| 373 |
+
key={`slot-${i}`}
|
| 374 |
+
className="flex items-center gap-2"
|
| 375 |
+
>
|
| 376 |
+
<div
|
| 377 |
+
aria-hidden
|
| 378 |
+
className="relative h-1.5 flex-1 rounded-sm bg-surface-2"
|
| 379 |
+
>
|
| 380 |
+
{/* scale ticks at 25 / 50 / 75 */}
|
| 381 |
+
{isPct &&
|
| 382 |
+
[25, 50, 75].map((t) => (
|
| 383 |
+
<span
|
| 384 |
+
key={t}
|
| 385 |
+
className="absolute inset-y-0 w-px bg-line"
|
| 386 |
+
style={{ left: `${t}%` }}
|
| 387 |
+
/>
|
| 388 |
+
))}
|
| 389 |
+
{typeof v === "number" &&
|
| 390 |
+
(i === 2 ? (
|
| 391 |
+
/* series 3: hairline-outlined, transparent
|
| 392 |
+
fill — distinct without more accent */
|
| 393 |
+
<span
|
| 394 |
+
className="absolute inset-y-0 left-0 rounded-sm border border-muted/70 bg-transparent transition-[width] duration-500 ease-out"
|
| 395 |
+
style={{
|
| 396 |
+
width: `${widthPct(v).toFixed(2)}%`,
|
| 397 |
+
}}
|
| 398 |
+
/>
|
| 399 |
+
) : (
|
| 400 |
+
<span
|
| 401 |
+
className="absolute inset-y-0 left-0 rounded-sm transition-[width] duration-500 ease-out"
|
| 402 |
+
style={{
|
| 403 |
+
width: `${widthPct(v).toFixed(2)}%`,
|
| 404 |
+
background: seriesColor[i],
|
| 405 |
+
opacity: 0.85,
|
| 406 |
+
}}
|
| 407 |
+
/>
|
| 408 |
+
))}
|
| 409 |
+
</div>
|
| 410 |
+
<span
|
| 411 |
+
className={clsx(
|
| 412 |
+
"num w-14 text-right text-[11px]",
|
| 413 |
+
typeof v === "number"
|
| 414 |
+
? "text-text"
|
| 415 |
+
: "text-muted/60",
|
| 416 |
+
)}
|
| 417 |
+
>
|
| 418 |
+
{fmt(v)}
|
| 419 |
+
</span>
|
| 420 |
+
</div>
|
| 421 |
+
);
|
| 422 |
+
})}
|
| 423 |
+
</div>
|
| 424 |
+
</div>
|
| 425 |
+
);
|
| 426 |
+
})}
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
)}
|
| 430 |
+
|
| 431 |
+
<p className="num mt-5 text-[11px] tracking-wide text-muted">
|
| 432 |
+
ALL VALUES: {datasetName.toUpperCase()} TEST SPLIT · NORMALIZER v0.1
|
| 433 |
+
— WER IS ONLY COMPARABLE WITHIN A NORMALIZER VERSION.
|
| 434 |
+
</p>
|
| 435 |
+
</div>
|
| 436 |
+
</section>
|
| 437 |
+
</MotionConfig>
|
| 438 |
+
);
|
| 439 |
+
}
|
src/components/compare/ParetoChart.tsx
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import type { ReactElement } from "react";
|
| 4 |
+
import {
|
| 5 |
+
CartesianGrid,
|
| 6 |
+
ReferenceArea,
|
| 7 |
+
ReferenceDot,
|
| 8 |
+
ReferenceLine,
|
| 9 |
+
ResponsiveContainer,
|
| 10 |
+
Scatter,
|
| 11 |
+
ScatterChart,
|
| 12 |
+
Tooltip,
|
| 13 |
+
XAxis,
|
| 14 |
+
YAxis,
|
| 15 |
+
} from "recharts";
|
| 16 |
+
import ProvenanceChip from "@/components/ui/ProvenanceChip";
|
| 17 |
+
import { categoryLabels, fmt, fmtCI } from "@/lib/data";
|
| 18 |
+
import {
|
| 19 |
+
CHART_AXIS_LABEL,
|
| 20 |
+
CHART_AXIS_TICK,
|
| 21 |
+
CHART_LABEL_FILL,
|
| 22 |
+
CHART_LABEL_KNOCKOUT,
|
| 23 |
+
categoryColor,
|
| 24 |
+
dominatedBands,
|
| 25 |
+
domainTicks,
|
| 26 |
+
frontierSegments,
|
| 27 |
+
isApiCategory,
|
| 28 |
+
niceDomain,
|
| 29 |
+
paretoFrontier,
|
| 30 |
+
shortName,
|
| 31 |
+
type ParetoDatum,
|
| 32 |
+
} from "./compare-utils";
|
| 33 |
+
|
| 34 |
+
/* Datum enriched with chart-level placement hints. `annotated` marks the
|
| 35 |
+
frontier point the serif "adapted frontier" annotation anchors to — its
|
| 36 |
+
FRONTIER tag is suppressed so captions never pile onto one point. */
|
| 37 |
+
type PlottedDatum = ParetoDatum & {
|
| 38 |
+
onFrontier: boolean;
|
| 39 |
+
flip: boolean;
|
| 40 |
+
annotated: boolean;
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
/* ------------------------------------------------------------------ */
|
| 44 |
+
/* Point glyph: category-colored dot (diamond for API systems) · */
|
| 45 |
+
/* dashed ring = paper-imported · amber ring = contamination · */
|
| 46 |
+
/* concentric accent ring + FRONTIER tag for frontier members · */
|
| 47 |
+
/* tiny mono label */
|
| 48 |
+
/* ------------------------------------------------------------------ */
|
| 49 |
+
|
| 50 |
+
function renderPoint(props: unknown): ReactElement {
|
| 51 |
+
const { cx, cy, payload } = props as {
|
| 52 |
+
cx?: number;
|
| 53 |
+
cy?: number;
|
| 54 |
+
payload?: PlottedDatum;
|
| 55 |
+
};
|
| 56 |
+
if (typeof cx !== "number" || typeof cy !== "number" || !payload) {
|
| 57 |
+
return <g />;
|
| 58 |
+
}
|
| 59 |
+
const m = payload.model;
|
| 60 |
+
const color = categoryColor[m.category];
|
| 61 |
+
const dashed = m.provenance === "paper-imported";
|
| 62 |
+
const contaminated = Boolean(m.flags?.contamination);
|
| 63 |
+
const frontier = payload.onFrontier;
|
| 64 |
+
const flip = payload.flip;
|
| 65 |
+
const annotated = payload.annotated;
|
| 66 |
+
return (
|
| 67 |
+
<g>
|
| 68 |
+
{contaminated && (
|
| 69 |
+
<circle
|
| 70 |
+
cx={cx}
|
| 71 |
+
cy={cy}
|
| 72 |
+
r={9.5}
|
| 73 |
+
fill="none"
|
| 74 |
+
stroke="var(--warn)"
|
| 75 |
+
strokeWidth={1}
|
| 76 |
+
opacity={0.85}
|
| 77 |
+
/>
|
| 78 |
+
)}
|
| 79 |
+
{frontier && (
|
| 80 |
+
<circle
|
| 81 |
+
cx={cx}
|
| 82 |
+
cy={cy}
|
| 83 |
+
r={8.5}
|
| 84 |
+
fill="none"
|
| 85 |
+
stroke="var(--accent)"
|
| 86 |
+
strokeOpacity={0.6}
|
| 87 |
+
strokeWidth={1}
|
| 88 |
+
/>
|
| 89 |
+
)}
|
| 90 |
+
{isApiCategory(m.category) ? (
|
| 91 |
+
<rect
|
| 92 |
+
x={cx - 4.4}
|
| 93 |
+
y={cy - 4.4}
|
| 94 |
+
width={8.8}
|
| 95 |
+
height={8.8}
|
| 96 |
+
transform={`rotate(45 ${cx} ${cy})`}
|
| 97 |
+
style={{ fill: color }}
|
| 98 |
+
fillOpacity={0.3}
|
| 99 |
+
stroke={color}
|
| 100 |
+
strokeWidth={1.25}
|
| 101 |
+
strokeDasharray={dashed ? "2.5 2.2" : undefined}
|
| 102 |
+
/>
|
| 103 |
+
) : (
|
| 104 |
+
<circle
|
| 105 |
+
cx={cx}
|
| 106 |
+
cy={cy}
|
| 107 |
+
r={5.5}
|
| 108 |
+
style={{ fill: color }}
|
| 109 |
+
fillOpacity={0.3}
|
| 110 |
+
stroke={color}
|
| 111 |
+
strokeWidth={1.25}
|
| 112 |
+
strokeDasharray={dashed ? "2.5 2.2" : undefined}
|
| 113 |
+
/>
|
| 114 |
+
)}
|
| 115 |
+
<circle cx={cx} cy={cy} r={1.6} style={{ fill: color }} />
|
| 116 |
+
{/* frontier members: name lifted clear above the dot + mono tag below */}
|
| 117 |
+
<text
|
| 118 |
+
x={frontier ? cx : flip ? cx - 11 : cx + 11}
|
| 119 |
+
y={frontier ? cy - 14 : cy + 3.5}
|
| 120 |
+
textAnchor={frontier ? "middle" : flip ? "end" : "start"}
|
| 121 |
+
style={{
|
| 122 |
+
fill: CHART_LABEL_FILL,
|
| 123 |
+
fontSize: 11,
|
| 124 |
+
fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
|
| 125 |
+
...CHART_LABEL_KNOCKOUT,
|
| 126 |
+
}}
|
| 127 |
+
>
|
| 128 |
+
{shortName(m)}
|
| 129 |
+
</text>
|
| 130 |
+
{frontier && !annotated && (
|
| 131 |
+
<text
|
| 132 |
+
x={cx}
|
| 133 |
+
y={cy + 21}
|
| 134 |
+
textAnchor="middle"
|
| 135 |
+
style={{
|
| 136 |
+
fill: "var(--accent)",
|
| 137 |
+
fillOpacity: 0.9,
|
| 138 |
+
fontSize: 9,
|
| 139 |
+
letterSpacing: "0.18em",
|
| 140 |
+
fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
|
| 141 |
+
...CHART_LABEL_KNOCKOUT,
|
| 142 |
+
}}
|
| 143 |
+
>
|
| 144 |
+
FRONTIER
|
| 145 |
+
</text>
|
| 146 |
+
)}
|
| 147 |
+
</g>
|
| 148 |
+
);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* ------------------------------------------------------------------ */
|
| 152 |
+
/* Instrument tooltip — glass over the chart, never opaque black */
|
| 153 |
+
/* ------------------------------------------------------------------ */
|
| 154 |
+
|
| 155 |
+
function ParetoTooltip(props: unknown) {
|
| 156 |
+
const { active, payload } = props as {
|
| 157 |
+
active?: boolean;
|
| 158 |
+
payload?: ReadonlyArray<{ payload?: ParetoDatum }>;
|
| 159 |
+
};
|
| 160 |
+
const d = payload?.[0]?.payload;
|
| 161 |
+
if (!active || !d) return null;
|
| 162 |
+
const m = d.model;
|
| 163 |
+
return (
|
| 164 |
+
<div className="glass depth-2 min-w-52 rounded-sm bg-surface-2/85 px-3 py-2.5">
|
| 165 |
+
<p className="text-sm leading-snug text-text">{m.name}</p>
|
| 166 |
+
<p className="num mt-0.5 text-[11px] tracking-wide text-muted">
|
| 167 |
+
{m.org} · {categoryLabels[m.category]}
|
| 168 |
+
</p>
|
| 169 |
+
<dl className="num mt-2 space-y-1 text-[12px]">
|
| 170 |
+
<div className="flex items-baseline justify-between gap-6">
|
| 171 |
+
<dt className="text-muted">WER</dt>
|
| 172 |
+
<dd className="text-right text-text">
|
| 173 |
+
{fmt(d.wer)}
|
| 174 |
+
{d.werCi && (
|
| 175 |
+
<span className="ml-1.5 text-[11px] text-muted">
|
| 176 |
+
{fmtCI(d.werCi)}
|
| 177 |
+
</span>
|
| 178 |
+
)}
|
| 179 |
+
</dd>
|
| 180 |
+
</div>
|
| 181 |
+
<div className="flex items-baseline justify-between gap-6">
|
| 182 |
+
<dt className="text-muted">CS-WER</dt>
|
| 183 |
+
<dd className="text-right text-text">
|
| 184 |
+
{fmt(d.csWer)}
|
| 185 |
+
{d.csWerCi && (
|
| 186 |
+
<span className="ml-1.5 text-[11px] text-muted">
|
| 187 |
+
{fmtCI(d.csWerCi)}
|
| 188 |
+
</span>
|
| 189 |
+
)}
|
| 190 |
+
</dd>
|
| 191 |
+
</div>
|
| 192 |
+
</dl>
|
| 193 |
+
<div className="mt-2">
|
| 194 |
+
<ProvenanceChip provenance={m.provenance} />
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* ------------------------------------------------------------------ */
|
| 201 |
+
/* Chart */
|
| 202 |
+
/* ------------------------------------------------------------------ */
|
| 203 |
+
|
| 204 |
+
export interface ParetoChartProps {
|
| 205 |
+
data: ParetoDatum[];
|
| 206 |
+
splitLabel: string;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
export default function ParetoChart({ data, splitLabel }: ParetoChartProps) {
|
| 210 |
+
/* axis domains track the data extent (padded, snapped to 5s) so the
|
| 211 |
+
points command the canvas instead of huddling in a corner */
|
| 212 |
+
const xDomain = niceDomain(data.map((d) => d.wer));
|
| 213 |
+
const yDomain = niceDomain(data.map((d) => d.csWer));
|
| 214 |
+
const xTicks = domainTicks(xDomain);
|
| 215 |
+
const yTicks = domainTicks(yDomain);
|
| 216 |
+
|
| 217 |
+
const frontier = paretoFrontier(data);
|
| 218 |
+
const frontierSet = new Set(frontier.map((d) => d.model.slug));
|
| 219 |
+
const segments = frontierSegments(frontier, xDomain[1], yDomain[1]);
|
| 220 |
+
const bands = dominatedBands(frontier, xDomain[1], yDomain[1]);
|
| 221 |
+
const last = frontier[frontier.length - 1];
|
| 222 |
+
|
| 223 |
+
const plotted: PlottedDatum[] = data.map((d) => ({
|
| 224 |
+
...d,
|
| 225 |
+
onFrontier: frontierSet.has(d.model.slug),
|
| 226 |
+
flip: d.wer > (xDomain[0] + xDomain[1]) / 2,
|
| 227 |
+
annotated: Boolean(last) && d.model.slug === last.model.slug,
|
| 228 |
+
}));
|
| 229 |
+
|
| 230 |
+
return (
|
| 231 |
+
<div
|
| 232 |
+
className="relative h-[440px] w-full"
|
| 233 |
+
role="img"
|
| 234 |
+
aria-label={`Scatter plot of overall WER against code-switch WER on the ${splitLabel} split, with the Pareto frontier drawn as a stepped line and the dominated region lightly shaded. ${data.length} models plotted; lower-left is better.`}
|
| 235 |
+
>
|
| 236 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 237 |
+
<ScatterChart margin={{ top: 24, right: 36, bottom: 16, left: 8 }}>
|
| 238 |
+
<CartesianGrid stroke="var(--line)" strokeWidth={1} />
|
| 239 |
+
<XAxis
|
| 240 |
+
type="number"
|
| 241 |
+
dataKey="wer"
|
| 242 |
+
name="WER"
|
| 243 |
+
domain={xDomain}
|
| 244 |
+
ticks={xTicks}
|
| 245 |
+
tick={CHART_AXIS_TICK}
|
| 246 |
+
tickLine={{ stroke: "var(--line)" }}
|
| 247 |
+
axisLine={{ stroke: "var(--line)" }}
|
| 248 |
+
label={{
|
| 249 |
+
value: "WER % · ← BETTER",
|
| 250 |
+
position: "insideBottomRight",
|
| 251 |
+
offset: -8,
|
| 252 |
+
style: CHART_AXIS_LABEL,
|
| 253 |
+
}}
|
| 254 |
+
/>
|
| 255 |
+
<YAxis
|
| 256 |
+
type="number"
|
| 257 |
+
dataKey="csWer"
|
| 258 |
+
name="CS-WER"
|
| 259 |
+
domain={yDomain}
|
| 260 |
+
ticks={yTicks}
|
| 261 |
+
tick={CHART_AXIS_TICK}
|
| 262 |
+
tickLine={{ stroke: "var(--line)" }}
|
| 263 |
+
axisLine={{ stroke: "var(--line)" }}
|
| 264 |
+
width={44}
|
| 265 |
+
label={{
|
| 266 |
+
value: "CS-WER % · ↓ BETTER",
|
| 267 |
+
angle: -90,
|
| 268 |
+
position: "insideLeft",
|
| 269 |
+
offset: 8,
|
| 270 |
+
style: { ...CHART_AXIS_LABEL, textAnchor: "middle" },
|
| 271 |
+
}}
|
| 272 |
+
/>
|
| 273 |
+
{/* dominated region: the staircase boundary reads as a region edge */}
|
| 274 |
+
{bands.map((b, i) => (
|
| 275 |
+
<ReferenceArea
|
| 276 |
+
key={`dominated-${i}`}
|
| 277 |
+
x1={b.x1}
|
| 278 |
+
x2={b.x2}
|
| 279 |
+
y1={b.y1}
|
| 280 |
+
y2={b.y2}
|
| 281 |
+
fill="var(--accent)"
|
| 282 |
+
fillOpacity={0.06}
|
| 283 |
+
stroke="none"
|
| 284 |
+
ifOverflow="hidden"
|
| 285 |
+
/>
|
| 286 |
+
))}
|
| 287 |
+
{segments.map(([a, b], i) => (
|
| 288 |
+
<ReferenceLine
|
| 289 |
+
key={`frontier-${i}`}
|
| 290 |
+
segment={[a, b]}
|
| 291 |
+
stroke="var(--accent)"
|
| 292 |
+
strokeWidth={1}
|
| 293 |
+
strokeDasharray="4 3"
|
| 294 |
+
strokeOpacity={0.6}
|
| 295 |
+
ifOverflow="hidden"
|
| 296 |
+
/>
|
| 297 |
+
))}
|
| 298 |
+
{/* data-anchored frontier annotation — floats above the frontier's
|
| 299 |
+
horizontal run-out, clear of the frontier point's own label */}
|
| 300 |
+
{last && (
|
| 301 |
+
<ReferenceDot
|
| 302 |
+
x={last.wer + (xDomain[1] - xDomain[0]) * 0.18}
|
| 303 |
+
y={last.csWer + (yDomain[1] - yDomain[0]) * 0.06}
|
| 304 |
+
r={0}
|
| 305 |
+
fill="none"
|
| 306 |
+
stroke="none"
|
| 307 |
+
ifOverflow="hidden"
|
| 308 |
+
label={{
|
| 309 |
+
value: "adapted frontier",
|
| 310 |
+
position: "right",
|
| 311 |
+
offset: 0,
|
| 312 |
+
style: {
|
| 313 |
+
textAnchor: "start",
|
| 314 |
+
fill: "var(--accent)",
|
| 315 |
+
fillOpacity: 0.85,
|
| 316 |
+
fontSize: 16,
|
| 317 |
+
fontStyle: "italic",
|
| 318 |
+
fontFamily:
|
| 319 |
+
"var(--font-display), 'Instrument Serif', Georgia, serif",
|
| 320 |
+
...CHART_LABEL_KNOCKOUT,
|
| 321 |
+
},
|
| 322 |
+
}}
|
| 323 |
+
/>
|
| 324 |
+
)}
|
| 325 |
+
<Tooltip
|
| 326 |
+
cursor={{ stroke: "var(--line)", strokeDasharray: "3 3" }}
|
| 327 |
+
content={ParetoTooltip}
|
| 328 |
+
/>
|
| 329 |
+
<Scatter data={plotted} shape={renderPoint} />
|
| 330 |
+
</ScatterChart>
|
| 331 |
+
</ResponsiveContainer>
|
| 332 |
+
|
| 333 |
+
{/* Quadrant captions — editorial, pinned at fixed insets from the
|
| 334 |
+
plot corners (plot edges: left 52px, top 24px, right 36px,
|
| 335 |
+
bottom ≈46px) so they frame the data */}
|
| 336 |
+
<p
|
| 337 |
+
aria-hidden
|
| 338 |
+
className="pointer-events-none absolute left-[68px] top-28 max-w-56 font-display text-[16px] italic leading-snug text-muted [text-shadow:0_0_6px_var(--surface),0_0_6px_var(--surface),0_0_6px_var(--surface)]"
|
| 339 |
+
>
|
| 340 |
+
Vietnamese-specialized: strong overall, weak CS
|
| 341 |
+
</p>
|
| 342 |
+
<p
|
| 343 |
+
aria-hidden
|
| 344 |
+
className="pointer-events-none absolute bottom-[58px] right-[52px] whitespace-nowrap text-right font-display text-[16px] italic leading-snug text-muted [text-shadow:0_0_6px_var(--surface),0_0_6px_var(--surface),0_0_6px_var(--surface)]"
|
| 345 |
+
>
|
| 346 |
+
multilingual: strong CS, weak overall
|
| 347 |
+
</p>
|
| 348 |
+
</div>
|
| 349 |
+
);
|
| 350 |
+
}
|
src/components/compare/ParetoSection.tsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useMemo, useState } from "react";
|
| 4 |
+
import SplitToggle from "@/components/ui/SplitToggle";
|
| 5 |
+
import ProvenanceChip from "@/components/ui/ProvenanceChip";
|
| 6 |
+
import { categoryLabels, splitLabels } from "@/lib/data";
|
| 7 |
+
import type { Category, ModelRow, Split } from "@/lib/types";
|
| 8 |
+
import { categoryColor, isApiCategory, paretoData } from "./compare-utils";
|
| 9 |
+
import ParetoChart from "./ParetoChart";
|
| 10 |
+
|
| 11 |
+
export interface ParetoSectionProps {
|
| 12 |
+
models: ModelRow[];
|
| 13 |
+
/** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */
|
| 14 |
+
datasetName?: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export default function ParetoSection({
|
| 18 |
+
models,
|
| 19 |
+
datasetName = "ViMedCSS",
|
| 20 |
+
}: ParetoSectionProps) {
|
| 21 |
+
const [split, setSplit] = useState<Split>("test");
|
| 22 |
+
const data = useMemo(() => paretoData(models, split), [models, split]);
|
| 23 |
+
const running = models.filter((m) => m.provenance === "running");
|
| 24 |
+
|
| 25 |
+
const categories = useMemo(() => {
|
| 26 |
+
const present = new Set<Category>(data.map((d) => d.model.category));
|
| 27 |
+
return (Object.keys(categoryLabels) as Category[]).filter((c) =>
|
| 28 |
+
present.has(c),
|
| 29 |
+
);
|
| 30 |
+
}, [data]);
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<section aria-label="WER versus CS-WER Pareto frontier">
|
| 34 |
+
<div className="glass depth-2 spotlight-card rounded-sm p-5">
|
| 35 |
+
{/* control row: legend chips left, split toggle right */}
|
| 36 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 37 |
+
<ul className="num flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11px] tracking-wide text-muted">
|
| 38 |
+
{categories.map((c) => (
|
| 39 |
+
<li key={c} className="flex items-center gap-1.5">
|
| 40 |
+
<span
|
| 41 |
+
aria-hidden
|
| 42 |
+
className={
|
| 43 |
+
isApiCategory(c)
|
| 44 |
+
? "inline-block size-2 rotate-45"
|
| 45 |
+
: "inline-block size-2 rounded-full"
|
| 46 |
+
}
|
| 47 |
+
style={{ background: categoryColor[c] }}
|
| 48 |
+
/>
|
| 49 |
+
{categoryLabels[c]}
|
| 50 |
+
</li>
|
| 51 |
+
))}
|
| 52 |
+
<li className="flex items-center gap-1.5">
|
| 53 |
+
<span
|
| 54 |
+
aria-hidden
|
| 55 |
+
className="inline-block size-2.5 rounded-full border border-dashed border-muted/70"
|
| 56 |
+
/>
|
| 57 |
+
paper-imported
|
| 58 |
+
</li>
|
| 59 |
+
<li className="flex items-center gap-1.5">
|
| 60 |
+
<span
|
| 61 |
+
aria-hidden
|
| 62 |
+
className="inline-block size-2.5 rounded-full border border-warn/80"
|
| 63 |
+
/>
|
| 64 |
+
contamination
|
| 65 |
+
</li>
|
| 66 |
+
<li className="flex items-center gap-1.5">
|
| 67 |
+
<span
|
| 68 |
+
aria-hidden
|
| 69 |
+
className="inline-block h-px w-4 border-t border-dashed border-accent/60"
|
| 70 |
+
/>
|
| 71 |
+
Pareto frontier
|
| 72 |
+
</li>
|
| 73 |
+
</ul>
|
| 74 |
+
<SplitToggle value={split} onChange={setSplit} />
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* keyed on split so toggling cross-fades the whole canvas in;
|
| 78 |
+
soft-appear collapses under the global reduced-motion override */}
|
| 79 |
+
<figure key={split} className="soft-appear mt-4">
|
| 80 |
+
{data.length > 0 ? (
|
| 81 |
+
<ParetoChart data={data} splitLabel={splitLabels[split]} />
|
| 82 |
+
) : (
|
| 83 |
+
<div className="flex h-[440px] flex-col items-center justify-center gap-4 rounded-sm border border-dashed border-line bg-surface-2/40 px-6 text-center">
|
| 84 |
+
<span
|
| 85 |
+
aria-hidden
|
| 86 |
+
className="pulse-dot inline-block size-2 rounded-full bg-accent"
|
| 87 |
+
/>
|
| 88 |
+
<p className="num text-[12px] tracking-[0.18em] text-muted">
|
| 89 |
+
NO PAIRED WER / CS-WER RESULTS ON THE {datasetName.toUpperCase()}{" "}
|
| 90 |
+
{splitLabels[split].toUpperCase()} SPLIT YET
|
| 91 |
+
</p>
|
| 92 |
+
<p className="max-w-md text-[13px] leading-relaxed text-muted">
|
| 93 |
+
Published baselines did not report this split. The first
|
| 94 |
+
platform-verified points will land here when the pilot runs
|
| 95 |
+
complete.
|
| 96 |
+
</p>
|
| 97 |
+
{running.length > 0 && (
|
| 98 |
+
<ul className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
|
| 99 |
+
{running.map((m) => (
|
| 100 |
+
<li key={m.slug} className="flex items-center gap-2">
|
| 101 |
+
<span className="num text-[12px] text-text">
|
| 102 |
+
{m.name}
|
| 103 |
+
</span>
|
| 104 |
+
<ProvenanceChip provenance={m.provenance} />
|
| 105 |
+
</li>
|
| 106 |
+
))}
|
| 107 |
+
</ul>
|
| 108 |
+
)}
|
| 109 |
+
</div>
|
| 110 |
+
)}
|
| 111 |
+
<figcaption className="hairline-t mt-4 pt-3 text-[13px] leading-relaxed text-muted">
|
| 112 |
+
<span className="font-display italic text-text/90">
|
| 113 |
+
The trade-off, in one plot:
|
| 114 |
+
</span>{" "}
|
| 115 |
+
on {datasetName}, Vietnamese-specialized models hold the left wall
|
| 116 |
+
but drift high on code-switched spans, multilingual models sink
|
| 117 |
+
lower on the right — and only adaptation reaches the lower-left,
|
| 118 |
+
where PhoWhisper-small + Attention-Guide sets the frontier at{" "}
|
| 119 |
+
<span className="num text-text">23.67</span> WER /{" "}
|
| 120 |
+
<span className="num text-text">19.50</span> CS-WER.
|
| 121 |
+
</figcaption>
|
| 122 |
+
</figure>
|
| 123 |
+
</div>
|
| 124 |
+
</section>
|
| 125 |
+
);
|
| 126 |
+
}
|
src/components/compare/compare-utils.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Category, MetricKey, ModelRow, Split } from "@/lib/types";
|
| 2 |
+
import { HIGHER_IS_BETTER } from "@/lib/data";
|
| 3 |
+
|
| 4 |
+
/* ------------------------------------------------------------------ */
|
| 5 |
+
/* Category color mapping — derived ONLY from globals.css tokens. */
|
| 6 |
+
/* vn-open = phosphor accent · multilingual = cool ice (accent→text) */
|
| 7 |
+
/* medical = sage · api-* = cool/neutral mixes drawn as DIAMONDS, so */
|
| 8 |
+
/* the warm --warn channel belongs exclusively to contamination. */
|
| 9 |
+
/* ------------------------------------------------------------------ */
|
| 10 |
+
|
| 11 |
+
export const categoryColor: Record<Category, string> = {
|
| 12 |
+
"vn-open": "var(--accent)",
|
| 13 |
+
"multilingual-open": "color-mix(in srgb, var(--accent) 65%, var(--text) 35%)",
|
| 14 |
+
medical: "var(--text)",
|
| 15 |
+
"api-intl": "color-mix(in srgb, var(--text) 55%, var(--muted) 45%)",
|
| 16 |
+
"api-vn": "var(--muted)",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
/** API points render as 45°-rotated squares instead of dots. */
|
| 20 |
+
export function isApiCategory(c: Category): boolean {
|
| 21 |
+
return c === "api-intl" || c === "api-vn";
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/* ------------------------------------------------------------------ */
|
| 25 |
+
/* Shared Recharts text styles — single source for both compare */
|
| 26 |
+
/* charts. 11px floor and token-driven fills keep SVG text legible on */
|
| 27 |
+
/* the lighter glass panels. */
|
| 28 |
+
/* ------------------------------------------------------------------ */
|
| 29 |
+
|
| 30 |
+
export const CHART_AXIS_TICK = {
|
| 31 |
+
fill: "var(--muted)",
|
| 32 |
+
fontSize: 11,
|
| 33 |
+
fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
|
| 34 |
+
} as const;
|
| 35 |
+
|
| 36 |
+
export const CHART_AXIS_LABEL = {
|
| 37 |
+
fill: "var(--muted)",
|
| 38 |
+
fontSize: 11,
|
| 39 |
+
letterSpacing: "0.14em",
|
| 40 |
+
fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
|
| 41 |
+
} as const;
|
| 42 |
+
|
| 43 |
+
/** Point labels knock out lines beneath them with a panel-toned halo —
|
| 44 |
+
charts now sit on glass surfaces, so the halo matches --surface,
|
| 45 |
+
not the page background. */
|
| 46 |
+
export const CHART_LABEL_KNOCKOUT = {
|
| 47 |
+
paintOrder: "stroke",
|
| 48 |
+
stroke: "var(--surface)",
|
| 49 |
+
strokeWidth: 4,
|
| 50 |
+
} as const;
|
| 51 |
+
|
| 52 |
+
/** Near-text fill for chart point labels (brighter than --muted). */
|
| 53 |
+
export const CHART_LABEL_FILL =
|
| 54 |
+
"color-mix(in srgb, var(--text) 86%, var(--muted) 14%)";
|
| 55 |
+
|
| 56 |
+
/** Selection-order palette for head-to-head columns and profile bars.
|
| 57 |
+
Series 2 reads as bone-white with a teal cast so it never doubles the
|
| 58 |
+
accent; series 3 bars render hairline-outlined with transparent fill. */
|
| 59 |
+
export const seriesColor: readonly string[] = [
|
| 60 |
+
"var(--accent)",
|
| 61 |
+
"color-mix(in srgb, var(--accent) 15%, var(--text) 85%)",
|
| 62 |
+
"var(--muted)",
|
| 63 |
+
];
|
| 64 |
+
|
| 65 |
+
/* ------------------------------------------------------------------ */
|
| 66 |
+
/* Compact display names for chart point labels */
|
| 67 |
+
/* ------------------------------------------------------------------ */
|
| 68 |
+
|
| 69 |
+
const SHORT_NAMES: Record<string, string> = {
|
| 70 |
+
"phowhisper-small-ag": "PhoWhisper-S ·AG",
|
| 71 |
+
"phowhisper-small-lora": "PhoWhisper-S ·LoRA",
|
| 72 |
+
vietasr: "VietASR 68M",
|
| 73 |
+
"phowhisper-large": "PhoWhisper-L",
|
| 74 |
+
"whisper-large-v3": "Whisper-L v3",
|
| 75 |
+
"phowhisper-small": "PhoWhisper-S",
|
| 76 |
+
"wav2vec2-base-vi": "w2v2-base-vi",
|
| 77 |
+
"whisper-small": "Whisper-S",
|
| 78 |
+
"mms-1b": "MMS-1B",
|
| 79 |
+
"zipformer-30m": "Zipformer-30M",
|
| 80 |
+
"qwen3-asr-0.6b": "Qwen3-ASR-0.6B",
|
| 81 |
+
"gemini-2.5-pro": "Gemini 2.5 Pro",
|
| 82 |
+
"gpt-4o-transcribe": "gpt-4o-transcribe",
|
| 83 |
+
"elevenlabs-scribe-v2": "Scribe v2",
|
| 84 |
+
"fpt-ai-stt": "FPT.AI STT",
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
export function shortName(m: ModelRow): string {
|
| 88 |
+
return SHORT_NAMES[m.slug] ?? m.name;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/* ------------------------------------------------------------------ */
|
| 92 |
+
/* Pareto math (minimize both axes; lower-left is better) */
|
| 93 |
+
/* ------------------------------------------------------------------ */
|
| 94 |
+
|
| 95 |
+
export interface ParetoDatum {
|
| 96 |
+
wer: number;
|
| 97 |
+
csWer: number;
|
| 98 |
+
werCi?: [number, number];
|
| 99 |
+
csWerCi?: [number, number];
|
| 100 |
+
model: ModelRow;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/** One datum per model holding BOTH wer and csWer on the split. */
|
| 104 |
+
export function paretoData(models: ModelRow[], split: Split): ParetoDatum[] {
|
| 105 |
+
return models.flatMap((m) => {
|
| 106 |
+
const s = m.metrics?.[split];
|
| 107 |
+
if (typeof s?.wer !== "number" || typeof s?.csWer !== "number") return [];
|
| 108 |
+
return [
|
| 109 |
+
{
|
| 110 |
+
wer: s.wer,
|
| 111 |
+
csWer: s.csWer,
|
| 112 |
+
werCi: s.werCi,
|
| 113 |
+
csWerCi: s.csWerCi,
|
| 114 |
+
model: m,
|
| 115 |
+
},
|
| 116 |
+
];
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/** Non-dominated subset, sorted by WER ascending (csWer strictly falls). */
|
| 121 |
+
export function paretoFrontier(points: ParetoDatum[]): ParetoDatum[] {
|
| 122 |
+
const sorted = [...points].sort((a, b) => a.wer - b.wer || a.csWer - b.csWer);
|
| 123 |
+
const out: ParetoDatum[] = [];
|
| 124 |
+
let best = Infinity;
|
| 125 |
+
for (const p of sorted) {
|
| 126 |
+
if (p.csWer < best) {
|
| 127 |
+
out.push(p);
|
| 128 |
+
best = p.csWer;
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
return out;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export type Segment = readonly [
|
| 135 |
+
{ x: number; y: number },
|
| 136 |
+
{ x: number; y: number },
|
| 137 |
+
];
|
| 138 |
+
|
| 139 |
+
/**
|
| 140 |
+
* Staircase boundary of the region dominated by the frontier,
|
| 141 |
+
* extended to the chart domain edges. Steps go right, then down.
|
| 142 |
+
*/
|
| 143 |
+
export function frontierSegments(
|
| 144 |
+
frontier: ParetoDatum[],
|
| 145 |
+
xMax: number,
|
| 146 |
+
yMax: number,
|
| 147 |
+
): Segment[] {
|
| 148 |
+
if (frontier.length === 0) return [];
|
| 149 |
+
const segs: Segment[] = [];
|
| 150 |
+
const first = frontier[0];
|
| 151 |
+
const last = frontier[frontier.length - 1];
|
| 152 |
+
// drop in from the top edge to the best-WER point
|
| 153 |
+
segs.push([
|
| 154 |
+
{ x: first.wer, y: yMax },
|
| 155 |
+
{ x: first.wer, y: first.csWer },
|
| 156 |
+
]);
|
| 157 |
+
for (let i = 0; i < frontier.length - 1; i += 1) {
|
| 158 |
+
const a = frontier[i];
|
| 159 |
+
const b = frontier[i + 1];
|
| 160 |
+
segs.push([
|
| 161 |
+
{ x: a.wer, y: a.csWer },
|
| 162 |
+
{ x: b.wer, y: a.csWer },
|
| 163 |
+
]);
|
| 164 |
+
segs.push([
|
| 165 |
+
{ x: b.wer, y: a.csWer },
|
| 166 |
+
{ x: b.wer, y: b.csWer },
|
| 167 |
+
]);
|
| 168 |
+
}
|
| 169 |
+
// run out to the right edge at the best CS-WER level
|
| 170 |
+
segs.push([
|
| 171 |
+
{ x: last.wer, y: last.csWer },
|
| 172 |
+
{ x: xMax, y: last.csWer },
|
| 173 |
+
]);
|
| 174 |
+
return segs;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/**
|
| 178 |
+
* Non-overlapping vertical bands covering the dominated region (up-right
|
| 179 |
+
* of the staircase): band i spans x from frontier[i] to frontier[i+1]
|
| 180 |
+
* (last band runs to xMax), y from frontier[i].csWer to yMax.
|
| 181 |
+
*/
|
| 182 |
+
export function dominatedBands(
|
| 183 |
+
frontier: ParetoDatum[],
|
| 184 |
+
xMax: number,
|
| 185 |
+
yMax: number,
|
| 186 |
+
): Array<{ x1: number; x2: number; y1: number; y2: number }> {
|
| 187 |
+
return frontier.map((p, i) => ({
|
| 188 |
+
x1: p.wer,
|
| 189 |
+
x2: frontier[i + 1]?.wer ?? xMax,
|
| 190 |
+
y1: p.csWer,
|
| 191 |
+
y2: yMax,
|
| 192 |
+
}));
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* ------------------------------------------------------------------ */
|
| 196 |
+
/* Axis domain helpers (data-driven, padded, rounded to 2s or 5s) */
|
| 197 |
+
/* ------------------------------------------------------------------ */
|
| 198 |
+
|
| 199 |
+
/**
|
| 200 |
+
* Data extent padded ~10–15% and snapped outward — to multiples of 2 when
|
| 201 |
+
* the span is tight (< 15 units) so the points command the canvas, else 5.
|
| 202 |
+
*/
|
| 203 |
+
export function niceDomain(values: number[]): [number, number] {
|
| 204 |
+
if (values.length === 0) return [0, 100];
|
| 205 |
+
const min = Math.min(...values);
|
| 206 |
+
const max = Math.max(...values);
|
| 207 |
+
const pad = Math.max((max - min) * 0.1, 1.5);
|
| 208 |
+
const snap = max - min < 15 ? 2 : 5;
|
| 209 |
+
return [
|
| 210 |
+
Math.floor((min - pad) / snap) * snap,
|
| 211 |
+
Math.ceil((max + pad) / snap) * snap,
|
| 212 |
+
];
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/** Tick positions for a niceDomain: every 2 / 5 / 10 by span width. */
|
| 216 |
+
export function domainTicks([lo, hi]: [number, number]): number[] {
|
| 217 |
+
const span = hi - lo;
|
| 218 |
+
const step = span <= 16 ? 2 : span <= 30 ? 5 : 10;
|
| 219 |
+
const out: number[] = [];
|
| 220 |
+
for (let t = lo; t <= hi; t += step) out.push(t);
|
| 221 |
+
return out;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* ------------------------------------------------------------------ */
|
| 225 |
+
/* Head-to-head helpers */
|
| 226 |
+
/* ------------------------------------------------------------------ */
|
| 227 |
+
|
| 228 |
+
export const H2H_METRICS: readonly MetricKey[] = [
|
| 229 |
+
"wer",
|
| 230 |
+
"csWer",
|
| 231 |
+
"nWer",
|
| 232 |
+
"cer",
|
| 233 |
+
"mtrExact",
|
| 234 |
+
"mtrFuzzy",
|
| 235 |
+
"rtfx",
|
| 236 |
+
];
|
| 237 |
+
|
| 238 |
+
export const METRIC_UNIT: Record<MetricKey, string> = {
|
| 239 |
+
wer: "%",
|
| 240 |
+
cer: "%",
|
| 241 |
+
csWer: "%",
|
| 242 |
+
nWer: "%",
|
| 243 |
+
mtrExact: "%",
|
| 244 |
+
mtrFuzzy: "%",
|
| 245 |
+
rtfx: "×",
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
export type DeltaTone = "better" | "worse" | "flat";
|
| 249 |
+
|
| 250 |
+
/** Sign-aware quality of a challenger-minus-baseline delta. */
|
| 251 |
+
export function deltaTone(delta: number, metric: MetricKey): DeltaTone {
|
| 252 |
+
if (delta === 0) return "flat";
|
| 253 |
+
const improved = HIGHER_IS_BETTER.has(metric) ? delta > 0 : delta < 0;
|
| 254 |
+
return improved ? "better" : "worse";
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
/** "+3.23" / "−1.40" / "±0.00" (true minus sign for mono alignment). */
|
| 258 |
+
export function fmtDelta(delta: number, digits = 2): string {
|
| 259 |
+
const sign = delta > 0 ? "+" : delta < 0 ? "−" : "±";
|
| 260 |
+
return `${sign}${Math.abs(delta).toFixed(digits)}`;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
/** True when the model has no numeric metric at all on the split. */
|
| 264 |
+
export function hasEvidenceGap(m: ModelRow, split: Split): boolean {
|
| 265 |
+
const s = m.metrics?.[split];
|
| 266 |
+
if (!s) return true;
|
| 267 |
+
return !H2H_METRICS.some((k) => typeof s[k] === "number");
|
| 268 |
+
}
|
src/components/docs/CautionCallout.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
import { AlertTriangle } from "lucide-react";
|
| 3 |
+
import type { ReactNode } from "react";
|
| 4 |
+
|
| 5 |
+
export interface CautionCalloutProps {
|
| 6 |
+
/** mono kicker, e.g. "Caution · Label provenance" */
|
| 7 |
+
eyebrow?: string;
|
| 8 |
+
title: string;
|
| 9 |
+
children: ReactNode;
|
| 10 |
+
className?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Prominent amber caution block — reserved for contamination and
|
| 15 |
+
* domain-validity warnings (--warn semantics only).
|
| 16 |
+
*/
|
| 17 |
+
export default function CautionCallout({
|
| 18 |
+
eyebrow = "Caution",
|
| 19 |
+
title,
|
| 20 |
+
children,
|
| 21 |
+
className,
|
| 22 |
+
}: CautionCalloutProps) {
|
| 23 |
+
return (
|
| 24 |
+
<aside
|
| 25 |
+
role="note"
|
| 26 |
+
className={clsx(
|
| 27 |
+
"depth-1 rounded-sm border border-warn/30 bg-warn-dim p-5",
|
| 28 |
+
className,
|
| 29 |
+
)}
|
| 30 |
+
>
|
| 31 |
+
<div className="flex items-start gap-3">
|
| 32 |
+
<AlertTriangle size={18} aria-hidden className="mt-0.5 shrink-0 text-warn" />
|
| 33 |
+
<div>
|
| 34 |
+
<p className="num text-[11px] uppercase tracking-[0.2em] text-warn">
|
| 35 |
+
{eyebrow}
|
| 36 |
+
</p>
|
| 37 |
+
<h3 className="mt-1 font-display text-xl text-text">{title}</h3>
|
| 38 |
+
<div className="mt-2 flex flex-col gap-2 text-[13px] leading-relaxed text-muted">
|
| 39 |
+
{children}
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</aside>
|
| 44 |
+
);
|
| 45 |
+
}
|
src/components/docs/HourBar.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
|
| 3 |
+
export interface HourBarProps {
|
| 4 |
+
/** 0..1 portion of the track to fill */
|
| 5 |
+
share: number;
|
| 6 |
+
/** accent = primary leaderboard data · accent-soft = secondary live data · muted = everything else */
|
| 7 |
+
tone?: "muted" | "accent" | "accent-soft";
|
| 8 |
+
className?: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const tones: Record<NonNullable<HourBarProps["tone"]>, string> = {
|
| 12 |
+
/* token-derived teal-tinted fill so even "everything else" bars carry
|
| 13 |
+
a quiet phosphor cast instead of grey-on-grey */
|
| 14 |
+
muted: "bg-[color-mix(in_srgb,var(--accent)_18%,var(--muted)_50%)]",
|
| 15 |
+
accent: "bg-accent/75",
|
| 16 |
+
"accent-soft": "bg-accent/40",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Proportional hour bar. Purely decorative — always pair with the printed
|
| 21 |
+
* number; the bar itself is aria-hidden.
|
| 22 |
+
*/
|
| 23 |
+
export default function HourBar({
|
| 24 |
+
share,
|
| 25 |
+
tone = "muted",
|
| 26 |
+
className,
|
| 27 |
+
}: HourBarProps) {
|
| 28 |
+
const clamped = Math.max(0, Math.min(1, share));
|
| 29 |
+
return (
|
| 30 |
+
<div
|
| 31 |
+
aria-hidden
|
| 32 |
+
className={clsx(
|
| 33 |
+
"hairline h-2 w-full overflow-hidden rounded-sm bg-surface-2",
|
| 34 |
+
className,
|
| 35 |
+
)}
|
| 36 |
+
>
|
| 37 |
+
<div
|
| 38 |
+
className={clsx("h-full", tones[tone])}
|
| 39 |
+
style={{ width: `${clamped * 100}%` }}
|
| 40 |
+
/>
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
src/components/docs/ManifestCard.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ANCHOR_DATASET_ID, HARNESS_VERSION, NORMALIZER_VERSION } from "@/lib/data";
|
| 2 |
+
|
| 3 |
+
export interface ManifestCardProps {
|
| 4 |
+
/** Registry id rendered in the exemplar's dataset field. */
|
| 5 |
+
datasetId?: string;
|
| 6 |
+
className?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const manifestFor = (datasetId: string) => `{
|
| 10 |
+
"run_id": "2026-06-09T21:14:02Z-a41f",
|
| 11 |
+
"model": { "id": "vinai/PhoWhisper-small", "revision": "84f3c1d" },
|
| 12 |
+
"harness": "${HARNESS_VERSION}",
|
| 13 |
+
"normalizer": "${NORMALIZER_VERSION}",
|
| 14 |
+
"dataset": { "id": "${datasetId}", "split": "test" },
|
| 15 |
+
"hardware": "1x RTX 4090 24GB / CUDA 12.4",
|
| 16 |
+
"seeds": { "global": 1337 },
|
| 17 |
+
"decode": { "beam_size": 5, "temperature": 0.0, "bias_list": null }
|
| 18 |
+
}`;
|
| 19 |
+
|
| 20 |
+
const FIELDS: ReadonlyArray<{ key: string; gloss: string }> = [
|
| 21 |
+
{
|
| 22 |
+
key: "model.revision",
|
| 23 |
+
gloss: "Pins exact weights — a commit hash, never a moving tag.",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
key: "harness",
|
| 27 |
+
gloss: "Version of the scoring code that produced every number in the row.",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
key: "normalizer",
|
| 31 |
+
gloss: "Text-normalization version. WER is only comparable within it.",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
key: "dataset",
|
| 35 |
+
gloss:
|
| 36 |
+
"Registry id and split the run scored — every manifest is scoped to exactly one dataset.",
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
key: "hardware",
|
| 40 |
+
gloss: "The machine behind RTFx — published so speed claims have context.",
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
key: "seeds",
|
| 44 |
+
gloss: "Decode determinism: same manifest in, same transcript out.",
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
key: "decode",
|
| 48 |
+
gloss: "Beam, temperature, bias list — Track B records the bias-list hash here.",
|
| 49 |
+
},
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
/** Anatomy of a run manifest: schematic mono block plus field glosses. */
|
| 53 |
+
export default function ManifestCard({
|
| 54 |
+
datasetId = ANCHOR_DATASET_ID,
|
| 55 |
+
className,
|
| 56 |
+
}: ManifestCardProps) {
|
| 57 |
+
return (
|
| 58 |
+
<div
|
| 59 |
+
className={`hairline spotlight-card depth-1 flex flex-col gap-4 rounded-sm bg-surface p-5${className ? ` ${className}` : ""}`}
|
| 60 |
+
>
|
| 61 |
+
<p className="num text-[11px] uppercase tracking-[0.18em] text-muted">
|
| 62 |
+
run_manifest.json — published with every platform-verified run
|
| 63 |
+
</p>
|
| 64 |
+
<pre className="num hairline overflow-x-auto rounded-sm bg-surface-2 p-4 text-[12px] leading-relaxed text-text">
|
| 65 |
+
{manifestFor(datasetId)}
|
| 66 |
+
</pre>
|
| 67 |
+
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
| 68 |
+
{FIELDS.map((f) => (
|
| 69 |
+
<div key={f.key}>
|
| 70 |
+
<dt className="num text-[12px] text-text">{f.key}</dt>
|
| 71 |
+
<dd className="mt-0.5 text-[12px] leading-relaxed text-muted">
|
| 72 |
+
{f.gloss}
|
| 73 |
+
</dd>
|
| 74 |
+
</div>
|
| 75 |
+
))}
|
| 76 |
+
</dl>
|
| 77 |
+
</div>
|
| 78 |
+
);
|
| 79 |
+
}
|
src/components/docs/MetricDefCard.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
import type { ReactNode } from "react";
|
| 3 |
+
import Badge from "@/components/ui/Badge";
|
| 4 |
+
|
| 5 |
+
export interface MetricDefCardProps {
|
| 6 |
+
/** short symbol, e.g. "CS-WER" */
|
| 7 |
+
abbr: string;
|
| 8 |
+
/** spelled-out name, e.g. "Code-switch word error rate" */
|
| 9 |
+
name: string;
|
| 10 |
+
/** mono formula line; omit on roadmap ghosts without a fixed definition */
|
| 11 |
+
formula?: string;
|
| 12 |
+
/** "lower is better" / "higher is better" */
|
| 13 |
+
direction?: string;
|
| 14 |
+
/** ghost treatment for roadmap metrics */
|
| 15 |
+
roadmap?: boolean;
|
| 16 |
+
children: ReactNode;
|
| 17 |
+
className?: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/** Typeset metric definition card: serif symbol, mono formula, prose gloss. */
|
| 21 |
+
export default function MetricDefCard({
|
| 22 |
+
abbr,
|
| 23 |
+
name,
|
| 24 |
+
formula,
|
| 25 |
+
direction,
|
| 26 |
+
roadmap = false,
|
| 27 |
+
children,
|
| 28 |
+
className,
|
| 29 |
+
}: MetricDefCardProps) {
|
| 30 |
+
return (
|
| 31 |
+
<div
|
| 32 |
+
className={clsx(
|
| 33 |
+
"flex flex-col gap-2.5 rounded-sm p-5 spotlight-card",
|
| 34 |
+
roadmap
|
| 35 |
+
? "border border-dashed border-line bg-transparent"
|
| 36 |
+
: "hairline depth-1 bg-surface",
|
| 37 |
+
className,
|
| 38 |
+
)}
|
| 39 |
+
>
|
| 40 |
+
<div className="flex items-baseline justify-between gap-3">
|
| 41 |
+
<h3
|
| 42 |
+
className={clsx(
|
| 43 |
+
"font-display text-2xl leading-none",
|
| 44 |
+
roadmap ? "text-muted" : "text-text",
|
| 45 |
+
)}
|
| 46 |
+
>
|
| 47 |
+
{abbr}
|
| 48 |
+
</h3>
|
| 49 |
+
{roadmap ? (
|
| 50 |
+
<Badge variant="ghost">Roadmap</Badge>
|
| 51 |
+
) : (
|
| 52 |
+
direction && (
|
| 53 |
+
<span className="num whitespace-nowrap text-[11px] uppercase tracking-[0.14em] text-muted">
|
| 54 |
+
{direction}
|
| 55 |
+
</span>
|
| 56 |
+
)
|
| 57 |
+
)}
|
| 58 |
+
</div>
|
| 59 |
+
<p className="text-[11px] uppercase tracking-[0.14em] text-muted">
|
| 60 |
+
{name}
|
| 61 |
+
</p>
|
| 62 |
+
{formula && (
|
| 63 |
+
<code
|
| 64 |
+
className={clsx(
|
| 65 |
+
"num block whitespace-pre-wrap rounded-sm px-3 py-2 text-[12px]",
|
| 66 |
+
roadmap
|
| 67 |
+
? "border border-dashed border-line text-muted"
|
| 68 |
+
: "hairline bg-surface-2 text-text",
|
| 69 |
+
)}
|
| 70 |
+
>
|
| 71 |
+
{formula}
|
| 72 |
+
</code>
|
| 73 |
+
)}
|
| 74 |
+
<p className="text-[13px] leading-relaxed text-muted">{children}</p>
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
}
|
src/components/docs/OnboardingCriteria.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
|
| 3 |
+
export interface OnboardingCriterion {
|
| 4 |
+
title: string;
|
| 5 |
+
detail: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* The six onboarding criteria — single source of truth, consumed by the
|
| 10 |
+
* methodology #onboarding section, the datasets index CTA card, and the
|
| 11 |
+
* coming-soon dataset teasers.
|
| 12 |
+
*/
|
| 13 |
+
export const ONBOARDING_CRITERIA: ReadonlyArray<OnboardingCriterion> = [
|
| 14 |
+
{
|
| 15 |
+
title: "Open or contracted license",
|
| 16 |
+
detail:
|
| 17 |
+
"Redistribution rights documented up front — an open license, or a signed contract for held-out corpora like the private clinic set.",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
title: "Transparent provenance",
|
| 21 |
+
detail:
|
| 22 |
+
"Where every clip came from, how transcripts were produced, and who verified them — published as part of the dataset profile.",
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
title: "Fixed frozen splits",
|
| 26 |
+
detail:
|
| 27 |
+
"Train, Valid, Test (and Hard where defined) frozen before the first run. Any revision creates a new dataset version — never a silent edit.",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
title: "Contamination policy",
|
| 31 |
+
detail:
|
| 32 |
+
"Systems that touched the labels — for example LLM-seeded transcripts — are declared and flagged permanently on that dataset's leaderboards.",
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
title: "Normalizer compatibility",
|
| 36 |
+
detail:
|
| 37 |
+
"References must score cleanly under the platform normalizer; orthographic quirks are documented and resolved before the first run.",
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
title: "Six-metric coverage",
|
| 41 |
+
detail:
|
| 42 |
+
"WER, CER, CS-WER, N-WER, MTR, and RTFx must be computable from the references — or the gaps declared on the dataset profile.",
|
| 43 |
+
},
|
| 44 |
+
];
|
| 45 |
+
|
| 46 |
+
export interface OnboardingCriteriaProps {
|
| 47 |
+
items?: ReadonlyArray<OnboardingCriterion>;
|
| 48 |
+
className?: string;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** Numbered grid of dataset-onboarding criteria. */
|
| 52 |
+
export default function OnboardingCriteria({
|
| 53 |
+
items = ONBOARDING_CRITERIA,
|
| 54 |
+
className,
|
| 55 |
+
}: OnboardingCriteriaProps) {
|
| 56 |
+
return (
|
| 57 |
+
<ol className={clsx("grid gap-3 sm:grid-cols-2 lg:grid-cols-3", className)}>
|
| 58 |
+
{items.map((item, i) => (
|
| 59 |
+
<li
|
| 60 |
+
key={item.title}
|
| 61 |
+
className="hairline spotlight-card depth-1 flex flex-col gap-1.5 rounded-sm bg-surface p-4"
|
| 62 |
+
>
|
| 63 |
+
<div className="flex items-baseline gap-2.5">
|
| 64 |
+
<span aria-hidden className="num text-[12px] text-muted">
|
| 65 |
+
{String(i + 1).padStart(2, "0")}
|
| 66 |
+
</span>
|
| 67 |
+
<h3 className="text-[14px] font-medium text-text">{item.title}</h3>
|
| 68 |
+
</div>
|
| 69 |
+
<p className="pl-[1.85rem] text-[13px] leading-relaxed text-muted">
|
| 70 |
+
{item.detail}
|
| 71 |
+
</p>
|
| 72 |
+
</li>
|
| 73 |
+
))}
|
| 74 |
+
</ol>
|
| 75 |
+
);
|
| 76 |
+
}
|
src/components/docs/PipelineSteps.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface PipelineStep {
|
| 2 |
+
title: string;
|
| 3 |
+
detail: string;
|
| 4 |
+
/** mono stat shown beside the title, e.g. "64,232 entries" */
|
| 5 |
+
stat?: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export interface PipelineStepsProps {
|
| 9 |
+
steps: PipelineStep[];
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/** Numbered construction-pipeline steps with a hairline connector spine. */
|
| 13 |
+
export default function PipelineSteps({ steps }: PipelineStepsProps) {
|
| 14 |
+
return (
|
| 15 |
+
<ol className="flex flex-col stagger-spring">
|
| 16 |
+
{steps.map((step, i) => (
|
| 17 |
+
<li
|
| 18 |
+
key={step.title}
|
| 19 |
+
className="relative grid grid-cols-[2.5rem_1fr] gap-x-4 pb-7 last:pb-0"
|
| 20 |
+
>
|
| 21 |
+
{i < steps.length - 1 && (
|
| 22 |
+
<span
|
| 23 |
+
aria-hidden
|
| 24 |
+
className="absolute bottom-0 left-5 top-11 w-px bg-line"
|
| 25 |
+
/>
|
| 26 |
+
)}
|
| 27 |
+
<span
|
| 28 |
+
aria-hidden
|
| 29 |
+
className="num hairline flex size-10 items-center justify-center rounded-sm bg-surface-2 text-[13px] text-text"
|
| 30 |
+
>
|
| 31 |
+
{String(i + 1).padStart(2, "0")}
|
| 32 |
+
</span>
|
| 33 |
+
<div className="pt-1.5">
|
| 34 |
+
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5">
|
| 35 |
+
<h3 className="text-sm font-medium text-text">{step.title}</h3>
|
| 36 |
+
{step.stat && (
|
| 37 |
+
<span className="num text-[12px] text-muted">{step.stat}</span>
|
| 38 |
+
)}
|
| 39 |
+
</div>
|
| 40 |
+
<p className="mt-1 max-w-xl text-[13px] leading-relaxed text-muted">
|
| 41 |
+
{step.detail}
|
| 42 |
+
</p>
|
| 43 |
+
</div>
|
| 44 |
+
</li>
|
| 45 |
+
))}
|
| 46 |
+
</ol>
|
| 47 |
+
);
|
| 48 |
+
}
|
src/components/docs/ProvenanceLadder.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ProvenanceChip from "@/components/ui/ProvenanceChip";
|
| 2 |
+
import type { Provenance } from "@/lib/types";
|
| 3 |
+
|
| 4 |
+
const LADDER: ReadonlyArray<{ p: Provenance; desc: string }> = [
|
| 5 |
+
{
|
| 6 |
+
p: "platform-verified",
|
| 7 |
+
desc: "Run by the platform harness, on platform hardware, from a pinned model revision. The full run manifest is published beside the number. The only class eligible for a SOTA badge.",
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
p: "self-reported",
|
| 11 |
+
desc: "Submitted by the system’s authors with a manifest, reproduced on their hardware rather than ours. Honor-system until the private canary subset ships.",
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
p: "api-snapshot",
|
| 15 |
+
desc: "Run through the platform harness against the live commercial API on a given date. We hold the hypotheses and bootstrap CIs, but the service has no pinned revision and runs on the vendor’s hardware — so RTFx is omitted and the row is never SOTA-eligible. Re-run on a quarterly drift schedule.",
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
p: "paper-imported",
|
| 19 |
+
desc: "Transcribed from a published paper. Normalizer and decode settings may differ from ours, so these rows wear a dashed outline everywhere they appear.",
|
| 20 |
+
},
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
const STATES: ReadonlyArray<{ p: Provenance; desc: string }> = [
|
| 24 |
+
{
|
| 25 |
+
p: "running",
|
| 26 |
+
desc: "A platform run is executing right now. Metrics appear the moment the run completes and is verified.",
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
p: "planned",
|
| 30 |
+
desc: "Queued for a platform run. No numbers yet — the row exists so the roadmap is legible.",
|
| 31 |
+
},
|
| 32 |
+
];
|
| 33 |
+
|
| 34 |
+
/** Trust ladder: three provenance classes ranked, plus the two row states. */
|
| 35 |
+
export default function ProvenanceLadder() {
|
| 36 |
+
return (
|
| 37 |
+
<div className="flex flex-col gap-4 stagger-spring">
|
| 38 |
+
<ol className="hairline spotlight-card depth-1 flex flex-col rounded-sm bg-surface px-5 py-1">
|
| 39 |
+
{LADDER.map((item, i) => (
|
| 40 |
+
<li
|
| 41 |
+
key={item.p}
|
| 42 |
+
className={
|
| 43 |
+
"grid grid-cols-[2rem_minmax(9.5rem,11rem)_1fr] items-start gap-x-4 py-4 " +
|
| 44 |
+
(i < LADDER.length - 1 ? "hairline-b" : "")
|
| 45 |
+
}
|
| 46 |
+
>
|
| 47 |
+
<span className="num pt-0.5 text-[12px] text-muted">
|
| 48 |
+
{String(i + 1).padStart(2, "0")}
|
| 49 |
+
</span>
|
| 50 |
+
<ProvenanceChip provenance={item.p} className="justify-self-start" />
|
| 51 |
+
<p className="text-[13px] leading-relaxed text-muted">{item.desc}</p>
|
| 52 |
+
</li>
|
| 53 |
+
))}
|
| 54 |
+
</ol>
|
| 55 |
+
|
| 56 |
+
<div className="hairline spotlight-card depth-1 flex flex-col rounded-sm bg-surface px-5 py-1">
|
| 57 |
+
<p className="num hairline-b py-3 text-[11px] uppercase tracking-[0.18em] text-muted">
|
| 58 |
+
Row states — not trust classes
|
| 59 |
+
</p>
|
| 60 |
+
<ul className="flex flex-col">
|
| 61 |
+
{STATES.map((item, i) => (
|
| 62 |
+
<li
|
| 63 |
+
key={item.p}
|
| 64 |
+
className={
|
| 65 |
+
"grid grid-cols-[2rem_minmax(9.5rem,11rem)_1fr] items-start gap-x-4 py-4 " +
|
| 66 |
+
(i < STATES.length - 1 ? "hairline-b" : "")
|
| 67 |
+
}
|
| 68 |
+
>
|
| 69 |
+
<span aria-hidden className="num pt-0.5 text-[12px] text-muted/50">
|
| 70 |
+
—
|
| 71 |
+
</span>
|
| 72 |
+
<ProvenanceChip provenance={item.p} className="justify-self-start" />
|
| 73 |
+
<p className="text-[13px] leading-relaxed text-muted">
|
| 74 |
+
{item.desc}
|
| 75 |
+
</p>
|
| 76 |
+
</li>
|
| 77 |
+
))}
|
| 78 |
+
</ul>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
);
|
| 82 |
+
}
|
src/components/docs/RoadmapStrip.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
import Badge from "@/components/ui/Badge";
|
| 3 |
+
|
| 4 |
+
export interface RoadmapItem {
|
| 5 |
+
name: string;
|
| 6 |
+
status: "live" | "next" | "north-star";
|
| 7 |
+
/** leading data figure, typeset in mono (e.g. "34.57 h") */
|
| 8 |
+
stat?: string;
|
| 9 |
+
detail: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export interface RoadmapStripProps {
|
| 13 |
+
items: RoadmapItem[];
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const statusMeta: Record<
|
| 17 |
+
RoadmapItem["status"],
|
| 18 |
+
{ label: string; variant: "accent" | "outline" | "ghost"; card: string }
|
| 19 |
+
> = {
|
| 20 |
+
live: {
|
| 21 |
+
label: "Live",
|
| 22 |
+
variant: "accent",
|
| 23 |
+
card: "hairline depth-1 bg-surface",
|
| 24 |
+
},
|
| 25 |
+
next: {
|
| 26 |
+
label: "Next",
|
| 27 |
+
variant: "outline",
|
| 28 |
+
card: "hairline depth-1 bg-surface",
|
| 29 |
+
},
|
| 30 |
+
"north-star": {
|
| 31 |
+
label: "North star",
|
| 32 |
+
variant: "ghost",
|
| 33 |
+
card: "border border-dashed border-line bg-transparent",
|
| 34 |
+
},
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
/** Multi-dataset roadmap: live anchor → next corpus → north-star evaluation. */
|
| 38 |
+
export default function RoadmapStrip({ items }: RoadmapStripProps) {
|
| 39 |
+
return (
|
| 40 |
+
<ol className="grid gap-3 md:grid-cols-3 stagger-spring">
|
| 41 |
+
{items.map((item, i) => {
|
| 42 |
+
const meta = statusMeta[item.status];
|
| 43 |
+
return (
|
| 44 |
+
<li
|
| 45 |
+
key={item.name}
|
| 46 |
+
className={clsx("flex flex-col gap-2 rounded-sm p-5", meta.card)}
|
| 47 |
+
>
|
| 48 |
+
<div className="flex items-center justify-between gap-2">
|
| 49 |
+
<span className="num text-[11px] tracking-[0.18em] text-muted">
|
| 50 |
+
{String(i + 1).padStart(2, "0")}
|
| 51 |
+
</span>
|
| 52 |
+
<Badge variant={meta.variant}>
|
| 53 |
+
{item.status === "live" && (
|
| 54 |
+
<span
|
| 55 |
+
aria-hidden
|
| 56 |
+
className="pulse-dot inline-block size-1.5 rounded-full bg-accent"
|
| 57 |
+
/>
|
| 58 |
+
)}
|
| 59 |
+
{meta.label}
|
| 60 |
+
</Badge>
|
| 61 |
+
</div>
|
| 62 |
+
<h3
|
| 63 |
+
className={clsx(
|
| 64 |
+
"font-display text-xl",
|
| 65 |
+
item.status === "north-star" ? "text-muted" : "text-text",
|
| 66 |
+
)}
|
| 67 |
+
>
|
| 68 |
+
{item.name}
|
| 69 |
+
</h3>
|
| 70 |
+
<p className="text-[13px] leading-relaxed text-muted">
|
| 71 |
+
{item.stat && (
|
| 72 |
+
<span className="num text-text">{item.stat} </span>
|
| 73 |
+
)}
|
| 74 |
+
{item.detail}
|
| 75 |
+
</p>
|
| 76 |
+
</li>
|
| 77 |
+
);
|
| 78 |
+
})}
|
| 79 |
+
</ol>
|
| 80 |
+
);
|
| 81 |
+
}
|
src/components/docs/SplitMiniBar.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
import { fmt } from "@/lib/data";
|
| 3 |
+
import type { DatasetInfo } from "@/lib/types";
|
| 4 |
+
|
| 5 |
+
type Splits = NonNullable<DatasetInfo["splits"]>;
|
| 6 |
+
|
| 7 |
+
export interface SplitMiniBarProps {
|
| 8 |
+
splits: Splits;
|
| 9 |
+
className?: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/* Segment palette mirrors HourBar tones: Test carries the phosphor note,
|
| 13 |
+
Hard a softer echo, Train/Valid stay quiet teal-greys. */
|
| 14 |
+
const SEGMENTS: ReadonlyArray<{
|
| 15 |
+
key: keyof Splits;
|
| 16 |
+
label: string;
|
| 17 |
+
fill: string;
|
| 18 |
+
}> = [
|
| 19 |
+
{
|
| 20 |
+
key: "train",
|
| 21 |
+
label: "Train",
|
| 22 |
+
fill: "bg-[color-mix(in_srgb,var(--accent)_16%,var(--muted)_46%)]",
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
key: "valid",
|
| 26 |
+
label: "Valid",
|
| 27 |
+
fill: "bg-[color-mix(in_srgb,var(--accent)_10%,var(--muted)_30%)]",
|
| 28 |
+
},
|
| 29 |
+
{ key: "test", label: "Test", fill: "bg-accent/75" },
|
| 30 |
+
{ key: "hard", label: "Hard", fill: "bg-accent/40" },
|
| 31 |
+
];
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Compact split-distribution readout for dataset cards: one segmented
|
| 35 |
+
* hour bar plus a mono legend. The bar is decorative; the legend carries
|
| 36 |
+
* the numbers.
|
| 37 |
+
*/
|
| 38 |
+
export default function SplitMiniBar({ splits, className }: SplitMiniBarProps) {
|
| 39 |
+
const total = SEGMENTS.reduce((sum, s) => sum + splits[s.key].hours, 0);
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className={clsx("flex flex-col gap-2.5", className)}>
|
| 43 |
+
<div
|
| 44 |
+
aria-hidden
|
| 45 |
+
className="hairline flex h-2.5 w-full gap-px overflow-hidden rounded-sm bg-surface-2"
|
| 46 |
+
>
|
| 47 |
+
{SEGMENTS.map((s) => (
|
| 48 |
+
<div
|
| 49 |
+
key={s.key}
|
| 50 |
+
className={clsx("h-full", s.fill)}
|
| 51 |
+
style={{ width: `${(splits[s.key].hours / total) * 100}%` }}
|
| 52 |
+
/>
|
| 53 |
+
))}
|
| 54 |
+
</div>
|
| 55 |
+
{/* 2×2 legend — four columns crowd the card's narrow readout column
|
| 56 |
+
and wrap the unit onto its own line */}
|
| 57 |
+
<dl className="grid grid-cols-2 gap-x-8 gap-y-1.5">
|
| 58 |
+
{SEGMENTS.map((s) => (
|
| 59 |
+
<div key={s.key} className="flex items-baseline gap-1.5">
|
| 60 |
+
<span
|
| 61 |
+
aria-hidden
|
| 62 |
+
className={clsx("size-2 shrink-0 self-center rounded-[1px]", s.fill)}
|
| 63 |
+
/>
|
| 64 |
+
<dt className="text-[12px] text-muted">{s.label}</dt>
|
| 65 |
+
<dd className="num ml-auto text-[12px] whitespace-nowrap text-text">
|
| 66 |
+
{fmt(splits[s.key].hours)}
|
| 67 |
+
<span className="text-muted"> h</span>
|
| 68 |
+
</dd>
|
| 69 |
+
</div>
|
| 70 |
+
))}
|
| 71 |
+
</dl>
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
src/components/docs/SplitsTable.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Badge from "@/components/ui/Badge";
|
| 2 |
+
import { fmt } from "@/lib/data";
|
| 3 |
+
import type { DatasetInfo } from "@/lib/types";
|
| 4 |
+
import HourBar from "./HourBar";
|
| 5 |
+
|
| 6 |
+
export interface SplitsTableProps {
|
| 7 |
+
/** Frozen split inventory of a live dataset. */
|
| 8 |
+
splits: NonNullable<DatasetInfo["splits"]>;
|
| 9 |
+
/** Dataset name for the accessible caption, e.g. "ViMedCSS". */
|
| 10 |
+
datasetName?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const ROWS: ReadonlyArray<{
|
| 14 |
+
key: keyof NonNullable<DatasetInfo["splits"]>;
|
| 15 |
+
name: string;
|
| 16 |
+
tone: "muted" | "accent" | "accent-soft";
|
| 17 |
+
badge?: { label: string; variant: "accent" | "outline" };
|
| 18 |
+
role: string;
|
| 19 |
+
}> = [
|
| 20 |
+
{
|
| 21 |
+
key: "train",
|
| 22 |
+
name: "Train",
|
| 23 |
+
tone: "muted",
|
| 24 |
+
role: "Adaptation corpus — Track C systems may train on this split and nothing else.",
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
key: "valid",
|
| 28 |
+
name: "Valid",
|
| 29 |
+
tone: "muted",
|
| 30 |
+
role: "Hyperparameter tuning and checkpoint selection. Never reported as a result.",
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
key: "test",
|
| 34 |
+
name: "Test",
|
| 35 |
+
tone: "accent",
|
| 36 |
+
badge: { label: "Primary", variant: "accent" },
|
| 37 |
+
role: "The primary leaderboard — every headline number on the home page is scored here.",
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
key: "hard",
|
| 41 |
+
name: "Hard",
|
| 42 |
+
tone: "accent-soft",
|
| 43 |
+
badge: { label: "Generalization", variant: "outline" },
|
| 44 |
+
role: "Rare and unseen terms, disjoint from Train — the generalization leaderboard.",
|
| 45 |
+
},
|
| 46 |
+
];
|
| 47 |
+
|
| 48 |
+
/** Dataset split table with proportional hour-bars and role captions. */
|
| 49 |
+
export default function SplitsTable({ splits, datasetName }: SplitsTableProps) {
|
| 50 |
+
const totalHours = ROWS.reduce((sum, r) => sum + splits[r.key].hours, 0);
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="hairline spotlight-row depth-1 overflow-x-auto rounded-sm bg-surface">
|
| 54 |
+
<table className="w-full min-w-[680px] border-collapse text-sm">
|
| 55 |
+
<caption className="sr-only">
|
| 56 |
+
{datasetName ?? "Dataset"} splits: hours, utterances, share of total
|
| 57 |
+
audio, and the role of each split
|
| 58 |
+
</caption>
|
| 59 |
+
<thead>
|
| 60 |
+
<tr className="hairline-b text-left">
|
| 61 |
+
<th
|
| 62 |
+
scope="col"
|
| 63 |
+
className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
|
| 64 |
+
>
|
| 65 |
+
Split
|
| 66 |
+
</th>
|
| 67 |
+
<th
|
| 68 |
+
scope="col"
|
| 69 |
+
className="num px-4 py-3 text-right text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
|
| 70 |
+
>
|
| 71 |
+
Hours
|
| 72 |
+
</th>
|
| 73 |
+
<th
|
| 74 |
+
scope="col"
|
| 75 |
+
className="num px-4 py-3 text-right text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
|
| 76 |
+
>
|
| 77 |
+
Utterances
|
| 78 |
+
</th>
|
| 79 |
+
<th
|
| 80 |
+
scope="col"
|
| 81 |
+
className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
|
| 82 |
+
>
|
| 83 |
+
Share of audio
|
| 84 |
+
</th>
|
| 85 |
+
<th
|
| 86 |
+
scope="col"
|
| 87 |
+
className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
|
| 88 |
+
>
|
| 89 |
+
Role
|
| 90 |
+
</th>
|
| 91 |
+
</tr>
|
| 92 |
+
</thead>
|
| 93 |
+
<tbody>
|
| 94 |
+
{ROWS.map((row, i) => {
|
| 95 |
+
const s = splits[row.key];
|
| 96 |
+
const share = s.hours / totalHours;
|
| 97 |
+
return (
|
| 98 |
+
<tr
|
| 99 |
+
key={row.key}
|
| 100 |
+
className={i < ROWS.length - 1 ? "hairline-b align-top" : "align-top"}
|
| 101 |
+
>
|
| 102 |
+
<th
|
| 103 |
+
scope="row"
|
| 104 |
+
className="whitespace-nowrap px-4 py-3.5 text-left font-medium text-text"
|
| 105 |
+
>
|
| 106 |
+
<span className="inline-flex items-center gap-2">
|
| 107 |
+
{row.name}
|
| 108 |
+
{row.badge && (
|
| 109 |
+
<Badge variant={row.badge.variant}>{row.badge.label}</Badge>
|
| 110 |
+
)}
|
| 111 |
+
</span>
|
| 112 |
+
</th>
|
| 113 |
+
<td className="num whitespace-nowrap px-4 py-3.5 text-right text-text">
|
| 114 |
+
{fmt(s.hours)}
|
| 115 |
+
<span className="text-muted"> h</span>
|
| 116 |
+
</td>
|
| 117 |
+
<td className="num whitespace-nowrap px-4 py-3.5 text-right text-text">
|
| 118 |
+
{s.utts.toLocaleString("en-US")}
|
| 119 |
+
</td>
|
| 120 |
+
<td className="px-4 py-3.5">
|
| 121 |
+
<div className="flex items-center gap-2.5">
|
| 122 |
+
<HourBar share={share} tone={row.tone} className="w-24 md:w-32" />
|
| 123 |
+
<span className="num text-[11px] text-muted">
|
| 124 |
+
{(share * 100).toFixed(1)}%
|
| 125 |
+
</span>
|
| 126 |
+
</div>
|
| 127 |
+
</td>
|
| 128 |
+
<td className="min-w-[16rem] px-4 py-3.5 text-[13px] leading-relaxed text-muted">
|
| 129 |
+
{row.role}
|
| 130 |
+
</td>
|
| 131 |
+
</tr>
|
| 132 |
+
);
|
| 133 |
+
})}
|
| 134 |
+
</tbody>
|
| 135 |
+
</table>
|
| 136 |
+
</div>
|
| 137 |
+
);
|
| 138 |
+
}
|
src/components/docs/StatTile.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
|
| 3 |
+
export interface StatTileProps {
|
| 4 |
+
label: string;
|
| 5 |
+
/** pre-formatted value, e.g. "34.57" or "2–21" */
|
| 6 |
+
value: string;
|
| 7 |
+
/** rendered after the value, muted, e.g. "h" or "s" */
|
| 8 |
+
suffix?: string;
|
| 9 |
+
/** small mono sub-line */
|
| 10 |
+
sub?: string;
|
| 11 |
+
className?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Static stat tile for docs pages — visually identical to MetricCard but
|
| 16 |
+
* server-rendered and string-valued (handles ranges like "2–21 s").
|
| 17 |
+
*/
|
| 18 |
+
export default function StatTile({
|
| 19 |
+
label,
|
| 20 |
+
value,
|
| 21 |
+
suffix,
|
| 22 |
+
sub,
|
| 23 |
+
className,
|
| 24 |
+
}: StatTileProps) {
|
| 25 |
+
return (
|
| 26 |
+
<div
|
| 27 |
+
className={clsx(
|
| 28 |
+
"hairline spotlight-card depth-1 flex flex-col gap-1 rounded-sm bg-surface px-4 py-3",
|
| 29 |
+
className,
|
| 30 |
+
)}
|
| 31 |
+
>
|
| 32 |
+
<span className="text-[11px] uppercase tracking-[0.14em] text-muted">
|
| 33 |
+
{label}
|
| 34 |
+
</span>
|
| 35 |
+
<span className="num text-2xl leading-none text-text">
|
| 36 |
+
{value}
|
| 37 |
+
{suffix && <span className="ml-0.5 text-base text-muted">{suffix}</span>}
|
| 38 |
+
</span>
|
| 39 |
+
{sub && <span className="num text-[11px] text-muted">{sub}</span>}
|
| 40 |
+
</div>
|
| 41 |
+
);
|
| 42 |
+
}
|
src/components/docs/TopicBars.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { fmt } from "@/lib/data";
|
| 2 |
+
import HourBar from "./HourBar";
|
| 3 |
+
|
| 4 |
+
export interface TopicBarsProps {
|
| 5 |
+
items: ReadonlyArray<{ label: string; vi?: string; hours: number }>;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
/** Horizontal topic-distribution bars (widths relative to the largest topic). */
|
| 9 |
+
export default function TopicBars({ items }: TopicBarsProps) {
|
| 10 |
+
const max = Math.max(...items.map((t) => t.hours));
|
| 11 |
+
const total = items.reduce((sum, t) => sum + t.hours, 0);
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<ul className="hairline depth-1 flex flex-col rounded-sm bg-surface px-4 py-1">
|
| 15 |
+
{items.map((t, i) => (
|
| 16 |
+
<li
|
| 17 |
+
key={t.label}
|
| 18 |
+
className={
|
| 19 |
+
"grid grid-cols-[minmax(9rem,12rem)_1fr_auto] items-center gap-x-4 py-3 " +
|
| 20 |
+
(i < items.length - 1 ? "hairline-b" : "")
|
| 21 |
+
}
|
| 22 |
+
>
|
| 23 |
+
<span className="flex flex-col">
|
| 24 |
+
<span className="text-[13px] text-text">{t.label}</span>
|
| 25 |
+
{t.vi && (
|
| 26 |
+
<span lang="vi" className="text-[11px] leading-snug text-muted">
|
| 27 |
+
{t.vi}
|
| 28 |
+
</span>
|
| 29 |
+
)}
|
| 30 |
+
</span>
|
| 31 |
+
{/* the dominant domain carries the one quiet phosphor note */}
|
| 32 |
+
<HourBar
|
| 33 |
+
share={t.hours / max}
|
| 34 |
+
tone={t.hours === max ? "accent-soft" : "muted"}
|
| 35 |
+
/>
|
| 36 |
+
<span className="num whitespace-nowrap text-right text-[12px] text-text">
|
| 37 |
+
{fmt(t.hours, 1)}
|
| 38 |
+
<span className="text-muted"> h</span>
|
| 39 |
+
<span className="ml-2 hidden text-muted sm:inline">
|
| 40 |
+
{((t.hours / total) * 100).toFixed(0)}%
|
| 41 |
+
</span>
|
| 42 |
+
</span>
|
| 43 |
+
</li>
|
| 44 |
+
))}
|
| 45 |
+
</ul>
|
| 46 |
+
);
|
| 47 |
+
}
|
src/components/docs/TracksTable.tsx
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
import Badge from "@/components/ui/Badge";
|
| 3 |
+
|
| 4 |
+
type TrackStatus = "live" | "config-ready" | "roadmap";
|
| 5 |
+
|
| 6 |
+
const STATUS: Record<
|
| 7 |
+
TrackStatus,
|
| 8 |
+
{ label: string; variant: "accent" | "outline" | "ghost" }
|
| 9 |
+
> = {
|
| 10 |
+
live: { label: "Live", variant: "accent" },
|
| 11 |
+
"config-ready": { label: "Config-ready", variant: "outline" },
|
| 12 |
+
roadmap: { label: "Roadmap", variant: "ghost" },
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
const TRACKS: ReadonlyArray<{
|
| 16 |
+
id: string;
|
| 17 |
+
name: string;
|
| 18 |
+
rule: string;
|
| 19 |
+
status: TrackStatus;
|
| 20 |
+
}> = [
|
| 21 |
+
{
|
| 22 |
+
id: "A",
|
| 23 |
+
name: "Zero-shot",
|
| 24 |
+
rule: "The model exactly as released. No dataset-specific signal of any kind — no prompts, no bias lists, no weight updates.",
|
| 25 |
+
status: "live",
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
id: "B",
|
| 29 |
+
name: "Contextual biasing",
|
| 30 |
+
rule: "Bias lists (medical-term hotwords or prompts) allowed at decode time. No weight updates; the bias list is recorded in the run manifest.",
|
| 31 |
+
status: "live",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
id: "C",
|
| 35 |
+
name: "Adapted",
|
| 36 |
+
rule: "Weights updated using the dataset's frozen Train split only; Valid for tuning. No other in-domain data of any kind.",
|
| 37 |
+
status: "live",
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: "D",
|
| 41 |
+
name: "Open",
|
| 42 |
+
rule: "Any external data or method, fully disclosed. The anything-goes track.",
|
| 43 |
+
status: "roadmap",
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
id: "E",
|
| 47 |
+
name: "Efficiency",
|
| 48 |
+
rule: "Deployment-realistic constraints: under 1B parameters, CPU decode, under 8GB VRAM.",
|
| 49 |
+
status: "config-ready",
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
id: "F",
|
| 53 |
+
name: "Conversational",
|
| 54 |
+
rule: "WER + DER on multi-speaker dialogue. Requires conversational corpora (VietMed, clinic data) — the track closest to production.",
|
| 55 |
+
status: "roadmap",
|
| 56 |
+
},
|
| 57 |
+
];
|
| 58 |
+
|
| 59 |
+
/** Tracks A–F: what each track allows, with launch-status chips. */
|
| 60 |
+
export default function TracksTable() {
|
| 61 |
+
return (
|
| 62 |
+
<div className="hairline spotlight-row depth-1 overflow-x-auto rounded-sm bg-surface">
|
| 63 |
+
<table className="w-full min-w-[640px] border-collapse text-sm">
|
| 64 |
+
<caption className="sr-only">
|
| 65 |
+
Evaluation tracks A through F with their rules and launch status
|
| 66 |
+
</caption>
|
| 67 |
+
<thead>
|
| 68 |
+
<tr className="hairline-b text-left">
|
| 69 |
+
<th
|
| 70 |
+
scope="col"
|
| 71 |
+
className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
|
| 72 |
+
>
|
| 73 |
+
Track
|
| 74 |
+
</th>
|
| 75 |
+
<th
|
| 76 |
+
scope="col"
|
| 77 |
+
className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
|
| 78 |
+
>
|
| 79 |
+
Allowed signal
|
| 80 |
+
</th>
|
| 81 |
+
<th
|
| 82 |
+
scope="col"
|
| 83 |
+
className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
|
| 84 |
+
>
|
| 85 |
+
Status
|
| 86 |
+
</th>
|
| 87 |
+
</tr>
|
| 88 |
+
</thead>
|
| 89 |
+
<tbody>
|
| 90 |
+
{TRACKS.map((t, i) => {
|
| 91 |
+
const dim = t.status === "roadmap";
|
| 92 |
+
return (
|
| 93 |
+
<tr
|
| 94 |
+
key={t.id}
|
| 95 |
+
className={i < TRACKS.length - 1 ? "hairline-b align-top" : "align-top"}
|
| 96 |
+
>
|
| 97 |
+
<th
|
| 98 |
+
scope="row"
|
| 99 |
+
className="whitespace-nowrap px-4 py-3.5 text-left"
|
| 100 |
+
>
|
| 101 |
+
<span className="inline-flex items-baseline gap-2.5">
|
| 102 |
+
<span
|
| 103 |
+
className={clsx(
|
| 104 |
+
"num text-base",
|
| 105 |
+
dim ? "text-muted/70" : "text-text",
|
| 106 |
+
)}
|
| 107 |
+
>
|
| 108 |
+
{t.id}
|
| 109 |
+
</span>
|
| 110 |
+
<span
|
| 111 |
+
className={clsx(
|
| 112 |
+
"text-sm font-medium",
|
| 113 |
+
dim ? "text-muted/70" : "text-text",
|
| 114 |
+
)}
|
| 115 |
+
>
|
| 116 |
+
{t.name}
|
| 117 |
+
</span>
|
| 118 |
+
</span>
|
| 119 |
+
</th>
|
| 120 |
+
<td
|
| 121 |
+
className={clsx(
|
| 122 |
+
"min-w-[20rem] px-4 py-3.5 text-[13px] leading-relaxed",
|
| 123 |
+
dim ? "text-muted/70" : "text-muted",
|
| 124 |
+
)}
|
| 125 |
+
>
|
| 126 |
+
{t.rule}
|
| 127 |
+
</td>
|
| 128 |
+
<td className="whitespace-nowrap px-4 py-3.5">
|
| 129 |
+
<Badge variant={STATUS[t.status].variant}>
|
| 130 |
+
{STATUS[t.status].label}
|
| 131 |
+
</Badge>
|
| 132 |
+
</td>
|
| 133 |
+
</tr>
|
| 134 |
+
);
|
| 135 |
+
})}
|
| 136 |
+
</tbody>
|
| 137 |
+
</table>
|
| 138 |
+
</div>
|
| 139 |
+
);
|
| 140 |
+
}
|
src/components/leaderboard/HeroStats.tsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
| 5 |
+
import { ArrowUpRight } from "lucide-react";
|
| 6 |
+
|
| 7 |
+
/* layout effect on the client, plain effect during SSR (where it never
|
| 8 |
+
runs) — silences React's useLayoutEffect-on-server warning */
|
| 9 |
+
const useIsoLayoutEffect =
|
| 10 |
+
typeof window === "undefined" ? useEffect : useLayoutEffect;
|
| 11 |
+
|
| 12 |
+
export interface HeroStatsProps {
|
| 13 |
+
/** total audio hours, e.g. 34.57 */
|
| 14 |
+
hours: number;
|
| 15 |
+
/** utterance count, e.g. 16576 */
|
| 16 |
+
utterances: number;
|
| 17 |
+
/** distinct code-switched terms, e.g. 889 */
|
| 18 |
+
csTerms: number;
|
| 19 |
+
/** dataset the snapshot belongs to, e.g. "ViMedCSS" */
|
| 20 |
+
datasetName: string;
|
| 21 |
+
/** dataset card route, e.g. "/datasets/vimedcss" */
|
| 22 |
+
datasetHref: string;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/** Spring-based easing for smooth, physical feel */
|
| 26 |
+
function springEase(t: number): number {
|
| 27 |
+
return 1 - Math.pow(1 - t, 3);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** Shared 0→1 spring ease progress over ~700ms (instant under reduced motion).
|
| 31 |
+
Starts at 1 so server markup (and no-JS readers) carry the real values —
|
| 32 |
+
the pre-paint layout effect rewinds to 0 before the count-up plays. */
|
| 33 |
+
function useSpringCount(duration = 700): number {
|
| 34 |
+
const [progress, setProgress] = useState(1);
|
| 35 |
+
const raf = useRef<number>(0);
|
| 36 |
+
|
| 37 |
+
useIsoLayoutEffect(() => {
|
| 38 |
+
const reduced = window.matchMedia(
|
| 39 |
+
"(prefers-reduced-motion: reduce)",
|
| 40 |
+
).matches;
|
| 41 |
+
if (reduced) return;
|
| 42 |
+
/* rewind before first paint, then spring up to the real values */
|
| 43 |
+
setProgress(0);
|
| 44 |
+
let t0: number | undefined;
|
| 45 |
+
const tick = (t: number) => {
|
| 46 |
+
t0 ??= t;
|
| 47 |
+
const p = Math.min((t - t0) / duration, 1);
|
| 48 |
+
setProgress(springEase(p));
|
| 49 |
+
if (p < 1) raf.current = requestAnimationFrame(tick);
|
| 50 |
+
};
|
| 51 |
+
raf.current = requestAnimationFrame(tick);
|
| 52 |
+
return () => cancelAnimationFrame(raf.current);
|
| 53 |
+
}, [duration]);
|
| 54 |
+
|
| 55 |
+
return progress;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Anchor-dataset snapshot band: a bento panel with a scope strip naming the
|
| 60 |
+
* dataset the numbers belong to (the hub hosts several datasets, so these
|
| 61 |
+
* stats must never read as platform totals), then four stat tiles whose
|
| 62 |
+
* numerals spring-count in once on mount.
|
| 63 |
+
*/
|
| 64 |
+
export default function HeroStats({
|
| 65 |
+
hours,
|
| 66 |
+
utterances,
|
| 67 |
+
csTerms,
|
| 68 |
+
datasetName,
|
| 69 |
+
datasetHref,
|
| 70 |
+
}: HeroStatsProps) {
|
| 71 |
+
const p = useSpringCount();
|
| 72 |
+
|
| 73 |
+
const cells: { value: string; unit?: string; label: string }[] = [
|
| 74 |
+
{ value: (hours * p).toFixed(2), unit: "h", label: "audio" },
|
| 75 |
+
{
|
| 76 |
+
value: Math.round(utterances * p).toLocaleString("en-US"),
|
| 77 |
+
label: "utterances",
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
value: Math.round(csTerms * p).toLocaleString("en-US"),
|
| 81 |
+
label: "CS medical terms",
|
| 82 |
+
},
|
| 83 |
+
{ value: "2–21", unit: "s", label: "clip length" },
|
| 84 |
+
];
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<section
|
| 88 |
+
aria-label={`${datasetName} anchor-dataset snapshot`}
|
| 89 |
+
className="hairline depth-2 overflow-hidden rounded-sm"
|
| 90 |
+
>
|
| 91 |
+
{/* scope strip — these numbers describe the anchor dataset, not the hub */}
|
| 92 |
+
<header className="hairline-b flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 bg-surface-2/60 px-4 py-2.5 backdrop-blur-sm">
|
| 93 |
+
<p className="num text-[11px] tracking-[0.2em] text-muted uppercase">
|
| 94 |
+
Anchor dataset snapshot —{" "}
|
| 95 |
+
<span className="text-text">{datasetName} v1</span>
|
| 96 |
+
</p>
|
| 97 |
+
<Link
|
| 98 |
+
href={datasetHref}
|
| 99 |
+
className="num inline-flex items-center gap-1 rounded-sm text-[11px] tracking-[0.14em] text-muted uppercase transition-colors duration-150 ease-out hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
|
| 100 |
+
>
|
| 101 |
+
Dataset card
|
| 102 |
+
<ArrowUpRight size={12} aria-hidden />
|
| 103 |
+
</Link>
|
| 104 |
+
</header>
|
| 105 |
+
|
| 106 |
+
<dl className="grid grid-cols-2 gap-px bg-line md:grid-cols-4">
|
| 107 |
+
{cells.map((c, i) => (
|
| 108 |
+
<div
|
| 109 |
+
key={c.label}
|
| 110 |
+
className="flex flex-col-reverse gap-1.5 bg-surface px-4 py-4 spring-in"
|
| 111 |
+
style={{ animationDelay: `${120 + i * 60}ms` }}
|
| 112 |
+
>
|
| 113 |
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-muted">
|
| 114 |
+
{c.label}
|
| 115 |
+
</dt>
|
| 116 |
+
<dd className="num text-3xl leading-none text-text">
|
| 117 |
+
{c.value}
|
| 118 |
+
{c.unit && (
|
| 119 |
+
<span className="ml-1 text-lg text-muted">{c.unit}</span>
|
| 120 |
+
)}
|
| 121 |
+
</dd>
|
| 122 |
+
</div>
|
| 123 |
+
))}
|
| 124 |
+
</dl>
|
| 125 |
+
</section>
|
| 126 |
+
);
|
| 127 |
+
}
|
src/components/leaderboard/LeaderboardTable.tsx
ADDED
|
@@ -0,0 +1,1013 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import clsx from "clsx";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 6 |
+
import { ArrowUp, ChevronsUpDown, Search, ShieldCheck } from "lucide-react";
|
| 7 |
+
import { MotionConfig, motion } from "framer-motion";
|
| 8 |
+
import Badge from "@/components/ui/Badge";
|
| 9 |
+
import CIBar from "@/components/ui/CIBar";
|
| 10 |
+
import FlagBadges from "@/components/ui/FlagBadges";
|
| 11 |
+
import ProvenanceChip from "@/components/ui/ProvenanceChip";
|
| 12 |
+
import SplitToggle from "@/components/ui/SplitToggle";
|
| 13 |
+
import {
|
| 14 |
+
HIGHER_IS_BETTER,
|
| 15 |
+
categoryLabels,
|
| 16 |
+
fmt,
|
| 17 |
+
metricLabels,
|
| 18 |
+
splitLabels,
|
| 19 |
+
trackLabels,
|
| 20 |
+
} from "@/lib/data";
|
| 21 |
+
import type {
|
| 22 |
+
Category,
|
| 23 |
+
MetricKey,
|
| 24 |
+
ModelRow,
|
| 25 |
+
Split,
|
| 26 |
+
SplitInfo,
|
| 27 |
+
Track,
|
| 28 |
+
} from "@/lib/types";
|
| 29 |
+
|
| 30 |
+
export interface LeaderboardTableProps {
|
| 31 |
+
models: ModelRow[];
|
| 32 |
+
/** Dataset the board is scoped to, e.g. "ViMedCSS" — shown in the title. */
|
| 33 |
+
datasetName: string;
|
| 34 |
+
/** Hard-split profile, for the explanatory note when Hard is selected. */
|
| 35 |
+
hardSplit: SplitInfo;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* ------------------------------------------------------------------ */
|
| 39 |
+
/* Column + filter configuration */
|
| 40 |
+
/* ------------------------------------------------------------------ */
|
| 41 |
+
|
| 42 |
+
interface MetricCol {
|
| 43 |
+
key: MetricKey;
|
| 44 |
+
label: string;
|
| 45 |
+
title: string;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const METRIC_COLS: MetricCol[] = [
|
| 49 |
+
{ key: "wer", label: "WER", title: "Word error rate (%) — lower is better" },
|
| 50 |
+
{
|
| 51 |
+
key: "csWer",
|
| 52 |
+
label: "CS-WER",
|
| 53 |
+
title: "Errors inside code-switched English spans (%) — lower is better",
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
key: "nWer",
|
| 57 |
+
label: "N-WER",
|
| 58 |
+
title: "WER on Vietnamese-only tokens (%) — lower is better",
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
key: "cer",
|
| 62 |
+
label: "CER",
|
| 63 |
+
title: "Character error rate (%) — lower is better",
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
key: "mtrExact",
|
| 67 |
+
label: "MTR",
|
| 68 |
+
title:
|
| 69 |
+
"Medical term recall, exact over fuzzy tier (%) — higher is better; sorts by exact",
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
key: "rtfx",
|
| 73 |
+
label: "RTFx",
|
| 74 |
+
title: "Audio seconds ÷ wall seconds — higher is faster",
|
| 75 |
+
},
|
| 76 |
+
];
|
| 77 |
+
|
| 78 |
+
const TRACKS: ("all" | Track)[] = ["all", "A", "B", "C"];
|
| 79 |
+
|
| 80 |
+
const CATEGORY_SHORT: Record<Category, string> = {
|
| 81 |
+
"vn-open": "VN open",
|
| 82 |
+
"multilingual-open": "Multilingual",
|
| 83 |
+
medical: "Medical",
|
| 84 |
+
"api-intl": "API · intl",
|
| 85 |
+
"api-vn": "API · VN",
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const CATEGORIES: ("all" | Category)[] = [
|
| 89 |
+
"all",
|
| 90 |
+
"vn-open",
|
| 91 |
+
"multilingual-open",
|
| 92 |
+
"medical",
|
| 93 |
+
"api-intl",
|
| 94 |
+
"api-vn",
|
| 95 |
+
];
|
| 96 |
+
|
| 97 |
+
/* ------------------------------------------------------------------ */
|
| 98 |
+
/* Small local pieces */
|
| 99 |
+
/* ------------------------------------------------------------------ */
|
| 100 |
+
|
| 101 |
+
function FilterChip({
|
| 102 |
+
active,
|
| 103 |
+
onClick,
|
| 104 |
+
title,
|
| 105 |
+
children,
|
| 106 |
+
}: {
|
| 107 |
+
active: boolean;
|
| 108 |
+
onClick: () => void;
|
| 109 |
+
title?: string;
|
| 110 |
+
children: React.ReactNode;
|
| 111 |
+
}) {
|
| 112 |
+
return (
|
| 113 |
+
<button
|
| 114 |
+
type="button"
|
| 115 |
+
aria-pressed={active}
|
| 116 |
+
onClick={onClick}
|
| 117 |
+
title={title}
|
| 118 |
+
className={clsx(
|
| 119 |
+
"num rounded-sm border px-2 py-1 text-[11px] leading-4 tracking-wide whitespace-nowrap",
|
| 120 |
+
"transition-[color,background-color,border-color,transform] duration-150 ease-out active:scale-[0.96]",
|
| 121 |
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
|
| 122 |
+
active
|
| 123 |
+
? "border-muted/40 bg-surface-2 text-text"
|
| 124 |
+
: "border-line bg-transparent text-muted hover:bg-surface-2/60 hover:text-text",
|
| 125 |
+
)}
|
| 126 |
+
>
|
| 127 |
+
{children}
|
| 128 |
+
</button>
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/** 244M / 1.55B from the paramsB field. */
|
| 133 |
+
function fmtParams(b?: number): string {
|
| 134 |
+
if (b === undefined) return "";
|
| 135 |
+
return b < 1 ? `${Math.round(b * 1000)}M` : `${b}B`;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* ------------------------------------------------------------------ */
|
| 139 |
+
/* Table */
|
| 140 |
+
/* ------------------------------------------------------------------ */
|
| 141 |
+
|
| 142 |
+
export default function LeaderboardTable({
|
| 143 |
+
models,
|
| 144 |
+
datasetName,
|
| 145 |
+
hardSplit,
|
| 146 |
+
}: LeaderboardTableProps) {
|
| 147 |
+
const [split, setSplit] = useState<Split>("test");
|
| 148 |
+
const [track, setTrack] = useState<"all" | Track>("all");
|
| 149 |
+
const [category, setCategory] = useState<"all" | Category>("all");
|
| 150 |
+
const [zone1Only, setZone1Only] = useState(false);
|
| 151 |
+
const [query, setQuery] = useState("");
|
| 152 |
+
const [sortKey, setSortKey] = useState<MetricKey>("wer");
|
| 153 |
+
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
| 154 |
+
const [showEmptyCols, setShowEmptyCols] = useState(false);
|
| 155 |
+
|
| 156 |
+
/* horizontal-scroll affordance: a right-edge fade that lifts once the
|
| 157 |
+
table is scrolled to its end (or fits without scrolling) */
|
| 158 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 159 |
+
const [moreRight, setMoreRight] = useState(false);
|
| 160 |
+
|
| 161 |
+
useEffect(() => {
|
| 162 |
+
const el = scrollRef.current;
|
| 163 |
+
if (!el) return;
|
| 164 |
+
const update = () =>
|
| 165 |
+
setMoreRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 8);
|
| 166 |
+
update();
|
| 167 |
+
el.addEventListener("scroll", update, { passive: true });
|
| 168 |
+
const ro = new ResizeObserver(update);
|
| 169 |
+
ro.observe(el);
|
| 170 |
+
return () => {
|
| 171 |
+
el.removeEventListener("scroll", update);
|
| 172 |
+
ro.disconnect();
|
| 173 |
+
};
|
| 174 |
+
}, []);
|
| 175 |
+
|
| 176 |
+
const bestFirstDir = HIGHER_IS_BETTER.has(sortKey) ? "desc" : "asc";
|
| 177 |
+
|
| 178 |
+
function onSort(key: MetricKey) {
|
| 179 |
+
if (key === sortKey) {
|
| 180 |
+
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
| 181 |
+
} else {
|
| 182 |
+
setSortKey(key);
|
| 183 |
+
setSortDir(HIGHER_IS_BETTER.has(key) ? "desc" : "asc");
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
const { scored, unscored } = useMemo(() => {
|
| 188 |
+
const q = query.trim().toLowerCase();
|
| 189 |
+
const filtered = models.filter(
|
| 190 |
+
(m) =>
|
| 191 |
+
(track === "all" || m.track === track) &&
|
| 192 |
+
(category === "all" || m.category === category) &&
|
| 193 |
+
(!zone1Only || m.flags?.zone1 === true) &&
|
| 194 |
+
(!q || `${m.name} ${m.org} ${m.slug}`.toLowerCase().includes(q)),
|
| 195 |
+
);
|
| 196 |
+
const hasMetric = (m: ModelRow) =>
|
| 197 |
+
typeof m.metrics?.[split]?.[sortKey] === "number";
|
| 198 |
+
const dir = sortDir === "asc" ? 1 : -1;
|
| 199 |
+
const scored = filtered
|
| 200 |
+
.filter(hasMetric)
|
| 201 |
+
.sort(
|
| 202 |
+
(a, b) =>
|
| 203 |
+
dir *
|
| 204 |
+
((a.metrics![split]![sortKey] as number) -
|
| 205 |
+
(b.metrics![split]![sortKey] as number)),
|
| 206 |
+
);
|
| 207 |
+
/* unscored order: measured-elsewhere rows, then running, then planned */
|
| 208 |
+
const statusRank = (m: ModelRow) =>
|
| 209 |
+
m.provenance === "planned" ? 2 : m.provenance === "running" ? 1 : 0;
|
| 210 |
+
const unscored = filtered
|
| 211 |
+
.filter((m) => !hasMetric(m))
|
| 212 |
+
.sort((a, b) => statusRank(a) - statusRank(b));
|
| 213 |
+
return { scored, unscored };
|
| 214 |
+
}, [models, split, track, category, zone1Only, query, sortKey, sortDir]);
|
| 215 |
+
|
| 216 |
+
/** Best-first rank regardless of the current sort direction. */
|
| 217 |
+
const rankOf = (i: number) =>
|
| 218 |
+
sortDir === bestFirstDir ? i + 1 : scored.length - i;
|
| 219 |
+
|
| 220 |
+
/* metric columns with no measured value in the current view: header and
|
| 221 |
+
dashes drop to whisper weight so absence never outshouts presence */
|
| 222 |
+
const emptyCols = useMemo(() => {
|
| 223 |
+
const rows = [...scored, ...unscored];
|
| 224 |
+
return new Set(
|
| 225 |
+
METRIC_COLS.filter(
|
| 226 |
+
(c) =>
|
| 227 |
+
!rows.some((m) => typeof m.metrics?.[split]?.[c.key] === "number"),
|
| 228 |
+
).map((c) => c.key),
|
| 229 |
+
);
|
| 230 |
+
}, [scored, unscored, split]);
|
| 231 |
+
|
| 232 |
+
/* fully-empty secondary columns collapse into one ghost column by
|
| 233 |
+
default (WER / CS-WER, the headline pair, never collapse); the moment
|
| 234 |
+
any row carries a value, the column restores automatically */
|
| 235 |
+
const collapsible = METRIC_COLS.filter(
|
| 236 |
+
(c) => c.key !== "wer" && c.key !== "csWer" && emptyCols.has(c.key),
|
| 237 |
+
);
|
| 238 |
+
const collapsed = !showEmptyCols && collapsible.length > 0;
|
| 239 |
+
const visibleCols = collapsed
|
| 240 |
+
? METRIC_COLS.filter((c) => !collapsible.some((e) => e.key === c.key))
|
| 241 |
+
: METRIC_COLS;
|
| 242 |
+
const visibleKeys = new Set(visibleCols.map((c) => c.key));
|
| 243 |
+
const collapsedLabels = collapsible.map((c) => c.label).join(", ");
|
| 244 |
+
/* 6 fixed columns (#, model, params, track, license, provenance) +
|
| 245 |
+
metric columns + the ghost/toggle column when anything is collapsible */
|
| 246 |
+
const colCount = 6 + visibleCols.length + (collapsible.length > 0 ? 1 : 0);
|
| 247 |
+
/* undefined = no ghost column · "" = column present but expanded */
|
| 248 |
+
const ghostCell =
|
| 249 |
+
collapsible.length > 0 ? (collapsed ? collapsedLabels : "") : undefined;
|
| 250 |
+
|
| 251 |
+
return (
|
| 252 |
+
/* reducedMotion="user" makes framer-motion honor prefers-reduced-motion
|
| 253 |
+
(transforms freeze, opacity still resolves) — the CSS-side animations
|
| 254 |
+
are already covered by the global override in globals.css */
|
| 255 |
+
<MotionConfig reducedMotion="user">
|
| 256 |
+
<section
|
| 257 |
+
aria-labelledby="leaderboard-heading"
|
| 258 |
+
className="flex flex-col gap-3"
|
| 259 |
+
>
|
| 260 |
+
{/* ----- title — the board is scoped to one dataset + split ----- */}
|
| 261 |
+
<h2
|
| 262 |
+
id="leaderboard-heading"
|
| 263 |
+
className="font-display mb-1 text-[1.7rem] leading-tight tracking-tight text-text md:text-3xl"
|
| 264 |
+
>
|
| 265 |
+
Leaderboard — {datasetName}{" "}
|
| 266 |
+
<span className="text-muted italic">
|
| 267 |
+
{splitLabels[split].toLowerCase()} split
|
| 268 |
+
</span>
|
| 269 |
+
</h2>
|
| 270 |
+
|
| 271 |
+
{/* ----- controls ----- */}
|
| 272 |
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
| 273 |
+
<SplitToggle value={split} onChange={setSplit} />
|
| 274 |
+
|
| 275 |
+
<div
|
| 276 |
+
role="group"
|
| 277 |
+
aria-label="Track filter"
|
| 278 |
+
className="flex items-center gap-1.5"
|
| 279 |
+
>
|
| 280 |
+
<span className="num text-[11px] uppercase tracking-[0.18em] text-muted">
|
| 281 |
+
Track
|
| 282 |
+
</span>
|
| 283 |
+
{TRACKS.map((t) => (
|
| 284 |
+
<FilterChip
|
| 285 |
+
key={t}
|
| 286 |
+
active={track === t}
|
| 287 |
+
onClick={() => setTrack(t)}
|
| 288 |
+
title={t === "all" ? "All tracks" : trackLabels[t]}
|
| 289 |
+
>
|
| 290 |
+
{t === "all" ? "All" : t}
|
| 291 |
+
</FilterChip>
|
| 292 |
+
))}
|
| 293 |
+
</div>
|
| 294 |
+
|
| 295 |
+
<div
|
| 296 |
+
role="group"
|
| 297 |
+
aria-label="Category filter"
|
| 298 |
+
className="flex flex-wrap items-center gap-1.5"
|
| 299 |
+
>
|
| 300 |
+
<span className="num text-[11px] uppercase tracking-[0.18em] text-muted">
|
| 301 |
+
Class
|
| 302 |
+
</span>
|
| 303 |
+
{CATEGORIES.map((c) => (
|
| 304 |
+
<FilterChip
|
| 305 |
+
key={c}
|
| 306 |
+
active={category === c}
|
| 307 |
+
onClick={() => setCategory(c)}
|
| 308 |
+
title={c === "all" ? "All model classes" : categoryLabels[c]}
|
| 309 |
+
>
|
| 310 |
+
{c === "all" ? "All" : CATEGORY_SHORT[c]}
|
| 311 |
+
</FilterChip>
|
| 312 |
+
))}
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
<button
|
| 316 |
+
type="button"
|
| 317 |
+
aria-pressed={zone1Only}
|
| 318 |
+
onClick={() => setZone1Only((v) => !v)}
|
| 319 |
+
title="Zone 1: self-hostable on one 24GB GPU, license-clean"
|
| 320 |
+
className={clsx(
|
| 321 |
+
"num inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[11px] leading-4 tracking-wide",
|
| 322 |
+
"transition-[color,background-color,border-color,transform] duration-150 ease-out active:scale-[0.96]",
|
| 323 |
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
|
| 324 |
+
zone1Only
|
| 325 |
+
? "border-accent/30 bg-accent-dim text-accent"
|
| 326 |
+
: "border-line text-muted hover:bg-surface-2/60 hover:text-text",
|
| 327 |
+
)}
|
| 328 |
+
>
|
| 329 |
+
<ShieldCheck size={13} aria-hidden />
|
| 330 |
+
Zone-1 deployable
|
| 331 |
+
</button>
|
| 332 |
+
|
| 333 |
+
<label className="relative ml-auto">
|
| 334 |
+
<span className="sr-only">Search models</span>
|
| 335 |
+
<Search
|
| 336 |
+
size={14}
|
| 337 |
+
aria-hidden
|
| 338 |
+
className="absolute top-1/2 left-2.5 -translate-y-1/2 text-muted"
|
| 339 |
+
/>
|
| 340 |
+
<input
|
| 341 |
+
type="search"
|
| 342 |
+
value={query}
|
| 343 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 344 |
+
placeholder="Search model or org…"
|
| 345 |
+
className={clsx(
|
| 346 |
+
"hairline num w-52 rounded-sm bg-surface py-1.5 pr-2 pl-8 text-[12px] text-text",
|
| 347 |
+
"placeholder:text-muted/70",
|
| 348 |
+
"focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-accent/60",
|
| 349 |
+
)}
|
| 350 |
+
/>
|
| 351 |
+
</label>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
{/* ----- hard-split note ----- */}
|
| 355 |
+
{split === "hard" && (
|
| 356 |
+
<p className="hairline rounded-sm bg-surface-2/60 px-3 py-2 text-[12px] leading-relaxed text-muted">
|
| 357 |
+
<span className="num text-text">Hard split</span> —{" "}
|
| 358 |
+
<span className="num">{fmt(hardSplit.hours)} h</span> /{" "}
|
| 359 |
+
<span className="num">
|
| 360 |
+
{hardSplit.utts.toLocaleString("en-US")}
|
| 361 |
+
</span>{" "}
|
| 362 |
+
utterances of rare or unseen code-switched terms: the generalization
|
| 363 |
+
leaderboard. Paper-imported rows have not published Hard-split
|
| 364 |
+
numbers, so fewer rows are scored here.
|
| 365 |
+
</p>
|
| 366 |
+
)}
|
| 367 |
+
|
| 368 |
+
{/* ----- mobile stacked rows (below md): rank + name, then WER ----- */}
|
| 369 |
+
<div className="hairline overflow-hidden rounded-sm bg-surface md:hidden">
|
| 370 |
+
<ol className="divide-y divide-line">
|
| 371 |
+
{scored.map((m, i) => (
|
| 372 |
+
<MobileRow
|
| 373 |
+
key={m.slug}
|
| 374 |
+
model={m}
|
| 375 |
+
split={split}
|
| 376 |
+
rank={rankOf(i)}
|
| 377 |
+
/>
|
| 378 |
+
))}
|
| 379 |
+
</ol>
|
| 380 |
+
{unscored.length > 0 && (
|
| 381 |
+
<>
|
| 382 |
+
<p className="num hairline-t bg-surface-2/40 px-3 py-2 text-[10.5px] tracking-[0.2em] text-muted uppercase">
|
| 383 |
+
Unscored on {splitLabels[split]} — awaiting runs or imports
|
| 384 |
+
</p>
|
| 385 |
+
<ul className="hairline-t divide-y divide-line">
|
| 386 |
+
{unscored.map((m) => (
|
| 387 |
+
<MobileRow key={m.slug} model={m} split={split} />
|
| 388 |
+
))}
|
| 389 |
+
</ul>
|
| 390 |
+
</>
|
| 391 |
+
)}
|
| 392 |
+
{scored.length === 0 && unscored.length === 0 && (
|
| 393 |
+
<p className="px-3 py-10 text-center text-sm text-muted">
|
| 394 |
+
No models match the current filters.
|
| 395 |
+
</p>
|
| 396 |
+
)}
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
{/* ----- full table (md and up) -----
|
| 400 |
+
breakout-wide lets the board outgrow the editorial column on xl+
|
| 401 |
+
screens — every column fits without horizontal scroll */}
|
| 402 |
+
<div className="breakout-wide">
|
| 403 |
+
<div className="relative">
|
| 404 |
+
<motion.div
|
| 405 |
+
ref={scrollRef}
|
| 406 |
+
className="hairline hidden overflow-x-auto rounded-sm bg-surface md:block"
|
| 407 |
+
initial={{ opacity: 0, y: 16 }}
|
| 408 |
+
animate={{ opacity: 1, y: 0 }}
|
| 409 |
+
transition={{
|
| 410 |
+
duration: 0.5,
|
| 411 |
+
delay: 0.2,
|
| 412 |
+
ease: [0.22, 1, 0.36, 1],
|
| 413 |
+
}}
|
| 414 |
+
>
|
| 415 |
+
<table className="w-full min-w-[1024px] border-collapse text-left">
|
| 416 |
+
<caption className="sr-only">
|
| 417 |
+
Model leaderboard on the {datasetName} {splitLabels[split]}{" "}
|
| 418 |
+
split, sorted by {metricLabels[sortKey]}
|
| 419 |
+
</caption>
|
| 420 |
+
<thead>
|
| 421 |
+
<tr className="hairline-b bg-surface-2/50">
|
| 422 |
+
<th
|
| 423 |
+
scope="col"
|
| 424 |
+
className="num py-2.5 pr-1 pl-3 text-right text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
|
| 425 |
+
>
|
| 426 |
+
#
|
| 427 |
+
</th>
|
| 428 |
+
<th
|
| 429 |
+
scope="col"
|
| 430 |
+
className="num px-3 py-2.5 text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
|
| 431 |
+
>
|
| 432 |
+
Model
|
| 433 |
+
</th>
|
| 434 |
+
<th
|
| 435 |
+
scope="col"
|
| 436 |
+
title="Parameter count"
|
| 437 |
+
className="num px-2 py-2.5 text-right text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
|
| 438 |
+
>
|
| 439 |
+
Params
|
| 440 |
+
</th>
|
| 441 |
+
<th
|
| 442 |
+
scope="col"
|
| 443 |
+
className="num px-2 py-2.5 text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
|
| 444 |
+
>
|
| 445 |
+
Track
|
| 446 |
+
</th>
|
| 447 |
+
<th
|
| 448 |
+
scope="col"
|
| 449 |
+
className="num px-2 py-2.5 text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
|
| 450 |
+
>
|
| 451 |
+
License
|
| 452 |
+
</th>
|
| 453 |
+
{visibleCols.map((col) => {
|
| 454 |
+
const active = sortKey === col.key;
|
| 455 |
+
const empty = emptyCols.has(col.key);
|
| 456 |
+
return (
|
| 457 |
+
<th
|
| 458 |
+
key={col.key}
|
| 459 |
+
scope="col"
|
| 460 |
+
aria-sort={
|
| 461 |
+
active
|
| 462 |
+
? sortDir === "asc"
|
| 463 |
+
? "ascending"
|
| 464 |
+
: "descending"
|
| 465 |
+
: "none"
|
| 466 |
+
}
|
| 467 |
+
className="px-2 py-1.5 text-right"
|
| 468 |
+
>
|
| 469 |
+
<button
|
| 470 |
+
type="button"
|
| 471 |
+
onClick={() => onSort(col.key)}
|
| 472 |
+
title={col.title}
|
| 473 |
+
className={clsx(
|
| 474 |
+
"group num inline-flex items-center gap-1 rounded-sm px-1.5 py-1 text-[11px] font-normal tracking-[0.14em] uppercase",
|
| 475 |
+
"transition-[color,background-color,transform] duration-150 ease-out",
|
| 476 |
+
"hover:bg-surface-2/70 active:scale-[0.95]",
|
| 477 |
+
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent/70",
|
| 478 |
+
active
|
| 479 |
+
? "text-text"
|
| 480 |
+
: empty
|
| 481 |
+
? "text-muted/50 hover:text-muted"
|
| 482 |
+
: "text-muted hover:text-text",
|
| 483 |
+
)}
|
| 484 |
+
>
|
| 485 |
+
{col.label}
|
| 486 |
+
{active ? (
|
| 487 |
+
/* one arrow that flips — direction toggles read as a
|
| 488 |
+
rotation instead of an icon swap */
|
| 489 |
+
<ArrowUp
|
| 490 |
+
size={11}
|
| 491 |
+
aria-hidden
|
| 492 |
+
className={clsx(
|
| 493 |
+
"text-accent transition-transform duration-200 ease-out",
|
| 494 |
+
sortDir === "desc" && "rotate-180",
|
| 495 |
+
)}
|
| 496 |
+
/>
|
| 497 |
+
) : (
|
| 498 |
+
<ChevronsUpDown
|
| 499 |
+
size={11}
|
| 500 |
+
aria-hidden
|
| 501 |
+
className="opacity-50 transition-opacity duration-150 ease-out group-hover:opacity-90"
|
| 502 |
+
/>
|
| 503 |
+
)}
|
| 504 |
+
</button>
|
| 505 |
+
</th>
|
| 506 |
+
);
|
| 507 |
+
})}
|
| 508 |
+
{collapsible.length > 0 && (
|
| 509 |
+
<th scope="col" className="px-2 py-1.5 text-right">
|
| 510 |
+
<button
|
| 511 |
+
type="button"
|
| 512 |
+
aria-pressed={showEmptyCols}
|
| 513 |
+
onClick={() => setShowEmptyCols((v) => !v)}
|
| 514 |
+
title={
|
| 515 |
+
showEmptyCols
|
| 516 |
+
? "Collapse the unmeasured columns"
|
| 517 |
+
: `Awaiting platform runs: ${collapsedLabels}`
|
| 518 |
+
}
|
| 519 |
+
className={clsx(
|
| 520 |
+
"num inline-flex items-center gap-1 rounded-sm border border-line/60 px-1.5 py-1",
|
| 521 |
+
"text-[10.5px] font-normal tracking-[0.14em] whitespace-nowrap uppercase",
|
| 522 |
+
"text-muted/60 transition-[color,background-color,transform] duration-150 ease-out",
|
| 523 |
+
"hover:bg-surface-2/70 hover:text-text active:scale-[0.96]",
|
| 524 |
+
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent/70",
|
| 525 |
+
)}
|
| 526 |
+
>
|
| 527 |
+
{showEmptyCols
|
| 528 |
+
? "− collapse"
|
| 529 |
+
: `+${collapsible.length} awaiting runs`}
|
| 530 |
+
</button>
|
| 531 |
+
</th>
|
| 532 |
+
)}
|
| 533 |
+
<th
|
| 534 |
+
scope="col"
|
| 535 |
+
className="num py-2.5 pr-3 pl-2 text-right text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
|
| 536 |
+
>
|
| 537 |
+
Provenance
|
| 538 |
+
</th>
|
| 539 |
+
</tr>
|
| 540 |
+
</thead>
|
| 541 |
+
<tbody>
|
| 542 |
+
{scored.map((m, i) => (
|
| 543 |
+
<Row
|
| 544 |
+
key={m.slug}
|
| 545 |
+
model={m}
|
| 546 |
+
split={split}
|
| 547 |
+
rank={rankOf(i)}
|
| 548 |
+
emptyCols={emptyCols}
|
| 549 |
+
visibleKeys={visibleKeys}
|
| 550 |
+
ghostCell={ghostCell}
|
| 551 |
+
index={i}
|
| 552 |
+
/>
|
| 553 |
+
))}
|
| 554 |
+
|
| 555 |
+
{unscored.length > 0 && (
|
| 556 |
+
<tr className="hairline-t">
|
| 557 |
+
<td
|
| 558 |
+
colSpan={colCount}
|
| 559 |
+
className="num bg-surface-2/40 px-3 pt-2.5 pb-2 text-[10.5px] tracking-[0.2em] text-muted uppercase"
|
| 560 |
+
>
|
| 561 |
+
Unscored on {splitLabels[split]} ·{" "}
|
| 562 |
+
{metricLabels[sortKey]} — awaiting runs or imports
|
| 563 |
+
</td>
|
| 564 |
+
</tr>
|
| 565 |
+
)}
|
| 566 |
+
{unscored.map((m, i) => (
|
| 567 |
+
<Row
|
| 568 |
+
key={m.slug}
|
| 569 |
+
model={m}
|
| 570 |
+
split={split}
|
| 571 |
+
rank={undefined}
|
| 572 |
+
emptyCols={emptyCols}
|
| 573 |
+
visibleKeys={visibleKeys}
|
| 574 |
+
ghostCell={ghostCell}
|
| 575 |
+
index={scored.length + i}
|
| 576 |
+
/>
|
| 577 |
+
))}
|
| 578 |
+
|
| 579 |
+
{scored.length === 0 && unscored.length === 0 && (
|
| 580 |
+
<tr>
|
| 581 |
+
<td
|
| 582 |
+
colSpan={colCount}
|
| 583 |
+
className="px-3 py-10 text-center text-sm text-muted"
|
| 584 |
+
>
|
| 585 |
+
No models match the current filters.
|
| 586 |
+
</td>
|
| 587 |
+
</tr>
|
| 588 |
+
)}
|
| 589 |
+
</tbody>
|
| 590 |
+
</table>
|
| 591 |
+
</motion.div>
|
| 592 |
+
|
| 593 |
+
{/* right-edge fade: columns continue past the fold — lifts at scroll end */}
|
| 594 |
+
<div
|
| 595 |
+
aria-hidden
|
| 596 |
+
className={clsx(
|
| 597 |
+
"pointer-events-none absolute inset-y-0 right-0 hidden w-14 rounded-r-sm",
|
| 598 |
+
"bg-gradient-to-l from-surface to-transparent",
|
| 599 |
+
"transition-opacity duration-300 ease-out md:block",
|
| 600 |
+
moreRight ? "opacity-100" : "opacity-0",
|
| 601 |
+
)}
|
| 602 |
+
/>
|
| 603 |
+
</div>
|
| 604 |
+
|
| 605 |
+
<p className="num mt-3 text-right text-[11px] text-muted">
|
| 606 |
+
{scored.length + unscored.length} / {models.length} models ·{" "}
|
| 607 |
+
{splitLabels[split]} split · sorted by {metricLabels[sortKey]}
|
| 608 |
+
</p>
|
| 609 |
+
</div>
|
| 610 |
+
</section>
|
| 611 |
+
</MotionConfig>
|
| 612 |
+
);
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
/* ------------------------------------------------------------------ */
|
| 616 |
+
/* Row */
|
| 617 |
+
/* ------------------------------------------------------------------ */
|
| 618 |
+
|
| 619 |
+
function Row({
|
| 620 |
+
model: m,
|
| 621 |
+
split,
|
| 622 |
+
rank,
|
| 623 |
+
emptyCols,
|
| 624 |
+
visibleKeys,
|
| 625 |
+
ghostCell,
|
| 626 |
+
index = 0,
|
| 627 |
+
}: {
|
| 628 |
+
model: ModelRow;
|
| 629 |
+
split: Split;
|
| 630 |
+
rank?: number;
|
| 631 |
+
emptyCols: ReadonlySet<MetricKey>;
|
| 632 |
+
/** metric columns currently rendered (empty ones collapse away) */
|
| 633 |
+
visibleKeys: ReadonlySet<MetricKey>;
|
| 634 |
+
/** undefined = no ghost column · "" = present but expanded ·
|
| 635 |
+
non-empty = collapsed, labels for the tooltip */
|
| 636 |
+
ghostCell?: string;
|
| 637 |
+
index?: number;
|
| 638 |
+
}) {
|
| 639 |
+
const s = m.metrics?.[split];
|
| 640 |
+
const isTop = rank === 1;
|
| 641 |
+
const running = m.provenance === "running";
|
| 642 |
+
const dimmed = rank === undefined;
|
| 643 |
+
const show = (key: MetricKey) => visibleKeys.has(key);
|
| 644 |
+
const missing = (key: MetricKey) => (
|
| 645 |
+
<MissingCell
|
| 646 |
+
running={running}
|
| 647 |
+
provenance={m.provenance}
|
| 648 |
+
dim={emptyCols.has(key)}
|
| 649 |
+
/>
|
| 650 |
+
);
|
| 651 |
+
|
| 652 |
+
return (
|
| 653 |
+
<motion.tr
|
| 654 |
+
/* hover surface + phosphor sweep come from .spotlight-row */
|
| 655 |
+
className="spotlight-row hairline-t"
|
| 656 |
+
initial={{ opacity: 0, y: 8 }}
|
| 657 |
+
animate={{ opacity: 1, y: 0 }}
|
| 658 |
+
transition={{
|
| 659 |
+
duration: 0.3,
|
| 660 |
+
/* cap the stagger so deep rows don't appear seconds late */
|
| 661 |
+
delay: Math.min(index, 12) * 0.04,
|
| 662 |
+
ease: [0.22, 1, 0.36, 1],
|
| 663 |
+
}}
|
| 664 |
+
>
|
| 665 |
+
{/* rank + accent rail for #1 · soft accent rail for running rows */}
|
| 666 |
+
<td
|
| 667 |
+
className={clsx(
|
| 668 |
+
"num border-l-2 py-3 pr-1 pl-3 text-right align-top text-[13px]",
|
| 669 |
+
isTop
|
| 670 |
+
? "border-l-accent text-accent"
|
| 671 |
+
: running
|
| 672 |
+
? "border-l-accent/40 text-muted"
|
| 673 |
+
: "border-l-transparent text-muted",
|
| 674 |
+
)}
|
| 675 |
+
>
|
| 676 |
+
{rank ?? "—"}
|
| 677 |
+
</td>
|
| 678 |
+
|
| 679 |
+
{/* model */}
|
| 680 |
+
<td className="px-3 py-3 align-top">
|
| 681 |
+
<div className="flex items-center gap-2">
|
| 682 |
+
<Link
|
| 683 |
+
href={`/models/${m.slug}`}
|
| 684 |
+
title={m.note}
|
| 685 |
+
className={clsx(
|
| 686 |
+
"max-w-[26ch] truncate text-[13px] leading-snug lg:max-w-[36ch]",
|
| 687 |
+
dimmed ? "text-muted" : "text-text",
|
| 688 |
+
"transition-colors duration-150 ease-out hover:text-accent",
|
| 689 |
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
|
| 690 |
+
m.provenance === "paper-imported" &&
|
| 691 |
+
"underline decoration-line decoration-dashed underline-offset-4",
|
| 692 |
+
)}
|
| 693 |
+
>
|
| 694 |
+
{m.name}
|
| 695 |
+
</Link>
|
| 696 |
+
<FlagBadges flags={m.flags} className="shrink-0" />
|
| 697 |
+
</div>
|
| 698 |
+
<div className="mt-0.5 text-[11px] text-muted">{m.org}</div>
|
| 699 |
+
</td>
|
| 700 |
+
|
| 701 |
+
{/* params — split out of the org sub-line into its own column */}
|
| 702 |
+
<td className="num px-2 py-3 text-right align-top text-[12px]">
|
| 703 |
+
{m.paramsB !== undefined ? (
|
| 704 |
+
<span className={dimmed ? "text-muted" : "text-text"}>
|
| 705 |
+
{fmtParams(m.paramsB)}
|
| 706 |
+
</span>
|
| 707 |
+
) : (
|
| 708 |
+
<span className="text-muted/70" aria-label="parameter count unknown">
|
| 709 |
+
—
|
| 710 |
+
</span>
|
| 711 |
+
)}
|
| 712 |
+
</td>
|
| 713 |
+
|
| 714 |
+
{/* track */}
|
| 715 |
+
<td className="px-2 py-3 align-top">
|
| 716 |
+
<Badge variant="outline" title={trackLabels[m.track]}>
|
| 717 |
+
{m.track}
|
| 718 |
+
</Badge>
|
| 719 |
+
</td>
|
| 720 |
+
|
| 721 |
+
{/* license */}
|
| 722 |
+
<td className="num max-w-[14ch] px-2 py-3 align-top text-[11px] leading-[1.45] break-words text-muted">
|
| 723 |
+
{m.license}
|
| 724 |
+
</td>
|
| 725 |
+
|
| 726 |
+
{/* WER — the headline number, with fixed-scale range bar */}
|
| 727 |
+
<td className="px-2 py-3 text-right align-top">
|
| 728 |
+
{typeof s?.wer === "number" ? (
|
| 729 |
+
<div className="flex flex-col items-end gap-1">
|
| 730 |
+
<span
|
| 731 |
+
className={clsx(
|
| 732 |
+
"num text-[15px] leading-none",
|
| 733 |
+
isTop ? "text-accent" : "text-text",
|
| 734 |
+
)}
|
| 735 |
+
title={
|
| 736 |
+
s.werCi
|
| 737 |
+
? `95% CI ${fmt(s.werCi[0])}–${fmt(s.werCi[1])}`
|
| 738 |
+
: undefined
|
| 739 |
+
}
|
| 740 |
+
>
|
| 741 |
+
{fmt(s.wer)}
|
| 742 |
+
</span>
|
| 743 |
+
<CIBar value={s.wer} ci={s.werCi} />
|
| 744 |
+
</div>
|
| 745 |
+
) : (
|
| 746 |
+
missing("wer")
|
| 747 |
+
)}
|
| 748 |
+
</td>
|
| 749 |
+
|
| 750 |
+
{/* CS-WER — the code-switching metric gets the same micro-bar as WER */}
|
| 751 |
+
<td className="px-2 py-3 text-right align-top">
|
| 752 |
+
{typeof s?.csWer === "number" ? (
|
| 753 |
+
<div className="flex flex-col items-end gap-1">
|
| 754 |
+
<span
|
| 755 |
+
className="num text-[13px] leading-none text-text"
|
| 756 |
+
title={
|
| 757 |
+
s.csWerCi
|
| 758 |
+
? `95% CI ${fmt(s.csWerCi[0])}–${fmt(s.csWerCi[1])}`
|
| 759 |
+
: undefined
|
| 760 |
+
}
|
| 761 |
+
>
|
| 762 |
+
{fmt(s.csWer)}
|
| 763 |
+
</span>
|
| 764 |
+
<CIBar value={s.csWer} ci={s.csWerCi} />
|
| 765 |
+
</div>
|
| 766 |
+
) : (
|
| 767 |
+
missing("csWer")
|
| 768 |
+
)}
|
| 769 |
+
</td>
|
| 770 |
+
|
| 771 |
+
{/* N-WER */}
|
| 772 |
+
{show("nWer") && (
|
| 773 |
+
<td className="num px-2 py-3 text-right align-top text-[12px] text-text">
|
| 774 |
+
{typeof s?.nWer === "number" ? fmt(s.nWer) : missing("nWer")}
|
| 775 |
+
</td>
|
| 776 |
+
)}
|
| 777 |
+
|
| 778 |
+
{/* CER */}
|
| 779 |
+
{show("cer") && (
|
| 780 |
+
<td className="num px-2 py-3 text-right align-top text-[12px] text-text">
|
| 781 |
+
{typeof s?.cer === "number" ? fmt(s.cer) : missing("cer")}
|
| 782 |
+
</td>
|
| 783 |
+
)}
|
| 784 |
+
|
| 785 |
+
{/* MTR exact / fuzzy, stacked */}
|
| 786 |
+
{show("mtrExact") && (
|
| 787 |
+
<td className="px-2 py-3 text-right align-top">
|
| 788 |
+
{typeof s?.mtrExact === "number" ||
|
| 789 |
+
typeof s?.mtrFuzzy === "number" ? (
|
| 790 |
+
<div className="flex flex-col items-end gap-0.5">
|
| 791 |
+
<span className="num text-[12px] leading-none text-text">
|
| 792 |
+
{fmt(s?.mtrExact, 1)}
|
| 793 |
+
</span>
|
| 794 |
+
<span
|
| 795 |
+
className="num text-[11px] leading-none text-muted"
|
| 796 |
+
title="Fuzzy tier"
|
| 797 |
+
>
|
| 798 |
+
{fmt(s?.mtrFuzzy, 1)} fz
|
| 799 |
+
</span>
|
| 800 |
+
</div>
|
| 801 |
+
) : (
|
| 802 |
+
missing("mtrExact")
|
| 803 |
+
)}
|
| 804 |
+
</td>
|
| 805 |
+
)}
|
| 806 |
+
|
| 807 |
+
{/* RTFx */}
|
| 808 |
+
{show("rtfx") && (
|
| 809 |
+
<td className="num px-2 py-3 text-right align-top text-[12px] text-text">
|
| 810 |
+
{typeof s?.rtfx === "number" ? (
|
| 811 |
+
<>
|
| 812 |
+
{fmt(s.rtfx, 1)}
|
| 813 |
+
<span className="text-muted">×</span>
|
| 814 |
+
</>
|
| 815 |
+
) : (
|
| 816 |
+
missing("rtfx")
|
| 817 |
+
)}
|
| 818 |
+
</td>
|
| 819 |
+
)}
|
| 820 |
+
|
| 821 |
+
{/* collapsed empty columns: one narrow ghost cell */}
|
| 822 |
+
{ghostCell !== undefined && (
|
| 823 |
+
<td className="px-2 py-3 text-right align-top">
|
| 824 |
+
{ghostCell !== "" && (
|
| 825 |
+
<span
|
| 826 |
+
className="num text-[11px] tracking-[0.2em] text-muted/40"
|
| 827 |
+
title={`${ghostCell} — awaiting platform runs`}
|
| 828 |
+
aria-label={`${ghostCell}: awaiting platform runs`}
|
| 829 |
+
>
|
| 830 |
+
· · ·
|
| 831 |
+
</span>
|
| 832 |
+
)}
|
| 833 |
+
</td>
|
| 834 |
+
)}
|
| 835 |
+
|
| 836 |
+
{/* provenance */}
|
| 837 |
+
<td className="py-3 pr-3 pl-2 text-right align-top">
|
| 838 |
+
<ProvenanceChip provenance={m.provenance} />
|
| 839 |
+
</td>
|
| 840 |
+
</motion.tr>
|
| 841 |
+
);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
/**
|
| 845 |
+
* Evidence-gap placeholder: "…" for in-flight runs, a dotted ghost dash for
|
| 846 |
+
* paper-imported gaps (footnote [2] — the paper never published the value),
|
| 847 |
+
* an em-dash otherwise. `dim` drops fully-empty columns to whisper weight.
|
| 848 |
+
*/
|
| 849 |
+
function MissingCell({
|
| 850 |
+
running,
|
| 851 |
+
provenance,
|
| 852 |
+
dim,
|
| 853 |
+
}: {
|
| 854 |
+
running: boolean;
|
| 855 |
+
provenance: ModelRow["provenance"];
|
| 856 |
+
dim?: boolean;
|
| 857 |
+
}) {
|
| 858 |
+
if (running) {
|
| 859 |
+
return (
|
| 860 |
+
<span
|
| 861 |
+
className="num text-[12px] text-muted/80"
|
| 862 |
+
aria-label="run in progress"
|
| 863 |
+
>
|
| 864 |
+
…
|
| 865 |
+
</span>
|
| 866 |
+
);
|
| 867 |
+
}
|
| 868 |
+
if (provenance === "paper-imported") {
|
| 869 |
+
return (
|
| 870 |
+
<span
|
| 871 |
+
role="img"
|
| 872 |
+
title="not published in source paper"
|
| 873 |
+
aria-label="not published in source paper"
|
| 874 |
+
className={clsx(
|
| 875 |
+
"inline-block w-4 border-b border-dotted align-middle",
|
| 876 |
+
dim ? "border-muted/40" : "border-muted/70",
|
| 877 |
+
)}
|
| 878 |
+
/>
|
| 879 |
+
);
|
| 880 |
+
}
|
| 881 |
+
return (
|
| 882 |
+
<span
|
| 883 |
+
className={clsx(
|
| 884 |
+
"num text-[12px]",
|
| 885 |
+
dim ? "text-muted/40" : "text-muted/70",
|
| 886 |
+
)}
|
| 887 |
+
aria-label="not measured"
|
| 888 |
+
>
|
| 889 |
+
—
|
| 890 |
+
</span>
|
| 891 |
+
);
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
/* ------------------------------------------------------------------ */
|
| 895 |
+
/* Mobile row (below md): rank + name, then WER — the headline metric */
|
| 896 |
+
/* stays on screen without horizontal scroll */
|
| 897 |
+
/* ------------------------------------------------------------------ */
|
| 898 |
+
|
| 899 |
+
function MobileRow({
|
| 900 |
+
model: m,
|
| 901 |
+
split,
|
| 902 |
+
rank,
|
| 903 |
+
}: {
|
| 904 |
+
model: ModelRow;
|
| 905 |
+
split: Split;
|
| 906 |
+
rank?: number;
|
| 907 |
+
}) {
|
| 908 |
+
const s = m.metrics?.[split];
|
| 909 |
+
const isTop = rank === 1;
|
| 910 |
+
const running = m.provenance === "running";
|
| 911 |
+
const dimmed = rank === undefined;
|
| 912 |
+
|
| 913 |
+
return (
|
| 914 |
+
<li
|
| 915 |
+
className={clsx(
|
| 916 |
+
"spotlight-row border-l-2 px-3 py-3",
|
| 917 |
+
isTop
|
| 918 |
+
? "border-l-accent"
|
| 919 |
+
: running
|
| 920 |
+
? "border-l-accent/40"
|
| 921 |
+
: "border-l-transparent",
|
| 922 |
+
)}
|
| 923 |
+
>
|
| 924 |
+
<div className="flex items-center gap-2">
|
| 925 |
+
<span
|
| 926 |
+
className={clsx(
|
| 927 |
+
"num w-5 shrink-0 text-right text-[13px]",
|
| 928 |
+
isTop ? "text-accent" : "text-muted",
|
| 929 |
+
)}
|
| 930 |
+
>
|
| 931 |
+
{rank ?? "—"}
|
| 932 |
+
</span>
|
| 933 |
+
<Link
|
| 934 |
+
href={`/models/${m.slug}`}
|
| 935 |
+
title={m.note}
|
| 936 |
+
className={clsx(
|
| 937 |
+
"min-w-0 line-clamp-2 text-[13px] leading-snug",
|
| 938 |
+
dimmed ? "text-muted" : "text-text",
|
| 939 |
+
"transition-colors duration-150 ease-out hover:text-accent",
|
| 940 |
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
|
| 941 |
+
m.provenance === "paper-imported" &&
|
| 942 |
+
"underline decoration-line decoration-dashed underline-offset-4",
|
| 943 |
+
)}
|
| 944 |
+
>
|
| 945 |
+
{m.name}
|
| 946 |
+
</Link>
|
| 947 |
+
<FlagBadges flags={m.flags} className="shrink-0" />
|
| 948 |
+
</div>
|
| 949 |
+
<div className="mt-1.5 flex items-end justify-between gap-3 pl-7">
|
| 950 |
+
<div className="min-w-0 text-[11px] text-muted">
|
| 951 |
+
<p className="truncate">
|
| 952 |
+
{m.org}
|
| 953 |
+
{m.paramsB !== undefined && (
|
| 954 |
+
<span className="num"> · {fmtParams(m.paramsB)}</span>
|
| 955 |
+
)}
|
| 956 |
+
</p>
|
| 957 |
+
<div className="mt-1.5">
|
| 958 |
+
<ProvenanceChip provenance={m.provenance} />
|
| 959 |
+
</div>
|
| 960 |
+
</div>
|
| 961 |
+
<div className="flex shrink-0 flex-col items-end gap-1">
|
| 962 |
+
{typeof s?.wer === "number" ? (
|
| 963 |
+
<>
|
| 964 |
+
<span
|
| 965 |
+
className={clsx(
|
| 966 |
+
"num text-[15px] leading-none",
|
| 967 |
+
isTop ? "text-accent" : "text-text",
|
| 968 |
+
)}
|
| 969 |
+
title={
|
| 970 |
+
s.werCi
|
| 971 |
+
? `95% CI ${fmt(s.werCi[0])}–${fmt(s.werCi[1])}`
|
| 972 |
+
: undefined
|
| 973 |
+
}
|
| 974 |
+
>
|
| 975 |
+
{fmt(s.wer)}
|
| 976 |
+
<span className="ml-1 text-[10.5px] tracking-wide text-muted">
|
| 977 |
+
WER
|
| 978 |
+
</span>
|
| 979 |
+
</span>
|
| 980 |
+
<CIBar value={s.wer} ci={s.werCi} />
|
| 981 |
+
{/* CS-WER — the benchmark's defining metric stays on mobile */}
|
| 982 |
+
{typeof s.csWer === "number" && (
|
| 983 |
+
<>
|
| 984 |
+
<span
|
| 985 |
+
className="num mt-1 text-[12px] leading-none text-text"
|
| 986 |
+
title={
|
| 987 |
+
s.csWerCi
|
| 988 |
+
? `95% CI ${fmt(s.csWerCi[0])}–${fmt(s.csWerCi[1])}`
|
| 989 |
+
: undefined
|
| 990 |
+
}
|
| 991 |
+
>
|
| 992 |
+
{fmt(s.csWer)}
|
| 993 |
+
<span className="ml-1 text-[10.5px] tracking-wide text-muted">
|
| 994 |
+
CS-WER
|
| 995 |
+
</span>
|
| 996 |
+
</span>
|
| 997 |
+
<CIBar value={s.csWer} ci={s.csWerCi} />
|
| 998 |
+
</>
|
| 999 |
+
)}
|
| 1000 |
+
</>
|
| 1001 |
+
) : (
|
| 1002 |
+
<span
|
| 1003 |
+
className="num text-[12px] text-muted/60"
|
| 1004 |
+
aria-label={running ? "run in progress" : "not measured"}
|
| 1005 |
+
>
|
| 1006 |
+
{running ? "…" : "—"}
|
| 1007 |
+
</span>
|
| 1008 |
+
)}
|
| 1009 |
+
</div>
|
| 1010 |
+
</div>
|
| 1011 |
+
</li>
|
| 1012 |
+
);
|
| 1013 |
+
}
|
src/components/model/DatasetScope.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link";
|
| 2 |
+
import { ArrowUpRight } from "lucide-react";
|
| 3 |
+
import type { DatasetInfo } from "@/lib/types";
|
| 4 |
+
|
| 5 |
+
export interface DatasetScopeProps {
|
| 6 |
+
dataset: DatasetInfo;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Scope bar pinned under the model header: MedASR Bench is a multi-dataset
|
| 11 |
+
* hub, so every number on a model page is measured on exactly ONE dataset.
|
| 12 |
+
* Today that is the anchor (ViMedCSS); when a second dataset goes live this
|
| 13 |
+
* bar is the natural home for a dataset switcher.
|
| 14 |
+
*/
|
| 15 |
+
export default function DatasetScope({ dataset }: DatasetScopeProps) {
|
| 16 |
+
const stats = [
|
| 17 |
+
typeof dataset.hours === "number" ? `${dataset.hours} h` : null,
|
| 18 |
+
typeof dataset.utterances === "number"
|
| 19 |
+
? `${dataset.utterances.toLocaleString("en-US")} utts`
|
| 20 |
+
: null,
|
| 21 |
+
typeof dataset.csTerms === "number"
|
| 22 |
+
? `${dataset.csTerms} CS terms`
|
| 23 |
+
: null,
|
| 24 |
+
].filter((s): s is string => s !== null);
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div className="glass depth-1 flex flex-wrap items-center justify-between gap-x-6 gap-y-2 rounded-sm px-4 py-3">
|
| 28 |
+
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
| 29 |
+
<span className="num text-[11px] uppercase tracking-[0.18em] text-muted">
|
| 30 |
+
Results on
|
| 31 |
+
</span>
|
| 32 |
+
<span className="font-display text-lg leading-none text-text">
|
| 33 |
+
{dataset.name}
|
| 34 |
+
</span>
|
| 35 |
+
{dataset.fullName && (
|
| 36 |
+
<span className="text-[13px] text-muted">{dataset.fullName}</span>
|
| 37 |
+
)}
|
| 38 |
+
{stats.length > 0 && (
|
| 39 |
+
<span className="num text-[11px] text-muted">
|
| 40 |
+
{stats.join(" · ")}
|
| 41 |
+
</span>
|
| 42 |
+
)}
|
| 43 |
+
</div>
|
| 44 |
+
<Link
|
| 45 |
+
href={`/datasets/${dataset.id}`}
|
| 46 |
+
className="num inline-flex items-center gap-1 text-[12px] tracking-wide text-text transition-colors duration-200 ease-out hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
| 47 |
+
>
|
| 48 |
+
Dataset profile
|
| 49 |
+
<ArrowUpRight size={13} aria-hidden />
|
| 50 |
+
</Link>
|
| 51 |
+
</div>
|
| 52 |
+
);
|
| 53 |
+
}
|
src/components/model/FlagNotes.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
import type { ComponentType } from "react";
|
| 3 |
+
import { AlertTriangle, Lock, ShieldCheck, Snowflake } from "lucide-react";
|
| 4 |
+
import type { ModelFlags } from "@/lib/types";
|
| 5 |
+
|
| 6 |
+
export interface FlagNotesProps {
|
| 7 |
+
flags?: ModelFlags;
|
| 8 |
+
className?: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface Note {
|
| 12 |
+
key: keyof ModelFlags;
|
| 13 |
+
Icon: ComponentType<{ size?: number | string; className?: string }>;
|
| 14 |
+
iconClass: string;
|
| 15 |
+
labelClass: string;
|
| 16 |
+
label: string;
|
| 17 |
+
text: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const NOTES: Note[] = [
|
| 21 |
+
{
|
| 22 |
+
key: "contamination",
|
| 23 |
+
Icon: AlertTriangle,
|
| 24 |
+
iconClass: "text-warn",
|
| 25 |
+
labelClass: "text-warn",
|
| 26 |
+
label: "Contamination",
|
| 27 |
+
text: "this model’s family seeded the ViMedCSS labels (Gemini 2.5 Pro, then human-verified at κ 0.65), so its scores carry a permanent contamination flag.",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
key: "referenceOnly",
|
| 31 |
+
Icon: Lock,
|
| 32 |
+
iconClass: "text-muted",
|
| 33 |
+
labelClass: "text-text",
|
| 34 |
+
label: "Reference-only",
|
| 35 |
+
text: "CC-BY-NC-ND — cannot be fine-tuned or deployed commercially; shown as a yardstick.",
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
key: "frozen",
|
| 39 |
+
Icon: Snowflake,
|
| 40 |
+
iconClass: "text-muted",
|
| 41 |
+
labelClass: "text-text",
|
| 42 |
+
label: "Frozen",
|
| 43 |
+
text: "the maintaining organization has stopped updates; results are pinned to the last public checkpoint.",
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
key: "zone1",
|
| 47 |
+
Icon: ShieldCheck,
|
| 48 |
+
iconClass: "text-accent",
|
| 49 |
+
labelClass: "text-text",
|
| 50 |
+
label: "Zone 1",
|
| 51 |
+
text: "self-hostable on a single 24 GB GPU with a license clean for deployment.",
|
| 52 |
+
},
|
| 53 |
+
];
|
| 54 |
+
|
| 55 |
+
/** Plain-language explanations for each active flag (pairs with FlagBadges icons). */
|
| 56 |
+
export default function FlagNotes({ flags, className }: FlagNotesProps) {
|
| 57 |
+
if (!flags) return null;
|
| 58 |
+
const active = NOTES.filter((n) => flags[n.key]);
|
| 59 |
+
if (active.length === 0) return null;
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<ul className={clsx("flex flex-col gap-2", className)}>
|
| 63 |
+
{active.map(({ key, Icon, iconClass, labelClass, label, text }) => (
|
| 64 |
+
<li
|
| 65 |
+
key={key}
|
| 66 |
+
className="flex items-start gap-2.5 text-[13px] leading-relaxed text-muted"
|
| 67 |
+
>
|
| 68 |
+
<span aria-hidden className={clsx("mt-[3px] shrink-0", iconClass)}>
|
| 69 |
+
<Icon size={14} />
|
| 70 |
+
</span>
|
| 71 |
+
<span>
|
| 72 |
+
<span className={clsx("font-medium", labelClass)}>{label}</span>{" "}
|
| 73 |
+
— {text}
|
| 74 |
+
</span>
|
| 75 |
+
</li>
|
| 76 |
+
))}
|
| 77 |
+
</ul>
|
| 78 |
+
);
|
| 79 |
+
}
|
src/components/model/FrontierThumb.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
frontierSegments,
|
| 3 |
+
niceDomain,
|
| 4 |
+
paretoData,
|
| 5 |
+
paretoFrontier,
|
| 6 |
+
} from "@/components/compare/compare-utils";
|
| 7 |
+
import { fmt } from "@/lib/data";
|
| 8 |
+
import type { ModelRow } from "@/lib/types";
|
| 9 |
+
|
| 10 |
+
export interface FrontierThumbProps {
|
| 11 |
+
models: ModelRow[];
|
| 12 |
+
/** slug of the model this page profiles — drawn in accent */
|
| 13 |
+
slug: string;
|
| 14 |
+
/** dataset the frontier is computed on, e.g. "ViMedCSS" */
|
| 15 |
+
datasetName?: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const W = 232;
|
| 19 |
+
const H = 192;
|
| 20 |
+
const PAD = 12;
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* "Position on the frontier" thumbnail: the Pareto domain (Test split,
|
| 24 |
+
* WER × CS-WER) with this model's dot in accent and every other system
|
| 25 |
+
* ghosted. Pure inline SVG — no chart runtime.
|
| 26 |
+
*/
|
| 27 |
+
export default function FrontierThumb({
|
| 28 |
+
models,
|
| 29 |
+
slug,
|
| 30 |
+
datasetName,
|
| 31 |
+
}: FrontierThumbProps) {
|
| 32 |
+
const data = paretoData(models, "test");
|
| 33 |
+
const self = data.find((d) => d.model.slug === slug);
|
| 34 |
+
if (!self || data.length < 2) return null;
|
| 35 |
+
|
| 36 |
+
const [xLo, xHi] = niceDomain(data.map((d) => d.wer));
|
| 37 |
+
const [yLo, yHi] = niceDomain(data.map((d) => d.csWer));
|
| 38 |
+
const sx = (v: number) => PAD + ((v - xLo) / (xHi - xLo)) * (W - 2 * PAD);
|
| 39 |
+
const sy = (v: number) =>
|
| 40 |
+
PAD + (1 - (v - yLo) / (yHi - yLo)) * (H - 2 * PAD);
|
| 41 |
+
|
| 42 |
+
const frontier = paretoFrontier(data);
|
| 43 |
+
const segments = frontierSegments(frontier, xHi, yHi);
|
| 44 |
+
const onFrontier = frontier.some((d) => d.model.slug === slug);
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<aside
|
| 48 |
+
aria-label="Position on the Pareto frontier"
|
| 49 |
+
className="flex flex-col gap-4"
|
| 50 |
+
>
|
| 51 |
+
<p className="num text-[11px] uppercase tracking-[0.18em] text-muted">
|
| 52 |
+
Position on the frontier
|
| 53 |
+
</p>
|
| 54 |
+
<div className="hairline depth-1 rounded-sm bg-surface p-4">
|
| 55 |
+
<svg
|
| 56 |
+
viewBox={`0 0 ${W} ${H}`}
|
| 57 |
+
className="block w-full"
|
| 58 |
+
role="img"
|
| 59 |
+
aria-label={`Thumbnail scatter of the WER versus CS-WER trade-off on the ${datasetName ? `${datasetName} ` : ""}Test split. This model sits at ${fmt(self.wer)} WER and ${fmt(self.csWer)} CS-WER${onFrontier ? " and defines the Pareto frontier" : ""}.`}
|
| 60 |
+
>
|
| 61 |
+
{/* plot frame */}
|
| 62 |
+
<rect
|
| 63 |
+
x={PAD}
|
| 64 |
+
y={PAD}
|
| 65 |
+
width={W - 2 * PAD}
|
| 66 |
+
height={H - 2 * PAD}
|
| 67 |
+
fill="none"
|
| 68 |
+
stroke="var(--line)"
|
| 69 |
+
strokeWidth={1}
|
| 70 |
+
/>
|
| 71 |
+
{/* frontier staircase */}
|
| 72 |
+
{segments.map(([a, b], i) => (
|
| 73 |
+
<line
|
| 74 |
+
key={i}
|
| 75 |
+
x1={sx(a.x)}
|
| 76 |
+
y1={sy(a.y)}
|
| 77 |
+
x2={sx(b.x)}
|
| 78 |
+
y2={sy(b.y)}
|
| 79 |
+
stroke="var(--accent)"
|
| 80 |
+
strokeWidth={1}
|
| 81 |
+
strokeDasharray="3 2.5"
|
| 82 |
+
strokeOpacity={0.55}
|
| 83 |
+
/>
|
| 84 |
+
))}
|
| 85 |
+
{/* ghosted peers */}
|
| 86 |
+
{data
|
| 87 |
+
.filter((d) => d.model.slug !== slug)
|
| 88 |
+
.map((d) => (
|
| 89 |
+
<circle
|
| 90 |
+
key={d.model.slug}
|
| 91 |
+
cx={sx(d.wer)}
|
| 92 |
+
cy={sy(d.csWer)}
|
| 93 |
+
r={3}
|
| 94 |
+
style={{ fill: "var(--muted)" }}
|
| 95 |
+
fillOpacity={0.45}
|
| 96 |
+
/>
|
| 97 |
+
))}
|
| 98 |
+
{/* this model */}
|
| 99 |
+
<circle
|
| 100 |
+
cx={sx(self.wer)}
|
| 101 |
+
cy={sy(self.csWer)}
|
| 102 |
+
r={8}
|
| 103 |
+
fill="none"
|
| 104 |
+
stroke="var(--accent)"
|
| 105 |
+
strokeOpacity={0.5}
|
| 106 |
+
strokeWidth={1}
|
| 107 |
+
/>
|
| 108 |
+
<circle
|
| 109 |
+
cx={sx(self.wer)}
|
| 110 |
+
cy={sy(self.csWer)}
|
| 111 |
+
r={4.5}
|
| 112 |
+
style={{ fill: "var(--accent)" }}
|
| 113 |
+
fillOpacity={0.3}
|
| 114 |
+
stroke="var(--accent)"
|
| 115 |
+
strokeWidth={1.25}
|
| 116 |
+
/>
|
| 117 |
+
<circle
|
| 118 |
+
cx={sx(self.wer)}
|
| 119 |
+
cy={sy(self.csWer)}
|
| 120 |
+
r={1.6}
|
| 121 |
+
style={{ fill: "var(--accent)" }}
|
| 122 |
+
/>
|
| 123 |
+
{/* axis cues */}
|
| 124 |
+
<text
|
| 125 |
+
x={W - PAD}
|
| 126 |
+
y={H - 2}
|
| 127 |
+
textAnchor="end"
|
| 128 |
+
style={{
|
| 129 |
+
fill: "var(--muted)",
|
| 130 |
+
fontSize: 9,
|
| 131 |
+
letterSpacing: "0.14em",
|
| 132 |
+
fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
|
| 133 |
+
}}
|
| 134 |
+
>
|
| 135 |
+
WER % · ← BETTER
|
| 136 |
+
</text>
|
| 137 |
+
<text
|
| 138 |
+
x={8}
|
| 139 |
+
y={PAD + 4}
|
| 140 |
+
textAnchor="end"
|
| 141 |
+
transform={`rotate(-90 8 ${PAD + 4})`}
|
| 142 |
+
style={{
|
| 143 |
+
fill: "var(--muted)",
|
| 144 |
+
fontSize: 9,
|
| 145 |
+
letterSpacing: "0.14em",
|
| 146 |
+
fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
|
| 147 |
+
}}
|
| 148 |
+
>
|
| 149 |
+
CS-WER % · ↓ BETTER
|
| 150 |
+
</text>
|
| 151 |
+
</svg>
|
| 152 |
+
<p className="num mt-3 text-[11px] tracking-wide text-muted">
|
| 153 |
+
<span className="text-accent">●</span> this model ·{" "}
|
| 154 |
+
{fmt(self.wer)} WER / {fmt(self.csWer)} CS-WER ·{" "}
|
| 155 |
+
{datasetName ? `${datasetName} · ` : ""}Test split
|
| 156 |
+
{onFrontier && (
|
| 157 |
+
<span className="text-accent"> · on the frontier</span>
|
| 158 |
+
)}
|
| 159 |
+
</p>
|
| 160 |
+
</div>
|
| 161 |
+
</aside>
|
| 162 |
+
);
|
| 163 |
+
}
|
src/components/model/GeneralizationStrip.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import clsx from "clsx";
|
| 2 |
+
import { FlaskConical } from "lucide-react";
|
| 3 |
+
import { fmt } from "@/lib/data";
|
| 4 |
+
import type { ModelRow, Provenance, SplitInfo, SplitMetrics } from "@/lib/types";
|
| 5 |
+
import SectionLabel from "./SectionLabel";
|
| 6 |
+
|
| 7 |
+
export interface GeneralizationStripProps {
|
| 8 |
+
model: ModelRow;
|
| 9 |
+
hardInfo: SplitInfo;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function hasNumbers(m?: SplitMetrics): boolean {
|
| 13 |
+
return !!m && Object.values(m).some((v) => typeof v === "number");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function DeltaTile({
|
| 17 |
+
label,
|
| 18 |
+
test,
|
| 19 |
+
hard,
|
| 20 |
+
}: {
|
| 21 |
+
label: string;
|
| 22 |
+
test?: number;
|
| 23 |
+
hard?: number;
|
| 24 |
+
}) {
|
| 25 |
+
const d =
|
| 26 |
+
typeof test === "number" && typeof hard === "number" ? hard - test : null;
|
| 27 |
+
const degrading = d !== null && d > 0;
|
| 28 |
+
return (
|
| 29 |
+
<div className="hairline depth-1 hover-lift flex min-w-[160px] flex-col gap-1 rounded-sm bg-surface px-4 py-3">
|
| 30 |
+
<span className="text-[11px] uppercase tracking-[0.14em] text-muted">
|
| 31 |
+
Δ {label}
|
| 32 |
+
</span>
|
| 33 |
+
<span
|
| 34 |
+
className={clsx(
|
| 35 |
+
"num text-2xl leading-none",
|
| 36 |
+
d === null ? "text-muted/50" : degrading ? "text-warn" : "text-text",
|
| 37 |
+
)}
|
| 38 |
+
>
|
| 39 |
+
{d === null ? "—" : `${d >= 0 ? "+" : "−"}${Math.abs(d).toFixed(2)}`}
|
| 40 |
+
</span>
|
| 41 |
+
<span className="num text-[11px] text-muted">
|
| 42 |
+
{fmt(test)} → {fmt(hard)}
|
| 43 |
+
</span>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function GhostTile({ label }: { label: string }) {
|
| 49 |
+
return (
|
| 50 |
+
<div
|
| 51 |
+
aria-hidden
|
| 52 |
+
className="flex min-w-[160px] flex-col gap-1 rounded-sm border border-dashed border-line px-4 py-3"
|
| 53 |
+
>
|
| 54 |
+
<span className="text-[11px] uppercase tracking-[0.14em] text-muted/70">
|
| 55 |
+
Δ {label}
|
| 56 |
+
</span>
|
| 57 |
+
<span className="num text-2xl leading-none text-muted/50">—</span>
|
| 58 |
+
<span className="num text-[11px] text-muted/50">test → hard</span>
|
| 59 |
+
</div>
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function gapCopy(provenance: Provenance): string {
|
| 64 |
+
if (provenance === "running")
|
| 65 |
+
return "This model’s pilot run is executing on Test + Hard right now — the delta lands here the moment it commits.";
|
| 66 |
+
if (provenance === "planned")
|
| 67 |
+
return "A platform run covering Test + Hard is on the roadmap for this model.";
|
| 68 |
+
return "Published results cover Test only; the platform pilot re-runs every model on Test + Hard, and this gap closes when its run lands.";
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Test → Hard generalization: signed delta tiles when both splits are
|
| 73 |
+
* measured (amber when degrading), otherwise a dashed evidence-gap callout
|
| 74 |
+
* with ghost tiles showing the shape of what will appear.
|
| 75 |
+
*/
|
| 76 |
+
export default function GeneralizationStrip({
|
| 77 |
+
model,
|
| 78 |
+
hardInfo,
|
| 79 |
+
}: GeneralizationStripProps) {
|
| 80 |
+
const test = model.metrics?.test;
|
| 81 |
+
const hard = model.metrics?.hard;
|
| 82 |
+
const measured = hasNumbers(test) && hasNumbers(hard);
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
<section className="flex flex-col gap-4">
|
| 86 |
+
<SectionLabel
|
| 87 |
+
index="03"
|
| 88 |
+
title="Generalization"
|
| 89 |
+
hint="Test → Hard delta"
|
| 90 |
+
/>
|
| 91 |
+
|
| 92 |
+
{measured ? (
|
| 93 |
+
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
| 94 |
+
<div className="flex flex-wrap gap-3">
|
| 95 |
+
<DeltaTile label="WER" test={test?.wer} hard={hard?.wer} />
|
| 96 |
+
<DeltaTile label="CS-WER" test={test?.csWer} hard={hard?.csWer} />
|
| 97 |
+
</div>
|
| 98 |
+
<p className="max-w-sm text-[13px] leading-relaxed text-muted">
|
| 99 |
+
The Hard split holds{" "}
|
| 100 |
+
<span className="num">{fmt(hardInfo.hours)}</span>
|
| 101 |
+
<span className="num text-[11px]">{" "}h</span> of rare and
|
| 102 |
+
unseen code-switched terms. A positive Δ (amber) means the model
|
| 103 |
+
degrades off the common-term distribution.
|
| 104 |
+
</p>
|
| 105 |
+
</div>
|
| 106 |
+
) : (
|
| 107 |
+
<div className="flex flex-col gap-5 rounded-sm border border-dashed border-line p-5 md:flex-row md:items-center md:justify-between md:p-6">
|
| 108 |
+
<div className="flex max-w-xl items-start gap-4">
|
| 109 |
+
<span aria-hidden className="mt-1 shrink-0 text-muted">
|
| 110 |
+
<FlaskConical size={18} />
|
| 111 |
+
</span>
|
| 112 |
+
<div>
|
| 113 |
+
<h3 className="font-display text-lg italic text-text">
|
| 114 |
+
Evidence gap — no Hard-split measurement yet
|
| 115 |
+
</h3>
|
| 116 |
+
<p className="mt-1.5 text-[13px] leading-relaxed text-muted">
|
| 117 |
+
The Hard split holds{" "}
|
| 118 |
+
<span className="num">{fmt(hardInfo.hours)}</span>
|
| 119 |
+
<span className="num text-[11px]">{" "}h</span> /{" "}
|
| 120 |
+
<span className="num">
|
| 121 |
+
{hardInfo.utts.toLocaleString("en-US")}
|
| 122 |
+
</span>{" "}
|
| 123 |
+
utterances of rare and unseen code-switched terms — the
|
| 124 |
+
generalization leaderboard. {gapCopy(model.provenance)}
|
| 125 |
+
</p>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
<div className="flex shrink-0 flex-wrap gap-3">
|
| 129 |
+
<GhostTile label="WER" />
|
| 130 |
+
<GhostTile label="CS-WER" />
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
</section>
|
| 135 |
+
);
|
| 136 |
+
}
|
src/components/model/MetricPanel.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import MetricCard from "@/components/ui/MetricCard";
|
| 5 |
+
import SplitToggle from "@/components/ui/SplitToggle";
|
| 6 |
+
import { NORMALIZER_VERSION, splitLabels } from "@/lib/data";
|
| 7 |
+
import type {
|
| 8 |
+
ModelRow,
|
| 9 |
+
Provenance,
|
| 10 |
+
Split,
|
| 11 |
+
SplitInfo,
|
| 12 |
+
SplitMetrics,
|
| 13 |
+
} from "@/lib/types";
|
| 14 |
+
import SectionLabel from "./SectionLabel";
|
| 15 |
+
|
| 16 |
+
export interface MetricPanelProps {
|
| 17 |
+
/** dataset these results are scoped to, e.g. "ViMedCSS" — the hub hosts
|
| 18 |
+
multiple datasets, so the section header names the one being shown */
|
| 19 |
+
datasetName: string;
|
| 20 |
+
metrics?: ModelRow["metrics"];
|
| 21 |
+
splits: { test: SplitInfo; hard: SplitInfo };
|
| 22 |
+
provenance: Provenance;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const VALUE_KEYS = [
|
| 26 |
+
"wer",
|
| 27 |
+
"csWer",
|
| 28 |
+
"nWer",
|
| 29 |
+
"cer",
|
| 30 |
+
"mtrExact",
|
| 31 |
+
"rtfx",
|
| 32 |
+
] as const;
|
| 33 |
+
|
| 34 |
+
const METRIC_CHIPS = ["WER", "CS-WER", "N-WER", "CER", "MTR", "RTFx"] as const;
|
| 35 |
+
|
| 36 |
+
function emptyNote(provenance: Provenance, split: Split): string {
|
| 37 |
+
if (provenance === "running")
|
| 38 |
+
return "No committed measurements yet — the pilot run executing now will populate this panel.";
|
| 39 |
+
if (provenance === "planned")
|
| 40 |
+
return "No measurements yet — this model is on the evaluation roadmap.";
|
| 41 |
+
return `No measurements recorded on the ${splitLabels[split]} split yet.`;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/** Per-card microcopy for a missing value, by row provenance. */
|
| 45 |
+
function gapReason(provenance: Provenance): string {
|
| 46 |
+
if (provenance === "paper-imported") return "not published";
|
| 47 |
+
if (provenance === "running" || provenance === "planned")
|
| 48 |
+
return "awaiting pilot run";
|
| 49 |
+
return "not recorded";
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Split-toggleable metric grid: WER / CS-WER / N-WER / CER / MTR (exact) /
|
| 54 |
+
* RTFx as count-up MetricCards, with 95% CI sub-lines where measured.
|
| 55 |
+
*/
|
| 56 |
+
export default function MetricPanel({
|
| 57 |
+
datasetName,
|
| 58 |
+
metrics,
|
| 59 |
+
splits,
|
| 60 |
+
provenance,
|
| 61 |
+
}: MetricPanelProps) {
|
| 62 |
+
const [split, setSplit] = useState<Split>("test");
|
| 63 |
+
const m: SplitMetrics | undefined = metrics?.[split];
|
| 64 |
+
const info = splits[split];
|
| 65 |
+
const hasAny = VALUE_KEYS.some((k) => typeof m?.[k] === "number");
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<section className="flex flex-col gap-4">
|
| 69 |
+
<div className="flex flex-wrap items-end justify-between gap-3">
|
| 70 |
+
<SectionLabel
|
| 71 |
+
index="02"
|
| 72 |
+
title={`Results — ${datasetName}`}
|
| 73 |
+
hint={`${splitLabels[split]} — ${info.hours} h · ${info.utts.toLocaleString("en-US")} utterances`}
|
| 74 |
+
/>
|
| 75 |
+
<SplitToggle value={split} onChange={setSplit} />
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{hasAny ? (
|
| 79 |
+
/* Stable keys: toggling splits updates values in place instead of
|
| 80 |
+
remounting cards and replaying the one-shot count-up. */
|
| 81 |
+
<div className="glass depth-2 grid grid-cols-2 gap-3 rounded-sm p-3 md:grid-cols-3">
|
| 82 |
+
<MetricCard
|
| 83 |
+
key="wer"
|
| 84 |
+
label="WER"
|
| 85 |
+
value={m?.wer}
|
| 86 |
+
suffix="%"
|
| 87 |
+
ci={m?.werCi}
|
| 88 |
+
gapReason={gapReason(provenance)}
|
| 89 |
+
/>
|
| 90 |
+
<MetricCard
|
| 91 |
+
key="cswer"
|
| 92 |
+
label="CS-WER"
|
| 93 |
+
value={m?.csWer}
|
| 94 |
+
suffix="%"
|
| 95 |
+
ci={m?.csWerCi}
|
| 96 |
+
gapReason={gapReason(provenance)}
|
| 97 |
+
/>
|
| 98 |
+
<MetricCard
|
| 99 |
+
key="nwer"
|
| 100 |
+
label="N-WER"
|
| 101 |
+
value={m?.nWer}
|
| 102 |
+
suffix="%"
|
| 103 |
+
gapReason={gapReason(provenance)}
|
| 104 |
+
/>
|
| 105 |
+
<MetricCard
|
| 106 |
+
key="cer"
|
| 107 |
+
label="CER"
|
| 108 |
+
value={m?.cer}
|
| 109 |
+
suffix="%"
|
| 110 |
+
gapReason={gapReason(provenance)}
|
| 111 |
+
/>
|
| 112 |
+
<MetricCard
|
| 113 |
+
key="mtr"
|
| 114 |
+
label="MTR (exact)"
|
| 115 |
+
value={m?.mtrExact}
|
| 116 |
+
suffix="%"
|
| 117 |
+
gapReason={gapReason(provenance)}
|
| 118 |
+
/>
|
| 119 |
+
<MetricCard
|
| 120 |
+
key="rtfx"
|
| 121 |
+
label="RTFx"
|
| 122 |
+
value={m?.rtfx}
|
| 123 |
+
suffix="×"
|
| 124 |
+
gapReason={gapReason(provenance)}
|
| 125 |
+
/>
|
| 126 |
+
</div>
|
| 127 |
+
) : (
|
| 128 |
+
/* flatlined-ECG empty state: one composed panel instead of a
|
| 129 |
+
checkerboard of em-dash cards */
|
| 130 |
+
<div className="hairline depth-1 rounded-sm bg-surface px-5 py-6">
|
| 131 |
+
<div aria-hidden className="flex items-center">
|
| 132 |
+
<span className="h-px flex-1 bg-accent/20" />
|
| 133 |
+
<span className="pulse-dot size-2 shrink-0 rounded-full bg-accent" />
|
| 134 |
+
</div>
|
| 135 |
+
<p className="mt-4 font-display text-lg italic text-text">
|
| 136 |
+
no trace yet — awaiting platform run
|
| 137 |
+
</p>
|
| 138 |
+
<p className="mt-1.5 max-w-xl text-[13px] leading-relaxed text-muted">
|
| 139 |
+
{emptyNote(provenance, split)}
|
| 140 |
+
</p>
|
| 141 |
+
<ul className="mt-4 flex flex-wrap gap-1.5" aria-label="Metrics this run will record">
|
| 142 |
+
{METRIC_CHIPS.map((c) => (
|
| 143 |
+
<li
|
| 144 |
+
key={c}
|
| 145 |
+
className="num rounded-sm border border-line/70 px-1.5 py-0.5 text-[11px] tracking-wide text-muted"
|
| 146 |
+
>
|
| 147 |
+
{c}
|
| 148 |
+
</li>
|
| 149 |
+
))}
|
| 150 |
+
</ul>
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
|
| 154 |
+
<p className="num text-[11px] text-muted">
|
| 155 |
+
WER family in %, lower is better · MTR higher is better · RTFx = audio
|
| 156 |
+
s ÷ wall s, higher is faster · normalizer {NORMALIZER_VERSION}
|
| 157 |
+
</p>
|
| 158 |
+
</section>
|
| 159 |
+
);
|
| 160 |
+
}
|
src/components/model/ModelHeader.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Badge from "@/components/ui/Badge";
|
| 2 |
+
import FlagBadges from "@/components/ui/FlagBadges";
|
| 3 |
+
import ProvenanceChip from "@/components/ui/ProvenanceChip";
|
| 4 |
+
import { categoryLabels, trackLabels } from "@/lib/data";
|
| 5 |
+
import type { ModelRow } from "@/lib/types";
|
| 6 |
+
import FlagNotes from "./FlagNotes";
|
| 7 |
+
|
| 8 |
+
export interface ModelHeaderProps {
|
| 9 |
+
model: ModelRow;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/** "0.6" → "0.6B"; undefined → "—". */
|
| 13 |
+
function fmtParams(paramsB?: number): string {
|
| 14 |
+
return paramsB === undefined ? "—" : `${paramsB}B`;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Model identity block: serif name, org, mono param count, license /
|
| 19 |
+
* track chips, provenance chip, flag icons + plain-language flag notes,
|
| 20 |
+
* and the editorial note line.
|
| 21 |
+
*/
|
| 22 |
+
export default function ModelHeader({ model }: ModelHeaderProps) {
|
| 23 |
+
return (
|
| 24 |
+
<header className="hairline-b pb-6 spring-up">
|
| 25 |
+
<p className="num mb-3 text-[11px] uppercase tracking-[0.22em] text-muted spring-in">
|
| 26 |
+
Model profile · {categoryLabels[model.category]}
|
| 27 |
+
</p>
|
| 28 |
+
|
| 29 |
+
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
|
| 30 |
+
<h1 className="font-display text-4xl leading-[1.05] text-text md:text-5xl spring-in" style={{ animationDelay: "60ms" }}>
|
| 31 |
+
{model.name}
|
| 32 |
+
</h1>
|
| 33 |
+
<div className="mb-1.5 spring-in" style={{ animationDelay: "90ms" }}>
|
| 34 |
+
<ProvenanceChip provenance={model.provenance} />
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 spring-in" style={{ animationDelay: "120ms" }}>
|
| 39 |
+
<span className="text-sm text-muted">{model.org}</span>
|
| 40 |
+
<span aria-hidden className="text-muted/40">
|
| 41 |
+
·
|
| 42 |
+
</span>
|
| 43 |
+
<span className="num text-sm text-text" title="Parameter count">
|
| 44 |
+
{fmtParams(model.paramsB)}
|
| 45 |
+
</span>
|
| 46 |
+
<Badge variant="outline" title={`License: ${model.license}`}>
|
| 47 |
+
{model.license}
|
| 48 |
+
</Badge>
|
| 49 |
+
<Badge variant="neutral" title={trackLabels[model.track]}>
|
| 50 |
+
{trackLabels[model.track]}
|
| 51 |
+
</Badge>
|
| 52 |
+
<FlagBadges flags={model.flags} />
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
{model.note && (
|
| 56 |
+
<p className="mt-4 font-display text-lg italic text-muted spring-in" style={{ animationDelay: "180ms" }}>
|
| 57 |
+
{model.note}
|
| 58 |
+
</p>
|
| 59 |
+
)}
|
| 60 |
+
|
| 61 |
+
<div className="mt-4 max-w-2xl spring-in" style={{ animationDelay: "240ms" }}>
|
| 62 |
+
<FlagNotes flags={model.flags} />
|
| 63 |
+
</div>
|
| 64 |
+
</header>
|
| 65 |
+
);
|
| 66 |
+
}
|